1.9
This commit is contained in:
parent
fc7c94bd69
commit
2cc2f003df
71 changed files with 3359 additions and 1027 deletions
|
|
@ -1,6 +0,0 @@
|
||||||
<IfModule mod_authz_core.c>
|
|
||||||
Require all denied
|
|
||||||
</IfModule>
|
|
||||||
<IfModule mod_access_compat.c>
|
|
||||||
Deny from all
|
|
||||||
</IfModule>
|
|
||||||
43
AI_MAP.md
43
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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`.
|
- 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.
|
- 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`.
|
- 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`.
|
||||||
- Fence restitution: `js/world_environment.js`; ball response: `js/item_dynamic_ball_system.js`.
|
- Achievement UI: `index.html` achievement dialog, `css/components.css` achievement rows/toast styles, and `js/achievements.js` DOM rendering.
|
||||||
- Ball-to-Tarinai contact, including sleepers: `js/collision_response_system.js`.
|
- Achievement spell payload: `TarinaiAchievements.exportSpellState()`, `snapshot.g.s`, `save_system.js` spell export inclusion, and `save_codec.js` achievement metadata readers/writers.
|
||||||
- Movable placement stacking: `js/item_tool_metadata.js`, `js/collision_footprint_system.js`, `js/world_placement_log.js`.
|
- Logic board: `js/circuit_board_system.js`, `js/signal_system.js`, signal-related item definitions/metadata, and the generated circuit editor markup/styles.
|
||||||
- Delete-tool target lookup: `js/world_tool_actions.js`.
|
- Shared play: `js/seesaw_system.js`, tarinai action/need modules, placement preview, and item/structure definitions.
|
||||||
- Nest-box tooltip offset: `js/render.js`.
|
- 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:
|
||||||
|
|
||||||
|
- 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.
|
||||||
## Achievement system (v39.16.60)
|
- 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`.
|
||||||
- `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.
|
- 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`.
|
||||||
- `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.
|
- 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.
|
||||||
- `css/components.css`: achievement rows use `--achievement-rate` to render horizontal background fills; unlocked rows use a distinct fill.
|
- `js/achievements.js` uses `renderIfDialogOpen()` for progress refreshes instead of repeating the hidden-dialog check at each event recorder.
|
||||||
- `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.
|
- `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.
|
||||||
- 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.
|
- `FILES.json` now includes JavaScript audit scripts and newer core bootstrap files (`domain_ids`, deterministic helpers, item registries/definitions) in machine routing.
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Binary schema 46 is the only accepted save/spell format; it stores the current achievement mask and sticky-bomb pass count.
|
||||||
- Version: 39.16.60
|
- Version: 39.16.74
|
||||||
- CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls.
|
- 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.
|
- 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.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.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.
|
||||||
|
|
|
||||||
19
FILES.json
19
FILES.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
|
"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": {
|
"entrypoints": {
|
||||||
"app": "index.html",
|
"app": "index.html",
|
||||||
"runtime": "js/main.js",
|
"runtime": "js/main.js",
|
||||||
|
|
@ -53,19 +53,28 @@
|
||||||
{
|
{
|
||||||
"id": "scripts",
|
"id": "scripts",
|
||||||
"patterns": [
|
"patterns": [
|
||||||
|
"scripts/*.js",
|
||||||
"scripts/*.py",
|
"scripts/*.py",
|
||||||
"scripts/*.ps1"
|
"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",
|
"id": "core",
|
||||||
"patterns": [
|
"patterns": [
|
||||||
"js/version.js",
|
"js/version.js",
|
||||||
"js/event_bus.js",
|
"js/event_bus.js",
|
||||||
|
"js/domain_ids.js",
|
||||||
"js/registry_base.js",
|
"js/registry_base.js",
|
||||||
"js/disease_registry.js",
|
"js/disease_registry.js",
|
||||||
"js/sound_pack.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/data.js",
|
||||||
"js/ground_types.js",
|
"js/ground_types.js",
|
||||||
"js/health.js",
|
"js/health.js",
|
||||||
|
|
@ -123,7 +132,8 @@
|
||||||
"js/item*.js",
|
"js/item*.js",
|
||||||
"js/items.js",
|
"js/items.js",
|
||||||
"js/structures.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."
|
"meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering."
|
||||||
},
|
},
|
||||||
|
|
@ -131,7 +141,8 @@
|
||||||
"id": "tarinai",
|
"id": "tarinai",
|
||||||
"patterns": [
|
"patterns": [
|
||||||
"js/tarinai*.js",
|
"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."
|
"meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## 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.
|
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)
|
## Achievement system (v39.16.74)
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0–100%, and the global achiever count is displayed beside it.
|
||||||
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
|
- `物でいっぱい` requires 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.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.
|
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day.
|
||||||
- Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
|
- Local unlocks and persistent totals live in browser storage. 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.
|
- 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.
|
- 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.
|
- 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.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.
|
||||||
|
|
|
||||||
33
README.md
33
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.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.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.
|
- 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.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.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.
|
- 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.
|
- 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)
|
## Achievement system (v39.16.74)
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- `自動化の第一歩` 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.
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
- 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.
|
- `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.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.
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,22 @@ const ACHIEVEMENT_IDS = [
|
||||||
'sniper_333_shots',
|
'sniper_333_shots',
|
||||||
'megalopolis',
|
'megalopolis',
|
||||||
'fertility_seeker_721_love_births',
|
'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_DIR_NAME = '.achievement_data';
|
||||||
const DATA_FILE_NAME = 'state.json';
|
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 {
|
function respond(array $payload, int $status = 200): never {
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
|
|
@ -78,16 +91,83 @@ function empty_state(): array {
|
||||||
return ['players' => [], 'unlocks' => []];
|
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 {
|
function normalize_state(mixed $value): array {
|
||||||
if (!is_array($value)) return empty_state();
|
if (!is_array($value)) return empty_state();
|
||||||
$players = [];
|
$players = [];
|
||||||
$unlocks = [];
|
$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 = [];
|
$playerIds = [];
|
||||||
foreach ($value['p'] as $row) {
|
foreach ($value['p'] as $row) {
|
||||||
if (!is_array($row) || !isset($row[0]) || !is_string($row[0]) || !valid_player_id($row[0])) continue;
|
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;
|
$playerIds[] = $id;
|
||||||
$players[$id] = [
|
$players[$id] = [
|
||||||
'firstSeen' => max(1, (int)($row[1] ?? 1)),
|
'firstSeen' => max(1, (int)($row[1] ?? 1)),
|
||||||
|
|
@ -96,7 +176,7 @@ function normalize_state(mixed $value): array {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
foreach ($value['u'] as $achievementId => $flat) {
|
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 = [];
|
$records = [];
|
||||||
$count = count($flat);
|
$count = count($flat);
|
||||||
for ($i = 0; $i + 1 < $count; $i += 2) {
|
for ($i = 0; $i + 1 < $count; $i += 2) {
|
||||||
|
|
@ -107,8 +187,24 @@ function normalize_state(mixed $value): array {
|
||||||
if ($records) $unlocks[$achievementId] = $records;
|
if ($records) $unlocks[$achievementId] = $records;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$players = isset($value['players']) && is_array($value['players']) ? $value['players'] : [];
|
$rawPlayers = isset($value['players']) && is_array($value['players']) ? $value['players'] : [];
|
||||||
$unlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : [];
|
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'])
|
$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']);
|
$playerIds = array_keys($state['players']);
|
||||||
sort($playerIds, SORT_STRING);
|
sort($playerIds, SORT_STRING);
|
||||||
$playerIndex = [];
|
$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 = [];
|
$players = [];
|
||||||
foreach ($playerIds as $index => $id) {
|
foreach ($playerIds as $index => $id) {
|
||||||
$playerIndex[$id] = $index;
|
$playerIndex[$id] = $index;
|
||||||
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
||||||
$players[] = [
|
$firstSeen = max(1, (int)($player['firstSeen'] ?? 1));
|
||||||
$id,
|
$lastSeen = max($firstSeen, (int)($player['lastSeen'] ?? $firstSeen));
|
||||||
max(1, (int)($player['firstSeen'] ?? 1)),
|
$version = substr((string)($player['gameVersion'] ?? ''), 0, 32);
|
||||||
max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))),
|
$row = [compact_player_id($id), $firstSeen - $baseTime, $lastSeen - $firstSeen];
|
||||||
substr((string)($player['gameVersion'] ?? ''), 0, 32),
|
$indexValue = (int)($versionIndex[$version] ?? 0);
|
||||||
];
|
if ($indexValue !== 0) $row[] = $indexValue;
|
||||||
|
$players[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
$unlocks = [];
|
$unlocks = [];
|
||||||
foreach ($state['unlocks'] as $achievementId => $records) {
|
foreach (ACHIEVEMENT_IDS as $achievementIndex => $achievementId) {
|
||||||
if (!is_string($achievementId) || !is_array($records) || !$records) continue;
|
$records = $state['unlocks'][$achievementId] ?? null;
|
||||||
$flat = [];
|
if (!is_array($records) || !$records) continue;
|
||||||
|
$pairs = [];
|
||||||
foreach ($records as $id => $timestamp) {
|
foreach ($records as $id => $timestamp) {
|
||||||
if (!isset($playerIndex[$id])) continue;
|
if (!isset($playerIndex[$id])) continue;
|
||||||
$flat[] = $playerIndex[$id];
|
$pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime];
|
||||||
$flat[] = max(1, (int)$timestamp);
|
|
||||||
}
|
}
|
||||||
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 {
|
function build_summary(array $state): array {
|
||||||
|
|
@ -180,10 +358,19 @@ function build_summary(array $state): array {
|
||||||
}
|
}
|
||||||
|
|
||||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||||
|
|
||||||
$payload = [];
|
$payload = [];
|
||||||
if ($method === 'POST') {
|
if ($method === 'POST') {
|
||||||
$raw = file_get_contents('php://input');
|
$declaredLength = max(0, (int)($_SERVER['CONTENT_LENGTH'] ?? 0));
|
||||||
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
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);
|
if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||||
$payload = $decoded;
|
$payload = $decoded;
|
||||||
}
|
}
|
||||||
|
|
@ -198,9 +385,13 @@ $playerId = $method === 'POST'
|
||||||
if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) {
|
if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) {
|
||||||
respond(['ok' => false, 'error' => 'invalid_action'], 400);
|
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)) {
|
if (!valid_player_id($playerId)) {
|
||||||
respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
||||||
}
|
}
|
||||||
|
$playerId = strtolower($playerId);
|
||||||
$syncUnlocks = [];
|
$syncUnlocks = [];
|
||||||
$completionistEligible = null;
|
$completionistEligible = null;
|
||||||
if ($action === 'unlock') {
|
if ($action === 'unlock') {
|
||||||
|
|
@ -227,11 +418,12 @@ if ($action === 'unlock') {
|
||||||
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||||
}
|
}
|
||||||
$numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT);
|
$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);
|
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||||
}
|
}
|
||||||
$syncUnlocks[$achievementId] = (float)$numericTimestamp;
|
$syncUnlocks[$achievementId] = (float)$numericTimestamp;
|
||||||
}
|
}
|
||||||
|
if ($completionistEligible === false) unset($syncUnlocks['true_tarinai_observer']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME;
|
$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);
|
respond(['ok' => false, 'error' => 'storage_unavailable'], 503);
|
||||||
}
|
}
|
||||||
$dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME;
|
$dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME;
|
||||||
$handle = fopen($dataPath, 'c+');
|
$lockPath = $dataDir . DIRECTORY_SEPARATOR . LOCK_FILE_NAME;
|
||||||
if ($handle === false || !flock($handle, LOCK_EX)) {
|
$lockHandle = @fopen($lockPath, 'c');
|
||||||
if (is_resource($handle)) fclose($handle);
|
if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) {
|
||||||
|
if (is_resource($lockHandle)) fclose($lockHandle);
|
||||||
respond(['ok' => false, 'error' => 'storage_locked'], 503);
|
respond(['ok' => false, 'error' => 'storage_locked'], 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$summary = null;
|
||||||
|
$storageError = null;
|
||||||
try {
|
try {
|
||||||
rewind($handle);
|
$contents = is_file($dataPath) ? file_get_contents($dataPath) : '';
|
||||||
$contents = stream_get_contents($handle);
|
if ($contents === false) throw new RuntimeException('storage_unavailable');
|
||||||
$state = normalize_state(json_decode(is_string($contents) ? $contents : '', true));
|
[$state, $sourceFormat] = decode_stored_state($contents);
|
||||||
$now = time();
|
$now = time();
|
||||||
$player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId])
|
$mutatesPlayer = $action !== 'summary';
|
||||||
? $state['players'][$playerId]
|
|
||||||
: ['firstSeen' => $now];
|
if ($mutatesPlayer) {
|
||||||
$player['lastSeen'] = $now;
|
$player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId])
|
||||||
$player['gameVersion'] = substr((string)($payload['gameVersion'] ?? ''), 0, 32);
|
? $state['players'][$playerId]
|
||||||
$state['players'][$playerId] = $player;
|
: ['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') {
|
if ($action === 'unlock') {
|
||||||
$achievementId = (string)$payload['achievementId'];
|
$achievementId = (string)$payload['achievementId'];
|
||||||
|
|
@ -285,14 +485,18 @@ try {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rewind($handle);
|
$encoded = encode_compact_state($state);
|
||||||
ftruncate($handle, 0);
|
if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) {
|
||||||
fwrite($handle, json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
atomic_write_state($dataPath, $encoded);
|
||||||
fflush($handle);
|
}
|
||||||
$summary = build_summary($state);
|
$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 {
|
} finally {
|
||||||
flock($handle, LOCK_UN);
|
flock($lockHandle, LOCK_UN);
|
||||||
fclose($handle);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "39.16.60",
|
"version": "39.16.74",
|
||||||
"css": [
|
"css": [
|
||||||
"css/base.css",
|
"css/base.css",
|
||||||
"css/layout.css",
|
"css/layout.css",
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"js/constraint_system.js",
|
"js/constraint_system.js",
|
||||||
"js/physics_world_system.js",
|
"js/physics_world_system.js",
|
||||||
"js/physics_projection_system.js",
|
"js/physics_projection_system.js",
|
||||||
|
"js/circuit_board_system.js",
|
||||||
"js/signal_system.js",
|
"js/signal_system.js",
|
||||||
"js/assets.js",
|
"js/assets.js",
|
||||||
"js/audio.js",
|
"js/audio.js",
|
||||||
|
|
@ -88,6 +89,7 @@
|
||||||
"js/tarinai_consumable_behavior.js",
|
"js/tarinai_consumable_behavior.js",
|
||||||
"js/tarinai_social_action_runtime.js",
|
"js/tarinai_social_action_runtime.js",
|
||||||
"js/tarinai_building_behavior.js",
|
"js/tarinai_building_behavior.js",
|
||||||
|
"js/seesaw_system.js",
|
||||||
"js/tarinai_action_definitions.js",
|
"js/tarinai_action_definitions.js",
|
||||||
"js/tarinai_needs_items.js",
|
"js/tarinai_needs_items.js",
|
||||||
"js/tarinai_food_prototype_mixin.js",
|
"js/tarinai_food_prototype_mixin.js",
|
||||||
|
|
@ -147,8 +149,8 @@
|
||||||
"js/ui.js",
|
"js/ui.js",
|
||||||
"js/game_dialogs.js",
|
"js/game_dialogs.js",
|
||||||
"js/achievements.js",
|
"js/achievements.js",
|
||||||
"js/ui_log.js",
|
|
||||||
"js/ui_helpers.js",
|
"js/ui_helpers.js",
|
||||||
|
"js/ui_log.js",
|
||||||
"js/ui_selected.js",
|
"js/ui_selected.js",
|
||||||
"js/save_schema.js",
|
"js/save_schema.js",
|
||||||
"js/snapshot_system.js",
|
"js/snapshot_system.js",
|
||||||
|
|
|
||||||
122
css/base.css
122
css/base.css
|
|
@ -917,3 +917,125 @@ h1 {
|
||||||
.save-confirm-dialog { z-index: 36; }
|
.save-confirm-dialog { z-index: 36; }
|
||||||
.save-confirm-panel { width: min(390px, 100%); }
|
.save-confirm-panel { width: min(390px, 100%); }
|
||||||
.save-confirm-message { margin-bottom: 8px; color: var(--ink); font-size: 14px; }
|
.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)}
|
||||||
|
|
|
||||||
|
|
@ -1128,7 +1128,7 @@
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #526b82;
|
color: #526b82;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 500;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.achievement-entry-meta {
|
.achievement-entry-meta {
|
||||||
|
|
|
||||||
354
index.html
354
index.html
|
|
@ -4,14 +4,14 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>たりない観察</title>
|
<title>たりない観察</title>
|
||||||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?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.60" />
|
<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.60" />
|
<link rel="shortcut icon" href="favicon.ico?v=39.16.74" />
|
||||||
<link rel="stylesheet" href="css/base.css?v=39.16.60" />
|
<link rel="stylesheet" href="css/base.css?v=39.16.74" />
|
||||||
<link rel="stylesheet" href="css/layout.css?v=39.16.60" />
|
<link rel="stylesheet" href="css/layout.css?v=39.16.74" />
|
||||||
<link rel="stylesheet" href="css/panel.css?v=39.16.60" />
|
<link rel="stylesheet" href="css/panel.css?v=39.16.74" />
|
||||||
<link rel="stylesheet" href="css/components.css?v=39.16.60" />
|
<link rel="stylesheet" href="css/components.css?v=39.16.74" />
|
||||||
<link rel="stylesheet" href="css/mobile.css?v=39.16.60" />
|
<link rel="stylesheet" href="css/mobile.css?v=39.16.74" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||||
<button id="speedBtn" class="btn">速度 x1</button>
|
<button id="speedBtn" class="btn">速度 x1</button>
|
||||||
<button id="saveBtn" class="btn">セーブ</button>
|
<button id="saveBtn" class="btn">セーブ</button>
|
||||||
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">実績 <span id="achievementButtonCount">0/51</span></button>
|
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">実績 <span id="achievementButtonCount">0/64</span></button>
|
||||||
<button id="resetBtn" class="btn danger reset-top-btn">リセット</button>
|
<button id="resetBtn" class="btn danger reset-top-btn">リセット</button>
|
||||||
<div id="topMenu" class="top-menu">
|
<div id="topMenu" class="top-menu">
|
||||||
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="メニュー">☰</button>
|
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="メニュー">☰</button>
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
<div class="achievement-dialog-head">
|
<div class="achievement-dialog-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="achievement-dialog-kicker">たりない観察</div>
|
<div class="achievement-dialog-kicker">たりない観察</div>
|
||||||
<h2 id="achievementsDialogTitle">実績 <span id="achievementDialogCount">0 / 51</span></h2>
|
<h2 id="achievementsDialogTitle">実績 <span id="achievementDialogCount">0 / 64</span></h2>
|
||||||
</div>
|
</div>
|
||||||
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="実績を閉じる">×</button>
|
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="実績を閉じる">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -292,171 +292,173 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></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/version.js?v=39.16.74" defer></script>
|
||||||
<script src="js/event_bus.js?v=39.16.60" defer></script>
|
<script src="js/event_bus.js?v=39.16.74" defer></script>
|
||||||
<script src="js/domain_ids.js?v=39.16.60" defer></script>
|
<script src="js/domain_ids.js?v=39.16.74" defer></script>
|
||||||
<script src="js/registry_base.js?v=39.16.60" defer></script>
|
<script src="js/registry_base.js?v=39.16.74" defer></script>
|
||||||
<script src="js/disease_registry.js?v=39.16.60" defer></script>
|
<script src="js/disease_registry.js?v=39.16.74" defer></script>
|
||||||
<script src="js/sound_pack.js?v=39.16.60" defer></script>
|
<script src="js/sound_pack.js?v=39.16.74" defer></script>
|
||||||
<script src="js/deterministic_helpers.js?v=39.16.60" defer></script>
|
<script src="js/deterministic_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_tool_metadata.js?v=39.16.60" 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.60" 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.60" 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.60" 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.60" defer></script>
|
<script src="js/item_effect_definitions.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_registry.js?v=39.16.60" defer></script>
|
<script src="js/item_registry.js?v=39.16.74" defer></script>
|
||||||
<script src="js/data.js?v=39.16.60" defer></script>
|
<script src="js/data.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ground_types.js?v=39.16.60" defer></script>
|
<script src="js/ground_types.js?v=39.16.74" defer></script>
|
||||||
<script src="js/input_mode_manager.js?v=39.16.60" defer></script>
|
<script src="js/input_mode_manager.js?v=39.16.74" defer></script>
|
||||||
<script src="js/math.js?v=39.16.60" defer></script>
|
<script src="js/math.js?v=39.16.74" defer></script>
|
||||||
<script src="js/geometry_helpers.js?v=39.16.60" defer></script>
|
<script src="js/geometry_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/collision_footprint_system.js?v=39.16.60" defer></script>
|
<script src="js/collision_footprint_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/physics_helpers.js?v=39.16.60" defer></script>
|
<script src="js/physics_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/placement_preview_system.js?v=39.16.60" 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.60" 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.60" 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.60" defer></script>
|
<script src="js/mechanical_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/mechanical_shape_bridge.js?v=39.16.60" defer></script>
|
<script src="js/mechanical_shape_bridge.js?v=39.16.74" defer></script>
|
||||||
<script src="js/constraint_system.js?v=39.16.60" defer></script>
|
<script src="js/constraint_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/physics_world_system.js?v=39.16.60" 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.60" defer></script>
|
<script src="js/physics_projection_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/signal_system.js?v=39.16.60" defer></script>
|
<script src="js/circuit_board_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/assets.js?v=39.16.60" defer></script>
|
<script src="js/signal_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/audio.js?v=39.16.60" defer></script>
|
<script src="js/assets.js?v=39.16.74" defer></script>
|
||||||
<script src="js/perf_profiler.js?v=39.16.60" defer></script>
|
<script src="js/audio.js?v=39.16.74" defer></script>
|
||||||
<script src="js/display_helpers.js?v=39.16.60" defer></script>
|
<script src="js/perf_profiler.js?v=39.16.74" defer></script>
|
||||||
<script src="js/render.js?v=39.16.60" defer></script>
|
<script src="js/display_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/sim_core.js?v=39.16.60" defer></script>
|
<script src="js/render.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_seed_factory.js?v=39.16.60" defer></script>
|
<script src="js/sim_core.js?v=39.16.74" defer></script>
|
||||||
<script src="js/structures.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_seed_factory.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_type_catalog.js?v=39.16.60" defer></script>
|
<script src="js/structures.js?v=39.16.74" defer></script>
|
||||||
<script src="js/items.js?v=39.16.60" defer></script>
|
<script src="js/item_type_catalog.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_type_initializers.js?v=39.16.60" defer></script>
|
<script src="js/items.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_support.js?v=39.16.60" defer></script>
|
<script src="js/item_type_initializers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_update_policy.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_support.js?v=39.16.74" defer></script>
|
||||||
<script src="js/fire_runtime_system.js?v=39.16.60" defer></script>
|
<script src="js/item_update_policy.js?v=39.16.74" defer></script>
|
||||||
<script src="js/robot_cleaner_system.js?v=39.16.60" defer></script>
|
<script src="js/fire_runtime_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_dynamic_tool_system.js?v=39.16.60" defer></script>
|
<script src="js/robot_cleaner_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_dynamic_ball_system.js?v=39.16.60" defer></script>
|
<script src="js/item_dynamic_tool_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_dynamic_duplicator_system.js?v=39.16.60" defer></script>
|
<script src="js/item_dynamic_ball_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_dynamic_pin_system.js?v=39.16.60" defer></script>
|
<script src="js/item_dynamic_duplicator_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_dynamic_zunchi_system.js?v=39.16.60" defer></script>
|
<script src="js/item_dynamic_pin_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_dynamic_system.js?v=39.16.60" defer></script>
|
<script src="js/item_dynamic_zunchi_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_decay_system.js?v=39.16.60" defer></script>
|
<script src="js/item_dynamic_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_growth_system.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_decay_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_step_frame.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_growth_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_step_decay.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_step_frame.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_step_decay.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_step_growth.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.74" defer></script>
|
||||||
<script src="js/update_step_pipeline_runner.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_step_growth.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_lifecycle_pipeline.js?v=39.16.60" defer></script>
|
<script src="js/update_step_pipeline_runner.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_runtime.js?v=39.16.60" defer></script>
|
<script src="js/item_lifecycle_pipeline.js?v=39.16.74" defer></script>
|
||||||
<script src="js/structure_lifecycle.js?v=39.16.60" defer></script>
|
<script src="js/item_runtime.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_render_helpers.js?v=39.16.60" defer></script>
|
<script src="js/structure_lifecycle.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_render_runtime.js?v=39.16.60" defer></script>
|
<script src="js/item_render_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/burn_motion_util.js?v=39.16.60" defer></script>
|
<script src="js/item_render_runtime.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ants.js?v=39.16.60" defer></script>
|
<script src="js/burn_motion_util.js?v=39.16.74" defer></script>
|
||||||
<script src="js/health.js?v=39.16.60" defer></script>
|
<script src="js/ants.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai.js?v=39.16.60" defer></script>
|
<script src="js/health.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_action_spec.js?v=39.16.60" defer></script>
|
<script src="js/tarinai.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_behavior_state.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_action_spec.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_identity_social.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_behavior_state.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_action_state.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_identity_social.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_disease_nest.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_action_state.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_item_effects.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_disease_nest.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_needs_core.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_item_effects.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_behavior_text.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_needs_core.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_forced_behavior.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_behavior_text.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_nest_sleep_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_forced_behavior.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_item_targeting.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_nest_sleep_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_consumable_behavior.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_item_targeting.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_social_action_runtime.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_consumable_behavior.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_building_behavior.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_social_action_runtime.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_action_definitions.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_building_behavior.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_needs_items.js?v=39.16.60" defer></script>
|
<script src="js/seesaw_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_action_definitions.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_direct_feeding_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_needs_items.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_need_planner_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_item_interaction_context.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_direct_feeding_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_food_interaction_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_need_planner_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_contact_item_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_item_interaction_context.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_item_interaction_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_food_interaction_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_sunbath_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_contact_item_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_cursor_care_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_item_interaction_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_local_environment_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_sunbath_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_social_move_life.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_cursor_care_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_step_frame.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_local_environment_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_step_ai.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_social_move_life.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_step_environment.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_step_frame.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_step_movement.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_step_ai.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_step_health.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_step_environment.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_pipeline.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_step_movement.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_runtime.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_step_health.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_render.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_pipeline.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_runtime.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_view.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_render.js?v=39.16.74" defer></script>
|
||||||
<script src="js/family_graph.js?v=39.16.60" defer></script>
|
<script src="js/world.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_reset_presets.js?v=39.16.60" defer></script>
|
<script src="js/world_view.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_family_social.js?v=39.16.60" defer></script>
|
<script src="js/family_graph.js?v=39.16.74" defer></script>
|
||||||
<script src="js/impact_core_system.js?v=39.16.60" defer></script>
|
<script src="js/world_reset_presets.js?v=39.16.74" defer></script>
|
||||||
<script src="js/impact_response_system.js?v=39.16.60" defer></script>
|
<script src="js/world_family_social.js?v=39.16.74" defer></script>
|
||||||
<script src="js/collision_response_system.js?v=39.16.60" defer></script>
|
<script src="js/impact_core_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_combat_effects.js?v=39.16.60" defer></script>
|
<script src="js/impact_response_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_environment.js?v=39.16.60" defer></script>
|
<script src="js/collision_response_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_temperature_system.js?v=39.16.60" defer></script>
|
<script src="js/world_combat_effects.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_pathfinding_system.js?v=39.16.60" defer></script>
|
<script src="js/world_environment.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_grass_placement_system.js?v=39.16.60" defer></script>
|
<script src="js/world_temperature_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_spatial_budget.js?v=39.16.60" defer></script>
|
<script src="js/world_pathfinding_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_ants_system.js?v=39.16.60" defer></script>
|
<script src="js/world_grass_placement_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/weather_system.js?v=39.16.60" defer></script>
|
<script src="js/world_spatial_budget.js?v=39.16.74" defer></script>
|
||||||
<script src="js/item_update_scheduler.js?v=39.16.60" defer></script>
|
<script src="js/world_ants_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_runtime_helpers.js?v=39.16.60" defer></script>
|
<script src="js/weather_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/tarinai_update_policy.js?v=39.16.60" defer></script>
|
<script src="js/item_update_scheduler.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_environment_system.js?v=39.16.60" defer></script>
|
<script src="js/simulation_runtime_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_item_ant_system.js?v=39.16.60" defer></script>
|
<script src="js/tarinai_update_policy.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_effects_system.js?v=39.16.60" defer></script>
|
<script src="js/simulation_environment_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_creature_system.js?v=39.16.60" defer></script>
|
<script src="js/simulation_item_ant_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_maintenance_system.js?v=39.16.60" defer></script>
|
<script src="js/simulation_effects_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_ambient_system.js?v=39.16.60" defer></script>
|
<script src="js/simulation_creature_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation_systems.js?v=39.16.60" defer></script>
|
<script src="js/simulation_maintenance_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/system_order.js?v=39.16.60" defer></script>
|
<script src="js/simulation_ambient_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/simulation.js?v=39.16.60" defer></script>
|
<script src="js/simulation_systems.js?v=39.16.74" defer></script>
|
||||||
<script src="js/colony_situation_system.js?v=39.16.60" defer></script>
|
<script src="js/system_order.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_update.js?v=39.16.60" defer></script>
|
<script src="js/simulation.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_tool_actions.js?v=39.16.60" defer></script>
|
<script src="js/colony_situation_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_placement_log.js?v=39.16.60" defer></script>
|
<script src="js/world_update.js?v=39.16.74" defer></script>
|
||||||
<script src="js/world_event_effects.js?v=39.16.60" defer></script>
|
<script src="js/world_tool_actions.js?v=39.16.74" defer></script>
|
||||||
<script src="js/command_dispatcher.js?v=39.16.60" defer></script>
|
<script src="js/world_placement_log.js?v=39.16.74" defer></script>
|
||||||
<script src="js/text_catalog.js?v=39.16.60" defer></script>
|
<script src="js/world_event_effects.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui.js?v=39.16.60" defer></script>
|
<script src="js/command_dispatcher.js?v=39.16.74" defer></script>
|
||||||
<script src="js/game_dialogs.js?v=39.16.60" defer></script>
|
<script src="js/text_catalog.js?v=39.16.74" defer></script>
|
||||||
<script src="js/achievements.js?v=39.16.60" defer></script>
|
<script src="js/ui.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_log.js?v=39.16.60" defer></script>
|
<script src="js/game_dialogs.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_helpers.js?v=39.16.60" defer></script>
|
<script src="js/achievements.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_selected.js?v=39.16.60" defer></script>
|
<script src="js/ui_helpers.js?v=39.16.74" defer></script>
|
||||||
<script src="js/save_schema.js?v=39.16.60" defer></script>
|
<script src="js/ui_log.js?v=39.16.74" defer></script>
|
||||||
<script src="js/snapshot_system.js?v=39.16.60" defer></script>
|
<script src="js/ui_selected.js?v=39.16.74" defer></script>
|
||||||
<script src="js/restore_coordinator.js?v=39.16.60" defer></script>
|
<script src="js/save_schema.js?v=39.16.74" defer></script>
|
||||||
<script src="js/history_system.js?v=39.16.60" defer></script>
|
<script src="js/snapshot_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/save_codec.js?v=39.16.60" defer></script>
|
<script src="js/restore_coordinator.js?v=39.16.74" defer></script>
|
||||||
<script src="js/save_storage.js?v=39.16.60" defer></script>
|
<script src="js/history_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/save_system.js?v=39.16.60" defer></script>
|
<script src="js/save_codec.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_tooltips.js?v=39.16.60" defer></script>
|
<script src="js/save_storage.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_layout_dialogs.js?v=39.16.60" defer></script>
|
<script src="js/save_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_ground.js?v=39.16.60" defer></script>
|
<script src="js/ui_tooltips.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_tools.js?v=39.16.60" defer></script>
|
<script src="js/ui_layout_dialogs.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_input_shared.js?v=39.16.60" defer></script>
|
<script src="js/ui_ground.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_pointer_action_system.js?v=39.16.60" defer></script>
|
<script src="js/ui_tools.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_input_touch.js?v=39.16.60" defer></script>
|
<script src="js/ui_input_shared.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_input_mouse.js?v=39.16.60" defer></script>
|
<script src="js/ui_pointer_action_system.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_bind.js?v=39.16.60" defer></script>
|
<script src="js/ui_input_touch.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_charts.js?v=39.16.60" defer></script>
|
<script src="js/ui_input_mouse.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_family_data.js?v=39.16.60" defer></script>
|
<script src="js/ui_bind.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_family_async.js?v=39.16.60" defer></script>
|
<script src="js/ui_charts.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_family_layout.js?v=39.16.60" defer></script>
|
<script src="js/ui_family_data.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_family_paths.js?v=39.16.60" defer></script>
|
<script src="js/ui_family_async.js?v=39.16.74" defer></script>
|
||||||
<script src="js/ui_family_render.js?v=39.16.60" defer></script>
|
<script src="js/ui_family_layout.js?v=39.16.74" defer></script>
|
||||||
<script src="js/main.js?v=39.16.60" defer></script>
|
<script src="js/ui_family_paths.js?v=39.16.74" defer></script>
|
||||||
<script src="js/debug_tools.js?v=39.16.60" 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,26 @@
|
||||||
const STORAGE_KEY = "tarinai_achievements_v2";
|
const STORAGE_KEY = "tarinai_achievements_v2";
|
||||||
const PLAYER_KEY = "tarinai_achievement_player_v1";
|
const PLAYER_KEY = "tarinai_achievement_player_v1";
|
||||||
const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php");
|
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 DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug"));
|
||||||
const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const ABSOLUTE_ZERO_C = -273.15;
|
const ABSOLUTE_ZERO_C = -273.15;
|
||||||
const CONTINUOUS_PLAY_TARGET_MS = 60 * 60 * 1000;
|
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 HELD_TARGET_MS = 30 * 1000;
|
||||||
const COMPLETIONIST_ID = "true_tarinai_observer";
|
const COMPLETIONIST_ID = "true_tarinai_observer";
|
||||||
const GENERATION_TARGET = 10;
|
const GENERATION_TARGET = 10;
|
||||||
const CHAOS_FIGHT_TARGET = 666;
|
const CHAOS_FIGHT_TARGET = 666;
|
||||||
const SNIPER_SHOT_TARGET = 333;
|
const SNIPER_SHOT_TARGET = 333;
|
||||||
const FERTILITY_BIRTH_TARGET = 721;
|
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_CLEAN_TARGET = 200;
|
||||||
const ROBOT_ALL_TARGET_MASK = 0x3f;
|
const ROBOT_ALL_TARGET_MASK = 0x3f;
|
||||||
const SYNC_DEBOUNCE_MS = 450;
|
const SYNC_DEBOUNCE_MS = 450;
|
||||||
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
const SPELL_STATE_VERSION = 3;
|
const SPELL_STATE_VERSION = 5;
|
||||||
const PREVIOUS_SPELL_STATE_VERSION = 2;
|
|
||||||
const LEGACY_SPELL_STATE_VERSION = 1;
|
|
||||||
const MEDICINE_LEDGER_TYPES = Object.freeze(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
|
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 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([
|
const PRE_UNLOCK_DESCRIPTION_IDS = new Set([
|
||||||
|
|
@ -41,6 +43,8 @@
|
||||||
"ants_alive_25",
|
"ants_alive_25",
|
||||||
"great_mother_1000_births",
|
"great_mother_1000_births",
|
||||||
"continuous_play_1_hour",
|
"continuous_play_1_hour",
|
||||||
|
"low_fps_single_digit",
|
||||||
|
"continuous_play_24_hours",
|
||||||
"robot_cleaner_100",
|
"robot_cleaner_100",
|
||||||
"mercury_lifespan",
|
"mercury_lifespan",
|
||||||
"enemy_enemy_friend",
|
"enemy_enemy_friend",
|
||||||
|
|
@ -48,6 +52,7 @@
|
||||||
"town_doctor_50",
|
"town_doctor_50",
|
||||||
"chaos_seeker_666_fights",
|
"chaos_seeker_666_fights",
|
||||||
"fertility_seeker_721_love_births",
|
"fertility_seeker_721_love_births",
|
||||||
|
"daily_play_7_days",
|
||||||
]);
|
]);
|
||||||
const ACHIEVEMENT_CATEGORIES = Object.freeze([
|
const ACHIEVEMENT_CATEGORIES = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
|
|
@ -56,6 +61,7 @@
|
||||||
ids: Object.freeze([
|
ids: Object.freeze([
|
||||||
"first_birth", "natural_zunchi_slave", "natural_tarinai_king",
|
"first_birth", "natural_zunchi_slave", "natural_tarinai_king",
|
||||||
"natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
|
"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",
|
"birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed",
|
||||||
"all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
|
"all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
|
||||||
"safe_colony_25_5_minutes", "ants_alive_25", "overprotective",
|
"safe_colony_25_5_minutes", "ants_alive_25", "overprotective",
|
||||||
|
|
@ -72,15 +78,16 @@
|
||||||
"ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge",
|
"ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge",
|
||||||
"medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
|
"medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
|
||||||
"revolution", "fuel_to_fire", "king_full_satisfaction", "slave_zero_satisfaction",
|
"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({
|
Object.freeze({
|
||||||
id: "construction",
|
id: "construction",
|
||||||
title: "\u5efa\u7bc9",
|
title: "\u5efa\u7bc9",
|
||||||
ids: Object.freeze([
|
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",
|
"minimalist_happy", "unplanned_city_30", "clean_freak_robot_only", "megalopolis",
|
||||||
|
"sandbox_five_toilets", "park_ground_changed", "ground_change_4_in_1_second",
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
|
|
@ -88,8 +95,9 @@
|
||||||
title: "\u305d\u306e\u4ed6",
|
title: "\u305d\u306e\u4ed6",
|
||||||
ids: Object.freeze([
|
ids: Object.freeze([
|
||||||
"idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second",
|
"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_mass_revival", "held_30_seconds", "undo_20", "redo_20",
|
||||||
"undo_20", "redo_20", "poke_plushie_fling", "true_tarinai_observer", "sniper_333_shots",
|
"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: "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: "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: "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: "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: "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_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: "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: "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: "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: "below_absolute_zero_item", title: "\u7269\u7406\u6cd5\u5247\u3092\u4e0b\u5074\u306b\u8d85\u8d8a", description: "\u6e29\u5ea6\u8a08\u7b97\u3055\u308c\u308b\u30a2\u30a4\u30c6\u30e0\u30921\u3064\u7d76\u5bfe\u96f6\u5ea6\u3092\u4e0b\u56de\u308b\u6e29\u5ea6\u306b\u3059\u308b\u3002" }),
|
||||||
Object.freeze({ id: "undo_mass_revival", title: "\u898b\u306a\u304b\u3063\u305f\u3053\u3068\u306b\u3057\u3088\u3046", description: "\u4e00\u5ea6\u306eUndo\u306710\u4f53\u4ee5\u4e0a\u306e\u6b7b\u4ea1\u3092\u306a\u304b\u3063\u305f\u3053\u3068\u306b\u3059\u308b\u3002" }),
|
Object.freeze({ id: "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: "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: "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: "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: "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: "medicine_ledger_all", title: "\u304a\u304f\u3059\u308a\u624b\u5e33\u5168\u57cb\u3081", description: "\u5168\u7a2e\u985e\u306e\u85ac\u3092\u5c11\u306a\u304f\u3068\u30821\u56de\u4f7f\u7528\u3059\u308b\u3002" }),
|
||||||
Object.freeze({ id: "mercury_lifespan", title: "\u4e0d\u8001\u4e0d\u6b7b\u307e\u3067\u3042\u3068\u4e00\u6b69", description: "\u6c34\u9280\u3092\u4f7f\u7528\u3057\u305f\u305f\u308a\u306a\u3044\u304c\u5929\u5bff\u3092\u5168\u3046\u3059\u308b\u3002" }),
|
Object.freeze({ id: "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: "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: "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: "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 definitionById = new Map(DEFINITIONS.map(def => [def.id, def]));
|
||||||
const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [
|
const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [
|
||||||
|
|
@ -177,17 +195,19 @@
|
||||||
let requestSerial = 0;
|
let requestSerial = 0;
|
||||||
let runtimeByWorld = new WeakMap();
|
let runtimeByWorld = new WeakMap();
|
||||||
let dangerFightDeathsByWorld = new WeakMap();
|
let dangerFightDeathsByWorld = new WeakMap();
|
||||||
|
let wireShockTrackers = new WeakMap();
|
||||||
let syncQueue = Promise.resolve();
|
let syncQueue = Promise.resolve();
|
||||||
let toastQueue = [];
|
let toastQueue = [];
|
||||||
let toastActive = false;
|
let toastActive = false;
|
||||||
let pauseClickTimes = [];
|
let emptyClickTimes = [];
|
||||||
let continuousPlayStartedAt = document.hidden ? 0 : Date.now();
|
let groundChangeTimes = [];
|
||||||
|
let continuousPlayStartedAt = Date.now();
|
||||||
let continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
let continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
||||||
let lastListSignature = "";
|
let lastListSignature = "";
|
||||||
let scheduledSyncTimer = 0;
|
let scheduledSyncTimer = 0;
|
||||||
|
|
||||||
function blankState() {
|
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() {
|
function loadState() {
|
||||||
|
|
@ -235,6 +255,8 @@
|
||||||
fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)),
|
fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)),
|
||||||
shotCount: Math.max(0, Math.floor(Number(progressSource.shotCount || 0) || 0)),
|
shotCount: Math.max(0, Math.floor(Number(progressSource.shotCount || 0) || 0)),
|
||||||
loveMochiBirthCount: Math.max(0, Math.floor(Number(progressSource.loveMochiBirthCount || 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]);
|
const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]);
|
||||||
if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) {
|
if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) {
|
||||||
|
|
@ -316,10 +338,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function continuousPlayElapsedMs(now = Date.now()) {
|
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);
|
const timestamp = Number(now);
|
||||||
if (document.hidden || !continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0;
|
if (!continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0;
|
||||||
if (continuousPlayLastHeartbeatAt && timestamp - continuousPlayLastHeartbeatAt > 15000) return 0;
|
|
||||||
return Math.max(0, timestamp - continuousPlayStartedAt);
|
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)));
|
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`;
|
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") {
|
if (id === "continuous_play_1_hour") {
|
||||||
const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs());
|
const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs());
|
||||||
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 60:00\uff09`;
|
return `\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") {
|
if (id === "enemy_enemy_friend") {
|
||||||
const world = global.world || null;
|
const world = global.world || null;
|
||||||
const count = state.unlocked[id] ? 10 : Math.min(10, Math.max(0, Math.floor(Number(world?.achievementEnemyAntKills || 0) || 0)));
|
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 = [
|
const local = [
|
||||||
Math.min(3333, Math.max(0, Number(state.progress?.birthCount || 0) || 0)),
|
Math.min(3333, Math.max(0, Number(state.progress?.birthCount || 0) || 0)),
|
||||||
Math.floor(continuousPlayElapsedMs() / 1000),
|
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(10, Math.max(0, Number(global.world?.achievementEnemyAntKills || 0) || 0)),
|
||||||
Math.min(50, Math.max(0, Number(state.progress?.firstAidHeals || 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(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(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(":");
|
].join(":");
|
||||||
return `${unlocks}|${rates}|${local}`;
|
return `${unlocks}|${rates}|${local}`;
|
||||||
}
|
}
|
||||||
|
|
@ -504,6 +536,10 @@
|
||||||
dom.list.replaceChildren(...groups);
|
dom.list.replaceChildren(...groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderIfDialogOpen() {
|
||||||
|
if (!dom.dialog?.classList.contains("hidden")) render();
|
||||||
|
}
|
||||||
|
|
||||||
function resetUnlockToastVisual() {
|
function resetUnlockToastVisual() {
|
||||||
if (!dom.toast) return;
|
if (!dom.toast) return;
|
||||||
dom.toast.classList.remove("absorbing");
|
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.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.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.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;
|
return state.progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -855,35 +893,14 @@
|
||||||
state.unlocked.chaos_seeker_666_fights ? 0 : Math.min(CHAOS_FIGHT_TARGET, progress.fightMochiFightCount),
|
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.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.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) {
|
function parseSpellState(payload = null) {
|
||||||
if (!Array.isArray(payload)) return null;
|
if (!Array.isArray(payload) || Number(payload[0]) !== SPELL_STATE_VERSION) 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;
|
|
||||||
const lowWord = Number(payload[1]);
|
const lowWord = Number(payload[1]);
|
||||||
const highWord = Number(payload[2]);
|
const highWord = Number(payload[2]);
|
||||||
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
|
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
|
||||||
|
|
@ -897,7 +914,7 @@
|
||||||
deltas: Array.isArray(payload[4]) ? payload[4] : [],
|
deltas: Array.isArray(payload[4]) ? payload[4] : [],
|
||||||
packed: Array.isArray(payload[5]) ? payload[5] : [],
|
packed: Array.isArray(payload[5]) ? payload[5] : [],
|
||||||
timestampUnitMs: 60000,
|
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.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.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))));
|
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);
|
const progressChanged = previousProgress !== JSON.stringify(progress);
|
||||||
changed = changed || progressChanged;
|
changed = changed || progressChanged;
|
||||||
|
|
@ -990,11 +1015,12 @@
|
||||||
}
|
}
|
||||||
let changed = evaluateCleanFreak(world, detail);
|
let changed = evaluateCleanFreak(world, detail);
|
||||||
changed = evaluateMegalopolis(world, detail) || changed;
|
changed = evaluateMegalopolis(world, detail) || changed;
|
||||||
|
changed = evaluateSandbox(world, detail) || changed;
|
||||||
if (!state.unlocked.placed_objects_100) {
|
if (!state.unlocked.placed_objects_100) {
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
progress.placementCount = Math.min(100, progress.placementCount + 1);
|
progress.placementCount = Math.min(100, progress.placementCount + 1);
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
if (progress.placementCount >= 100) changed = unlock("placed_objects_100", { ...detail, world, count: progress.placementCount }) || changed;
|
if (progress.placementCount >= 100) changed = unlock("placed_objects_100", { ...detail, world, count: progress.placementCount }) || changed;
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
|
|
@ -1015,7 +1041,7 @@
|
||||||
if (!state.unlocked.unplanned_city_30) {
|
if (!state.unlocked.unplanned_city_30) {
|
||||||
progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1);
|
progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1);
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
if (progress.quickDeleteCount >= 30) changed = unlock("unplanned_city_30", { ...detail, world, count: progress.quickDeleteCount }) || changed;
|
if (progress.quickDeleteCount >= 30) changed = unlock("unplanned_city_30", { ...detail, world, count: progress.quickDeleteCount }) || changed;
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
|
|
@ -1029,7 +1055,7 @@
|
||||||
const achievementId = action === "undo" ? "undo_20" : "redo_20";
|
const achievementId = action === "undo" ? "undo_20" : "redo_20";
|
||||||
progress[key] = Math.min(20, progress[key] + 1);
|
progress[key] = Math.min(20, progress[key] + 1);
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (action === "undo" && Math.max(0, Number(detail.deadRestored || 0) || 0) >= 10) {
|
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;
|
changed = unlock("undo_mass_revival", { ...detail, count: Math.max(0, Number(detail.deadRestored || 0) || 0) }) || changed;
|
||||||
|
|
@ -1054,7 +1080,7 @@
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
progress.linkTypes = [...new Set([...(progress.linkTypes || []), linkType])];
|
progress.linkTypes = [...new Set([...(progress.linkTypes || []), linkType])];
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
return evaluateMechanizedIndustry({ ...detail, type: linkType });
|
return evaluateMechanizedIndustry({ ...detail, type: linkType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1064,7 +1090,7 @@
|
||||||
if (!progress.signalActivated) {
|
if (!progress.signalActivated) {
|
||||||
progress.signalActivated = true;
|
progress.signalActivated = true;
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
}
|
}
|
||||||
return evaluateMechanizedIndustry(detail);
|
return evaluateMechanizedIndustry(detail);
|
||||||
}
|
}
|
||||||
|
|
@ -1076,24 +1102,67 @@
|
||||||
return false;
|
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 = {}) {
|
function recordAntKilled(detail = {}) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
if (!state.unlocked.ants_killed_100) {
|
if (!state.unlocked.ants_killed_100) {
|
||||||
progress.antKills = Math.min(100, progress.antKills + 1);
|
progress.antKills = Math.min(100, progress.antKills + 1);
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
if (progress.antKills >= 100) changed = unlock("ants_killed_100", detail) || changed;
|
if (progress.antKills >= 100) changed = unlock("ants_killed_100", detail) || changed;
|
||||||
}
|
}
|
||||||
const world = detailWorld(detail);
|
const world = detailWorld(detail);
|
||||||
const ant = detail.ant || null;
|
const ant = detail.ant || null;
|
||||||
const now = Math.max(0, Number(world?.time || 0) || 0);
|
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 aliveCount = (world?.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
|
||||||
const lastDamageAt = Number(world?.achievementLastTarinaiDamageAt);
|
if (world && aliveCount < 30) world.achievementEnemyAntKills = 0;
|
||||||
const undamagedForTenSeconds = !Number.isFinite(lastDamageAt) || now - lastDamageAt >= 10;
|
if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30) {
|
||||||
if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30 && undamagedForTenSeconds) {
|
|
||||||
world.achievementEnemyAntKills = Math.min(10, Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) + 1);
|
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;
|
if (world.achievementEnemyAntKills >= 10) changed = unlock("enemy_enemy_friend", { ...detail, world, count: world.achievementEnemyAntKills }) || changed;
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
|
|
@ -1118,7 +1187,10 @@
|
||||||
const dangerSource = dangerItemSource(source);
|
const dangerSource = dangerItemSource(source);
|
||||||
// This achievement tracks collateral damage caused by danger-category tools,
|
// This achievement tracks collateral damage caused by danger-category tools,
|
||||||
// not ordinary combat, hunger, disease, or ant attacks.
|
// 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 ? {
|
tarinai._achievementLastDamageSource = dangerSource ? {
|
||||||
danger: true,
|
danger: true,
|
||||||
at: now,
|
at: now,
|
||||||
|
|
@ -1204,36 +1276,100 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordPauseClick(detail = {}) {
|
function recordEmptyClick(detail = {}) {
|
||||||
if (state.unlocked.pause_spam_4_in_1_second) return false;
|
if (state.unlocked.pause_spam_4_in_1_second) return false;
|
||||||
const now = Number(detail.now);
|
const now = Number(detail.now);
|
||||||
const timestamp = Number.isFinite(now) ? now : Date.now();
|
const timestamp = Number.isFinite(now) ? now : Date.now();
|
||||||
pauseClickTimes = pauseClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value);
|
emptyClickTimes = emptyClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value);
|
||||||
pauseClickTimes.push(timestamp);
|
emptyClickTimes.push(timestamp);
|
||||||
if (pauseClickTimes.length >= 4) {
|
if (emptyClickTimes.length >= 4) {
|
||||||
pauseClickTimes = [];
|
emptyClickTimes = [];
|
||||||
return unlock("pause_spam_4_in_1_second", { ...detail, count: 4, windowMs: 1000 });
|
return unlock("pause_spam_4_in_1_second", { ...detail, count: 4, windowMs: 1000 });
|
||||||
}
|
}
|
||||||
return false;
|
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 = {}) {
|
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);
|
const explicitElapsed = Number(detail.elapsedMs);
|
||||||
let elapsedMs;
|
let elapsedMs;
|
||||||
if (Number.isFinite(explicitElapsed)) {
|
if (Number.isFinite(explicitElapsed)) {
|
||||||
elapsedMs = Math.max(0, explicitElapsed);
|
elapsedMs = Math.max(0, explicitElapsed);
|
||||||
} else {
|
} else {
|
||||||
const timestamp = Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now();
|
const timestamp = Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now();
|
||||||
if (document.hidden) return false;
|
if (!continuousPlayStartedAt || timestamp < continuousPlayStartedAt) continuousPlayStartedAt = timestamp;
|
||||||
if (!continuousPlayStartedAt || !continuousPlayLastHeartbeatAt || timestamp - continuousPlayLastHeartbeatAt > 15000 || timestamp < continuousPlayLastHeartbeatAt) {
|
|
||||||
continuousPlayStartedAt = timestamp;
|
|
||||||
}
|
|
||||||
continuousPlayLastHeartbeatAt = timestamp;
|
continuousPlayLastHeartbeatAt = timestamp;
|
||||||
elapsedMs = Math.max(0, timestamp - continuousPlayStartedAt);
|
elapsedMs = Math.max(0, timestamp - continuousPlayStartedAt);
|
||||||
}
|
}
|
||||||
if (elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) return unlock("continuous_play_1_hour", { ...detail, elapsedMs });
|
let changed = false;
|
||||||
return 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 = {}) {
|
function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) {
|
||||||
|
|
@ -1253,7 +1389,7 @@
|
||||||
const type = String(detail.type || detail.target?.type || "");
|
const type = String(detail.type || detail.target?.type || "");
|
||||||
if (!world || !["zunchi", "ant_corpse", "trace", "splat"].includes(type)) return false;
|
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);
|
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 });
|
if (world.achievementRobotCleanCount >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count: world.achievementRobotCleanCount });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1304,7 +1440,7 @@
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
progress.firstAidHeals = Math.min(50, progress.firstAidHeals + 1);
|
progress.firstAidHeals = Math.min(50, progress.firstAidHeals + 1);
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
if (progress.firstAidHeals >= 50) return unlock("town_doctor_50", { ...detail, count: progress.firstAidHeals, recovered });
|
if (progress.firstAidHeals >= 50) return unlock("town_doctor_50", { ...detail, count: progress.firstAidHeals, recovered });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1330,7 +1466,7 @@
|
||||||
if (!progress.medicineTypes.includes(type)) {
|
if (!progress.medicineTypes.includes(type)) {
|
||||||
progress.medicineTypes = [...progress.medicineTypes, type];
|
progress.medicineTypes = [...progress.medicineTypes, type];
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
}
|
}
|
||||||
if (MEDICINE_LEDGER_TYPES.every(required => progress.medicineTypes.includes(required))) {
|
if (MEDICINE_LEDGER_TYPES.every(required => progress.medicineTypes.includes(required))) {
|
||||||
changed = unlock("medicine_ledger_all", { ...detail, count: progress.medicineTypes.length }) || changed;
|
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;
|
changed = unlock("chaos_seeker_666_fights", { ...detail, world, count: progress.fightMochiFightCount, fightMochiInfluenced: true }) || changed;
|
||||||
} else {
|
} else {
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const now = Math.max(0, Number(world.time || 0) || 0);
|
const now = Math.max(0, Number(world.time || 0) || 0);
|
||||||
|
|
@ -1398,7 +1534,7 @@
|
||||||
progress.shotCount = Math.min(SNIPER_SHOT_TARGET, progress.shotCount + 1);
|
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 });
|
if (progress.shotCount >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count: progress.shotCount });
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1410,7 +1546,7 @@
|
||||||
return unlock("fertility_seeker_721_love_births", { ...detail, count: progress.loveMochiBirthCount });
|
return unlock("fertility_seeker_721_love_births", { ...detail, count: progress.loveMochiBirthCount });
|
||||||
}
|
}
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1486,7 +1622,7 @@
|
||||||
if (!state.unlocked.great_mother_1000_births) {
|
if (!state.unlocked.great_mother_1000_births) {
|
||||||
progress.birthCount = Math.min(3333, progress.birthCount + 1);
|
progress.birthCount = Math.min(3333, progress.birthCount + 1);
|
||||||
saveState();
|
saveState();
|
||||||
if (!dom.dialog?.classList.contains("hidden")) render();
|
renderIfDialogOpen();
|
||||||
if (progress.birthCount >= 3333) unlock("great_mother_1000_births", { ...detail, count: progress.birthCount });
|
if (progress.birthCount >= 3333) unlock("great_mother_1000_births", { ...detail, count: progress.birthCount });
|
||||||
}
|
}
|
||||||
if (state.unlocked.birth_50_in_60_seconds) return;
|
if (state.unlocked.birth_50_in_60_seconds) return;
|
||||||
|
|
@ -1500,11 +1636,15 @@
|
||||||
|
|
||||||
function recordDeath(detail = {}) {
|
function recordDeath(detail = {}) {
|
||||||
recordFightPairDangerDeath(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;
|
const world = detail.world || detail.tarinai?.world || null;
|
||||||
if (world) {
|
if (world) {
|
||||||
evaluateMegalopolis(world, detail);
|
evaluateMegalopolis(world, detail);
|
||||||
const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
|
if (!lifespanDeath) {
|
||||||
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
|
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 now = Number(world?.time || 0) || 0;
|
||||||
const tracker = runtimeTracker(world, now);
|
const tracker = runtimeTracker(world, now);
|
||||||
|
|
@ -1513,7 +1653,6 @@
|
||||||
tracker.deathTimes = tracker.deathTimes.filter(time => time >= now - 10.0001);
|
tracker.deathTimes = tracker.deathTimes.filter(time => time >= now - 10.0001);
|
||||||
if (tracker.deathTimes.length >= 50) unlock("death_50_in_10_seconds", detail);
|
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;
|
const tarinai = detail.tarinai || null;
|
||||||
if (reason.includes("\u5929\u5bff\u3092\u5168\u3046\u3057\u305f")) {
|
if (reason.includes("\u5929\u5bff\u3092\u5168\u3046\u3057\u305f")) {
|
||||||
unlock("lifespan_completed", detail);
|
unlock("lifespan_completed", detail);
|
||||||
|
|
@ -1555,7 +1694,7 @@
|
||||||
recordDirectCare({ ...detail, world, foodGift: true });
|
recordDirectCare({ ...detail, world, foodGift: true });
|
||||||
if (state.unlocked.direct_feed_33) return false;
|
if (state.unlocked.direct_feed_33) return false;
|
||||||
world.achievementDirectFeedCount = Math.min(33, Math.max(0, Math.floor(Number(world.achievementDirectFeedCount || 0) || 0)) + 1);
|
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 });
|
if (world.achievementDirectFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: world.achievementDirectFeedCount });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1577,7 +1716,8 @@
|
||||||
|| !state.unlocked.rain_shelter_all
|
|| !state.unlocked.rain_shelter_all
|
||||||
|| !state.unlocked.sauna_cold_plunge
|
|| !state.unlocked.sauna_cold_plunge
|
||||||
|| !state.unlocked.idle_observer_5_minutes
|
|| !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 needsAntScan = !state.unlocked.ants_alive_25;
|
||||||
const needsTemperatureItemScan = !state.unlocked.below_absolute_zero_item;
|
const needsTemperatureItemScan = !state.unlocked.below_absolute_zero_item;
|
||||||
const needsCleanFreakScan = !state.unlocked.clean_freak_robot_only;
|
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.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.below_absolute_zero_item) recordBelowAbsoluteZeroItem(worldRef);
|
||||||
if (!state.unlocked.clean_freak_robot_only) evaluateCleanFreak(worldRef, { source: "world-evaluation" });
|
if (!state.unlocked.clean_freak_robot_only) evaluateCleanFreak(worldRef, { source: "world-evaluation" });
|
||||||
if (!state.unlocked.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" });
|
if (!state.unlocked.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" });
|
||||||
|
|
@ -1686,16 +1828,19 @@
|
||||||
state.resetPending = true;
|
state.resetPending = true;
|
||||||
runtimeByWorld = new WeakMap();
|
runtimeByWorld = new WeakMap();
|
||||||
dangerFightDeathsByWorld = new WeakMap();
|
dangerFightDeathsByWorld = new WeakMap();
|
||||||
|
wireShockTrackers = new WeakMap();
|
||||||
toastQueue = [];
|
toastQueue = [];
|
||||||
toastActive = false;
|
toastActive = false;
|
||||||
pauseClickTimes = [];
|
emptyClickTimes = [];
|
||||||
continuousPlayStartedAt = document.hidden ? 0 : Date.now();
|
groundChangeTimes = [];
|
||||||
|
continuousPlayStartedAt = Date.now();
|
||||||
continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
||||||
if (global.world) {
|
if (global.world) {
|
||||||
resetWorldProgress(global.world);
|
resetWorldProgress(global.world);
|
||||||
global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0);
|
global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0);
|
||||||
}
|
}
|
||||||
saveState();
|
saveState();
|
||||||
|
recordDailyPlay({ source: "reset" });
|
||||||
render();
|
render();
|
||||||
clearTimeout(showUnlockToast.absorbTimer);
|
clearTimeout(showUnlockToast.absorbTimer);
|
||||||
clearTimeout(showUnlockToast.hideTimer);
|
clearTimeout(showUnlockToast.hideTimer);
|
||||||
|
|
@ -1736,14 +1881,14 @@
|
||||||
dom.button?.focus?.();
|
dom.button?.focus?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
dom.pauseButton?.addEventListener("click", () => recordPauseClick());
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
continuousPlayStartedAt = document.hidden ? 0 : Date.now();
|
if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now();
|
||||||
continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
|
||||||
});
|
});
|
||||||
if (typeof global.setInterval === "function") {
|
if (typeof global.setInterval === "function") {
|
||||||
global.setInterval(() => {
|
global.setInterval(() => {
|
||||||
if (!document.hidden) recordContinuousPlay();
|
if (!document.hidden) recordContinuousPlay();
|
||||||
|
recordDailyPlay();
|
||||||
|
recordLowFps();
|
||||||
recordHeldTarinai(global.world, Date.now());
|
recordHeldTarinai(global.world, Date.now());
|
||||||
}, 1000);
|
}, 1000);
|
||||||
if (!DEBUG_MODE) global.setInterval(() => void syncPending({ full: true }), FULL_SYNC_INTERVAL_MS);
|
if (!DEBUG_MODE) global.setInterval(() => void syncPending({ full: true }), FULL_SYNC_INTERVAL_MS);
|
||||||
|
|
@ -1801,10 +1946,19 @@
|
||||||
recordLinkPlaced,
|
recordLinkPlaced,
|
||||||
recordSignalActivation,
|
recordSignalActivation,
|
||||||
recordAntPopulation,
|
recordAntPopulation,
|
||||||
|
evaluateAntNestWithoutTarinai,
|
||||||
recordAntKilled,
|
recordAntKilled,
|
||||||
|
recordStickyBombPass,
|
||||||
|
recordCircuitBoardSignalTransfer,
|
||||||
|
recordWirePowerState,
|
||||||
|
recordWireShock,
|
||||||
recordSaveSlotsFilled,
|
recordSaveSlotsFilled,
|
||||||
recordPauseClick,
|
recordEmptyClick,
|
||||||
|
recordGroundChange,
|
||||||
|
evaluateSandbox,
|
||||||
|
recordLowFps,
|
||||||
recordContinuousPlay,
|
recordContinuousPlay,
|
||||||
|
recordDailyPlay,
|
||||||
recordBelowAbsoluteZeroItem,
|
recordBelowAbsoluteZeroItem,
|
||||||
recordDamageSource,
|
recordDamageSource,
|
||||||
recordAntDamageSource,
|
recordAntDamageSource,
|
||||||
|
|
@ -1816,6 +1970,7 @@
|
||||||
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },
|
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },
|
||||||
});
|
});
|
||||||
global.TarinaiAchievements = api;
|
global.TarinaiAchievements = api;
|
||||||
|
recordDailyPlay({ source: "load" });
|
||||||
if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) unlock(COMPLETIONIST_ID, { source: "load" });
|
if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) unlock(COMPLETIONIST_ID, { source: "load" });
|
||||||
render();
|
render();
|
||||||
global.setTimeout(() => recordSaveSlotsFilled(), 0);
|
global.setTimeout(() => recordSaveSlotsFilled(), 0);
|
||||||
|
|
|
||||||
1117
js/circuit_board_system.js
Normal file
1117
js/circuit_board_system.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -59,6 +59,12 @@
|
||||||
const solid = worldRef?.solidObstacleRects?.(item) || [];
|
const solid = worldRef?.solidObstacleRects?.(item) || [];
|
||||||
if (solid.length) return solid;
|
if (solid.length) return solid;
|
||||||
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
|
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;
|
return solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,14 @@
|
||||||
const nearestAvg = nearestAverage(alive);
|
const nearestAvg = nearestAverage(alive);
|
||||||
const fieldId = String(worldRef?.fieldType || "garden");
|
const fieldId = String(worldRef?.fieldType || "garden");
|
||||||
const overcrowdLimit = fieldId === "cage" ? 28 : (fieldId === "park" ? 140 : 75);
|
const overcrowdLimit = fieldId === "cage" ? 28 : (fieldId === "park" ? 140 : 75);
|
||||||
|
const previousPopulation = Number(worldRef?.lastColonyMoodPopulation);
|
||||||
|
const populationIncreasing = Number.isFinite(previousPopulation) && n > previousPopulation;
|
||||||
return {
|
return {
|
||||||
now,
|
now,
|
||||||
alive,
|
alive,
|
||||||
n,
|
n,
|
||||||
|
previousPopulation,
|
||||||
|
populationIncreasing,
|
||||||
avgEnergy: avgOf(alive, t => t.energy || 0),
|
avgEnergy: avgOf(alive, t => t.energy || 0),
|
||||||
avgStress: avgOf(alive, t => t.stress || 0),
|
avgStress: avgOf(alive, t => t.stress || 0),
|
||||||
avgHunger: avgOf(alive, t => t.hunger || 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("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("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("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("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("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);
|
add("overcrowded", 34, m.n >= m.overcrowdLimit || m.denseCrowd);
|
||||||
|
|
@ -87,7 +91,9 @@
|
||||||
const id = choose(worldRef, metrics);
|
const id = choose(worldRef, metrics);
|
||||||
const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } };
|
const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } };
|
||||||
worldRef.colonyMood = next;
|
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);
|
global.TarinaiAchievements?.evaluateWorld?.(worldRef, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
// and visual effects. Previously this bootstrap lived inline in several
|
// and visual effects. Previously this bootstrap lived inline in several
|
||||||
// modules; keeping it here prevents helper drift and load-order surprises.
|
// modules; keeping it here prevents helper drift and load-order surprises.
|
||||||
(function ensureDeterministicHelpers(global) {
|
(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 clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
|
||||||
const keyPart = value => {
|
const keyPart = value => {
|
||||||
if (value == null) return "null";
|
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 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 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.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.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.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.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);
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
|
|
||||||
// Keep the historical bare high-level helper names available to legacy scripts while the
|
// Keep the historical bare high-level helper names available to legacy scripts while the
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,11 @@
|
||||||
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
|
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
|
||||||
// editor hit-testing, and world obstacle queries.
|
// editor hit-testing, and world obstacle queries.
|
||||||
(function (global) {
|
(function (global) {
|
||||||
function num(v, fallback = 0) {
|
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
});
|
||||||
|
const clampValue = global.TarinaiCoreHelpers?.clampNumber || ((v, min, max) => Math.max(min, Math.min(max, v)));
|
||||||
function clampValue(v, min, max) {
|
|
||||||
return Math.max(min, Math.min(max, v));
|
|
||||||
}
|
|
||||||
|
|
||||||
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
||||||
const c = Math.cos(num(angle));
|
const c = Math.cos(num(angle));
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@
|
||||||
if (old && old !== carrier) {
|
if (old && old !== carrier) {
|
||||||
old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18);
|
old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18);
|
||||||
old.thought = "\u7c98\u7740\u30dc\u30e0\u304c\u79fb\u3063\u305f";
|
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") {
|
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)" }));
|
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)" }));
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o
|
||||||
function duplicatorApplyStoredPin(duplicator, worldRef) {
|
function duplicatorApplyStoredPin(duplicator, worldRef) {
|
||||||
const storedType = String(duplicator?.storedFoodType || "");
|
const storedType = String(duplicator?.storedFoodType || "");
|
||||||
if (!duplicator || duplicator.type !== "duplicator" || !isPinType(storedType)) return false;
|
if (!duplicator || duplicator.type !== "duplicator" || !isPinType(storedType)) return false;
|
||||||
|
if (globalThis.TarinaiSignalSystem?.powerOverride?.(duplicator) === false) return false;
|
||||||
const now = Number(worldRef?.time || 0);
|
const now = Number(worldRef?.time || 0);
|
||||||
if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false;
|
if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false;
|
||||||
const dish = duplicatorLoadPoint(duplicator);
|
const dish = duplicatorLoadPoint(duplicator);
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,7 @@ draw(ctx, t, lighting = null) {
|
||||||
} else if (this.type === "duplicator") {
|
} else if (this.type === "duplicator") {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
const loaded = !!this.storedFoodType;
|
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);
|
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, styleNight ? "#3d4852" : "#d8e2e6");
|
||||||
bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7");
|
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.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)";
|
||||||
ctx.stroke();
|
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.beginPath();
|
||||||
ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2);
|
ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
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.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.fill();
|
||||||
ctx.restore();
|
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") {
|
} else if (this.type === "signboard") {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12);
|
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") {
|
} else if (this.type === "dry_ice") {
|
||||||
const r = this.r || 21;
|
const r = this.r || 21;
|
||||||
const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
|
const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
|
||||||
|
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (itemShadowsEnabled()) {
|
if (powered && itemShadowsEnabled()) {
|
||||||
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
|
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
|
||||||
ctx.shadowBlur = 11;
|
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);
|
roundedRect(ctx, -bodyW * 0.48, y + bodyH - footH * 0.4, bodyW * 0.96, footH, r * 0.10);
|
||||||
ctx.fill();
|
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 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 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;
|
const sy = y - capH * 0.62 - phase * r * 0.76;
|
||||||
|
|
@ -750,8 +822,9 @@ draw(ctx, t, lighting = null) {
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
} else if (this.type === "stove") {
|
} else if (this.type === "stove") {
|
||||||
const r = this.r || 24;
|
const r = this.r || 24;
|
||||||
|
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (itemShadowsEnabled()) {
|
if (powered && itemShadowsEnabled()) {
|
||||||
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
|
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
|
||||||
ctx.shadowBlur = 9;
|
ctx.shadowBlur = 9;
|
||||||
}
|
}
|
||||||
|
|
@ -764,17 +837,19 @@ draw(ctx, t, lighting = null) {
|
||||||
ctx.fillStyle = "rgba(42, 32, 28, 0.92)";
|
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);
|
roundedRect(ctx, -r * 0.48, -r * 0.34, r * 0.96, r * 0.62, r * 0.14);
|
||||||
ctx.fill();
|
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));
|
if (powered) {
|
||||||
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
|
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.beginPath();
|
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
|
||||||
ctx.moveTo(0, r * 0.16);
|
ctx.beginPath();
|
||||||
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
|
ctx.moveTo(0, r * 0.16);
|
||||||
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 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.fill();
|
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
|
||||||
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
|
||||||
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
|
ctx.beginPath();
|
||||||
ctx.fill();
|
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
} else if (this.type === "ant_nest") {
|
} else if (this.type === "ant_nest") {
|
||||||
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
|
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 r = this.r || 26;
|
||||||
const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this);
|
const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this);
|
||||||
const windAngle = 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.save();
|
||||||
ctx.rotate(windAngle);
|
ctx.rotate(windAngle);
|
||||||
|
if (powered) {
|
||||||
const windRange = 320;
|
const windRange = 320;
|
||||||
const windStart = r * 1.02;
|
const windStart = r * 1.02;
|
||||||
const windSpread = 0.62;
|
const windSpread = 0.62;
|
||||||
|
|
@ -934,6 +1011,7 @@ draw(ctx, t, lighting = null) {
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = fanAlpha;
|
ctx.globalAlpha = fanAlpha;
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.rotate(bodyAngle);
|
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.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();
|
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 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.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(detectorLabel,0,14);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
} else if (this.type === "rotator") {
|
} else if (this.type === "rotator") {
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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 },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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 },
|
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 },
|
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,23 @@
|
||||||
// Static item/tool metadata shared by tool definitions and UI helpers.
|
// Static item/tool metadata shared by tool definitions and UI helpers.
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const ITEM_TRAITS = Object.freeze({
|
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"]),
|
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"]),
|
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"]),
|
pin: new Set(["pushpin", "oshibyo"]),
|
||||||
kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]),
|
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"]),
|
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"]),
|
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 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 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 ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board"]);
|
||||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
|
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({
|
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 },
|
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: "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: "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: "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: "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_TOOL_IDS },
|
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_CATEGORY_TOOL_IDS },
|
||||||
|
{ id: "electric", label: "\u96fb\u6c17", themeClass: "tool-category-electric", order: 46, toolIds: ELECTRIC_TOOL_IDS },
|
||||||
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] },
|
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -54,6 +57,8 @@
|
||||||
FENCE_ITEM_TYPES,
|
FENCE_ITEM_TYPES,
|
||||||
MECHANICAL_ITEM_TYPES,
|
MECHANICAL_ITEM_TYPES,
|
||||||
PHYSICS_TOOL_IDS,
|
PHYSICS_TOOL_IDS,
|
||||||
|
PHYSICS_CATEGORY_TOOL_IDS,
|
||||||
|
ELECTRIC_TOOL_IDS,
|
||||||
ROTATABLE_ITEM_TYPES,
|
ROTATABLE_ITEM_TYPES,
|
||||||
PIN_BEHAVIORS,
|
PIN_BEHAVIORS,
|
||||||
TOOL_CATEGORIES,
|
TOOL_CATEGORIES,
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
if (type === "pressure_switch") {
|
||||||
item.pressureTarget = "tarinai";
|
item.pressureTarget = "tarinai";
|
||||||
item.pressureComparator = "gte";
|
item.pressureMin = 1;
|
||||||
item.pressureThreshold = 1;
|
item.pressureMax = 300;
|
||||||
item.pressureWidth = 220;
|
item.pressureWidth = 220;
|
||||||
item.pressureHeight = 160;
|
item.pressureHeight = 160;
|
||||||
item.pressureTimeStart = 360;
|
item.pressureTimeStart = 360;
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
|
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const footprints = global.TarinaiCollisionFootprints;
|
const footprints = global.TarinaiCollisionFootprints;
|
||||||
function num(v, fallback = 0) {
|
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
});
|
||||||
|
|
||||||
function rectCorners(rect, padding = 0) {
|
function rectCorners(rect, padding = 0) {
|
||||||
return footprints?.rectCorners?.(rect, padding) || [];
|
return footprints?.rectCorners?.(rect, padding) || [];
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// Shared tiny helpers for positional projection used by links, mechanical
|
// Shared tiny helpers for positional projection used by links, mechanical
|
||||||
// bodies, and circle/circle collision code.
|
// bodies, and circle/circle collision code.
|
||||||
(function (global) {
|
(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) {
|
function markSpatialDirty(worldRef, item, reason = "projection", threshold = 0.35, minInterval = 0.065) {
|
||||||
const world = worldRef || item?.world || null;
|
const world = worldRef || item?.world || null;
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,15 @@
|
||||||
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
|
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
|
||||||
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
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);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
});
|
||||||
function bool(value, fallback = true) {
|
function bool(value, fallback = true) {
|
||||||
if (value == null) return !!fallback;
|
if (value == null) return !!fallback;
|
||||||
return !!value;
|
return !!value;
|
||||||
}
|
}
|
||||||
function clamp(value, min, max) {
|
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
|
||||||
return Math.max(min, Math.min(max, num(value, min)));
|
|
||||||
}
|
|
||||||
function isBodyType(typeOrItem) {
|
function isBodyType(typeOrItem) {
|
||||||
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
|
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
|
||||||
return BODY_TYPES.has(type);
|
return BODY_TYPES.has(type);
|
||||||
|
|
@ -271,6 +269,7 @@
|
||||||
x: num(endpoint.x),
|
x: num(endpoint.x),
|
||||||
y: num(endpoint.y),
|
y: num(endpoint.y),
|
||||||
attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null,
|
attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null,
|
||||||
|
portId: String(endpoint.portId || ""),
|
||||||
fuzzyResolve: endpoint.fuzzyResolve === true,
|
fuzzyResolve: endpoint.fuzzyResolve === true,
|
||||||
supportLost: endpoint.supportLost === true,
|
supportLost: endpoint.supportLost === true,
|
||||||
_ref: endpoint._ref || null,
|
_ref: endpoint._ref || null,
|
||||||
|
|
@ -476,7 +475,7 @@
|
||||||
if (!ep) return null;
|
if (!ep) return null;
|
||||||
const kind = ep.kind || "item";
|
const kind = ep.kind || "item";
|
||||||
const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1);
|
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)] } };
|
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 kind = ep.kind || "item";
|
||||||
const refIdx = Number(ep.refIdx);
|
const refIdx = Number(ep.refIdx);
|
||||||
const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null;
|
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 : [];
|
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);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
// of placement blocking. Render and actual placement both delegate here so a
|
// of placement blocking. Render and actual placement both delegate here so a
|
||||||
// green preview means the same footprint the click will try to place.
|
// green preview means the same footprint the click will try to place.
|
||||||
(function (global) {
|
(function (global) {
|
||||||
function num(v, fallback = 0) {
|
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
});
|
||||||
function itemRadius(type, fallback = 12) {
|
function itemRadius(type, fallback = 12) {
|
||||||
return global.itemRadiusFor(type, fallback);
|
return global.itemRadiusFor(type, fallback);
|
||||||
}
|
}
|
||||||
|
|
@ -181,8 +181,10 @@
|
||||||
}
|
}
|
||||||
if (item.type === "rain_shelter") {
|
if (item.type === "rain_shelter") {
|
||||||
const footprint = global.TarinaiParasolSystem?.parasolFootprint?.(item) || null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
function geometryFor(worldRef, item) {
|
function geometryFor(worldRef, item) {
|
||||||
|
|
|
||||||
59
js/render.js
59
js/render.js
|
|
@ -175,6 +175,44 @@ function strokeWorldObstacleRect(ctx, rect) {
|
||||||
return true;
|
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) {
|
function drawToolTargetHoverHighlight(ctx, world) {
|
||||||
if (!world) return;
|
if (!world) return;
|
||||||
const tool = String(world.tool || "");
|
const tool = String(world.tool || "");
|
||||||
|
|
@ -495,7 +533,6 @@ function drawPlacementPreview(ctx, world) {
|
||||||
const preview = placementPreviewFor(world);
|
const preview = placementPreviewFor(world);
|
||||||
if (!preview) return;
|
if (!preview) return;
|
||||||
const { type, x, y, r, rect, rects, overlay, influence, blocked } = preview;
|
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.save();
|
||||||
ctx.globalAlpha = blocked ? 0.44 : 0.72;
|
ctx.globalAlpha = blocked ? 0.44 : 0.72;
|
||||||
ctx.lineWidth = blocked ? 2.2 : 2.0;
|
ctx.lineWidth = blocked ? 2.2 : 2.0;
|
||||||
|
|
@ -519,9 +556,9 @@ function drawPlacementPreview(ctx, world) {
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (overlay?.shape === "ellipse") {
|
if (overlay?.shape === "circle") {
|
||||||
ctx.beginPath();
|
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.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
} else if (overlay?.shape === "roundedRect") {
|
} 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);
|
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 {
|
} else {
|
||||||
ctx.beginPath();
|
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.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
@ -585,8 +617,8 @@ function drawPlacementPreview(ctx, world) {
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
const markerX = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerX : x;
|
const markerX = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerX : x;
|
||||||
const markerY = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerY : y;
|
const markerY = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerY : y;
|
||||||
ctx.globalAlpha = blocked ? 0.52 : 0.70;
|
ctx.globalAlpha = blocked ? 0.52 : 0.70;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(markerX - 8, markerY);
|
ctx.moveTo(markerX - 8, markerY);
|
||||||
|
|
@ -711,7 +743,7 @@ function drawOperationToolPreview(ctx, world) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16);
|
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([]);
|
ctx.setLineDash([]);
|
||||||
const markerX = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerX : item.x;
|
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) {
|
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) {
|
function renderLayerSortY(entity) {
|
||||||
|
|
@ -1764,6 +1796,7 @@ function render() {
|
||||||
}
|
}
|
||||||
renderPerfEnd(endAttachments);
|
renderPerfEnd(endAttachments);
|
||||||
|
|
||||||
|
drawWireConnectableHighlights(ctx, world);
|
||||||
drawToolTargetHoverHighlight(ctx, world);
|
drawToolTargetHoverHighlight(ctx, world);
|
||||||
|
|
||||||
const endEffects = renderPerfBegin("render.draw.effects");
|
const endEffects = renderPerfBegin("render.draw.effects");
|
||||||
|
|
|
||||||
|
|
@ -635,7 +635,7 @@
|
||||||
return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit];
|
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 || ""));
|
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
|
||||||
if (match) {
|
if (match) {
|
||||||
const parsed = parseInt(match[1], 36);
|
const parsed = parseInt(match[1], 36);
|
||||||
|
|
@ -648,7 +648,7 @@
|
||||||
h = Math.imul(h, 16777619);
|
h = Math.imul(h, 16777619);
|
||||||
}
|
}
|
||||||
return h >>> 0;
|
return h >>> 0;
|
||||||
}
|
});
|
||||||
function birthSeedFromDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
|
function birthSeedFromDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
|
||||||
const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || 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`;
|
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.x) || 0) !== (Number(row?.[1]) || 0)) mask |= 128;
|
||||||
if (!indexed && (Number(endpoint.y) || 0) !== (Number(row?.[2]) || 0)) mask |= 256;
|
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.t != null && Number.isFinite(Number(endpoint.t))) mask |= 512;
|
||||||
|
if (endpoint.port) mask |= 1024;
|
||||||
writer.u(mask);
|
writer.u(mask);
|
||||||
if (mask & 1) writer.str(endpoint.id);
|
if (mask & 1) writer.str(endpoint.id);
|
||||||
if (mask & 2) writer.str(endpoint.type);
|
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 & 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 & 256) writer.s((Number(endpoint.y) || 0) - (Number(row?.[2]) || 0));
|
||||||
if (mask & 512) writer.u(endpoint.t);
|
if (mask & 512) writer.u(endpoint.t);
|
||||||
|
if (mask & 1024) writer.str(endpoint.port);
|
||||||
}
|
}
|
||||||
function readPhysicsEndpoint(reader, row = []) {
|
function readPhysicsEndpoint(reader, row = []) {
|
||||||
const [present] = readBitFields(reader, [1]);
|
const [present] = readBitFields(reader, [1]);
|
||||||
|
|
@ -1124,7 +1126,8 @@
|
||||||
const x = (Number(row?.[1]) || 0) + ((mask & 128) ? reader.s() : 0);
|
const x = (Number(row?.[1]) || 0) + ((mask & 128) ? reader.s() : 0);
|
||||||
const y = (Number(row?.[2]) || 0) + ((mask & 256) ? reader.s() : 0);
|
const y = (Number(row?.[2]) || 0) + ((mask & 256) ? reader.s() : 0);
|
||||||
const t = (mask & 512) ? reader.u() : null;
|
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 = []) {
|
function writePhysicsConstraintExtra(writer, extra, type, row = []) {
|
||||||
const constraint = extra?.constraint && typeof extra.constraint === "object" ? extra.constraint : {};
|
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 === "zunchi") writeFixedSignedDefaults(writer, list, [0, 100, 100], 3);
|
||||||
else if (type === "signboard") writeSaveString(writer, list[0] || "");
|
else if (type === "signboard") writeSaveString(writer, list[0] || "");
|
||||||
else if (type === "robot_cleaner") writeFixedSignedDefaults(writer, list, [3, 0], 2);
|
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 === "duplicator") writeFixedSignedDefaults(writer, list, [-1], 1);
|
||||||
else if (type === "ball" || type === "balloon") writeFixedSignedDefaults(writer, list, [0, 0], 2);
|
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_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 === "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 (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 (isPinSaveType(type)) writeFixedSignedDefaults(writer, list, [0, -1, 0, 0, 0], 5);
|
||||||
else if (type === "fan") {
|
else if (type === "fan") {
|
||||||
|
|
@ -1242,11 +1246,12 @@
|
||||||
if (type === "zunchi") return readFixedSignedDefaults(reader, [0, 100, 100], 3);
|
if (type === "zunchi") return readFixedSignedDefaults(reader, [0, 100, 100], 3);
|
||||||
if (type === "signboard") return [readSaveString(reader)];
|
if (type === "signboard") return [readSaveString(reader)];
|
||||||
if (type === "robot_cleaner") return readFixedSignedDefaults(reader, [3, 0], 2);
|
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 === "duplicator") return readFixedSignedDefaults(reader, [-1], 1);
|
||||||
if (type === "ball" || type === "balloon") return readFixedSignedDefaults(reader, [0, 0], 2);
|
if (type === "ball" || type === "balloon") return readFixedSignedDefaults(reader, [0, 0], 2);
|
||||||
if (type === "ant_nest") 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 === "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 (type === "nest_box") return readFixedSignedDefaults(reader, [0, 0, 0, 0], 4);
|
||||||
if (isPinSaveType(type)) return readFixedSignedDefaults(reader, [0, -1, 0, 0, 0], 5);
|
if (isPinSaveType(type)) return readFixedSignedDefaults(reader, [0, -1, 0, 0, 0], 5);
|
||||||
if (type === "fan") {
|
if (type === "fan") {
|
||||||
|
|
@ -1459,16 +1464,11 @@
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_JSON_BINARY_SCHEMA_VERSION = 41;
|
|
||||||
const LEGACY_COMPACT_BINARY_SCHEMA_VERSION = 42;
|
|
||||||
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
|
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
|
||||||
const ACHIEVEMENT_SPELL_BINARY_VERSION = 3;
|
const ACHIEVEMENT_SPELL_BINARY_VERSION = 5;
|
||||||
const PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION = 2;
|
const ACHIEVEMENT_MASK_BYTES = 8;
|
||||||
const ACHIEVEMENT_MASK_BYTES = 7;
|
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17]);
|
||||||
const LEGACY_ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10]);
|
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071]);
|
||||||
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]);
|
|
||||||
|
|
||||||
function finiteNumberOrNull(value) {
|
function finiteNumberOrNull(value) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
|
|
@ -1493,34 +1493,14 @@
|
||||||
function achievementMaskBytesFromSpell(spell = null) {
|
function achievementMaskBytesFromSpell(spell = null) {
|
||||||
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
|
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
|
||||||
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
|
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
|
||||||
const version = Number(spell[0]);
|
if (Number(spell[0]) !== ACHIEVEMENT_SPELL_BINARY_VERSION) throw new Error("unsupported achievement spell version");
|
||||||
let baseMinute = 0;
|
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
|
||||||
let deltas = [];
|
const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[2]) || 0)));
|
||||||
let progress = [];
|
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255;
|
||||||
if (version === 1) {
|
for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
|
||||||
const maskHex = String(spell[1] || "").toLowerCase();
|
const baseMinute = toSafeUInt(spell[3]);
|
||||||
if (!/^[0-9a-f]{14}$/.test(maskHex)) throw new Error("invalid achievement spell mask");
|
const deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : [];
|
||||||
for (let i = 0; i < ACHIEVEMENT_MASK_BYTES; i++) bytes[i] = parseInt(maskHex.slice(i * 2, i * 2 + 2), 16);
|
const progress = Array.isArray(spell[5]) ? spell[5] : [];
|
||||||
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");
|
|
||||||
}
|
|
||||||
let unlockedCount = 0;
|
let unlockedCount = 0;
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
let value = byte;
|
let value = byte;
|
||||||
|
|
@ -1529,11 +1509,12 @@
|
||||||
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
|
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
|
||||||
return { bytes, baseMinute, deltas, progress };
|
return { bytes, baseMinute, deltas, progress };
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) {
|
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) {
|
||||||
let low = 0;
|
let low = 0;
|
||||||
let high = 0;
|
let high = 0;
|
||||||
for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
|
for (let i = 0; i < 4; i++) 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];
|
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) {
|
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);
|
for (const delta of normalized.deltas) writer.u(delta);
|
||||||
writeAchievementProgress(writer, normalized.progress);
|
writeAchievementProgress(writer, normalized.progress);
|
||||||
}
|
}
|
||||||
function readAchievementSpell(reader, schema = BINARY_SCHEMA_VERSION) {
|
function readAchievementSpell(reader) {
|
||||||
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
|
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
|
||||||
let unlockedCount = 0;
|
let unlockedCount = 0;
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
|
|
@ -1578,11 +1559,8 @@
|
||||||
const baseMinute = reader.u();
|
const baseMinute = reader.u();
|
||||||
const deltas = [];
|
const deltas = [];
|
||||||
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
|
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
|
||||||
const legacy = schema === LEGACY_COMPACT_BINARY_SCHEMA_VERSION;
|
const progress = readAchievementProgress(reader);
|
||||||
const progress = legacy
|
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress);
|
||||||
? 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);
|
|
||||||
}
|
}
|
||||||
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
|
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
|
||||||
const value = metadata && typeof metadata === "object" ? metadata : {};
|
const value = metadata && typeof metadata === "object" ? metadata : {};
|
||||||
|
|
@ -1749,7 +1727,7 @@
|
||||||
const formatHeader = reader.b();
|
const formatHeader = reader.b();
|
||||||
const schema = formatHeader & 0x7f;
|
const schema = formatHeader & 0x7f;
|
||||||
const hasStringTable = Boolean(formatHeader & 0x80);
|
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);
|
readStringTable(reader, hasStringTable);
|
||||||
const w = readWorld(reader);
|
const w = readWorld(reader);
|
||||||
const tCount = reader.u();
|
const tCount = reader.u();
|
||||||
|
|
@ -1760,12 +1738,7 @@
|
||||||
const items = readItemBlocks(reader);
|
const items = readItemBlocks(reader);
|
||||||
let g = null;
|
let g = null;
|
||||||
try {
|
try {
|
||||||
if (schema === LEGACY_JSON_BINARY_SCHEMA_VERSION) {
|
g = readAchievementMetadata(reader, tCount, items.length, schema);
|
||||||
const encodedMetadata = reader.str();
|
|
||||||
g = encodedMetadata ? JSON.parse(encodedMetadata) : null;
|
|
||||||
} else {
|
|
||||||
g = readAchievementMetadata(reader, tCount, items.length, schema);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error("invalid achievement metadata in binary save");
|
throw new Error("invalid achievement metadata in binary save");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
|
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");
|
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
|
||||||
|
|
||||||
const SNAPSHOT_VERSION = 35;
|
const SNAPSHOT_VERSION = 40;
|
||||||
const BINARY_SCHEMA_VERSION = 43;
|
const BINARY_SCHEMA_VERSION = 49;
|
||||||
|
|
||||||
function moodValue(index, fallback = "relaxed") {
|
function moodValue(index, fallback = "relaxed") {
|
||||||
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
|
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
|
||||||
|
|
|
||||||
217
js/seesaw_system.js
Normal file
217
js/seesaw_system.js
Normal 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);
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
|
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
|
||||||
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
||||||
const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]);
|
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({
|
const DETECTOR_METRICS = Object.freeze({
|
||||||
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
|
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
|
||||||
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 },
|
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);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
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) {
|
function normalizeMinute(value, fallback = 0) {
|
||||||
return Math.max(0, Math.min(1435, Math.round(num(value, fallback) / 5) * 5));
|
return Math.max(0, Math.min(1435, Math.round(num(value, fallback) / 5) * 5));
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +91,18 @@
|
||||||
return found;
|
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) {
|
function endpointPoint(endpoint, worldRef) {
|
||||||
return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef)
|
return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef)
|
||||||
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
|
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
|
||||||
|
|
@ -145,9 +157,10 @@
|
||||||
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
|
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
|
||||||
if (!item || item.dead) continue;
|
if (!item || item.dead) continue;
|
||||||
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
|
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
|
||||||
item.pressureComparator = normalizeComparator(item.pressureComparator);
|
|
||||||
const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1;
|
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.pressureWidth = clamp(item.pressureWidth || 220, 60, 840);
|
||||||
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
|
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
|
||||||
item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360);
|
item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360);
|
||||||
|
|
@ -159,7 +172,7 @@
|
||||||
value = active ? 1 : 0;
|
value = active ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
value = detectorMetricValue(item, worldRef);
|
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;
|
if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true;
|
||||||
item.pressureCount = value;
|
item.pressureCount = value;
|
||||||
|
|
@ -192,30 +205,40 @@
|
||||||
|
|
||||||
function buildSignalNetwork(worldRef) {
|
function buildSignalNetwork(worldRef) {
|
||||||
const adjacency = new Map();
|
const adjacency = new Map();
|
||||||
const connected = new Set();
|
const connectedNodes = new Set();
|
||||||
const wires = [];
|
const wires = [];
|
||||||
|
const circuit = global.TarinaiCircuitBoardSystem;
|
||||||
let wireGeometryMoved = false;
|
let wireGeometryMoved = false;
|
||||||
const addEdge = (a, b) => {
|
const addEdge = (a, b) => {
|
||||||
if (!a || !b || a === b) return;
|
if (!a || !b || a === b) return;
|
||||||
if (!adjacency.has(a)) adjacency.set(a, new Set());
|
if (!adjacency.has(a)) adjacency.set(a, new Set());
|
||||||
if (!adjacency.has(b)) adjacency.set(b, new Set());
|
if (!adjacency.has(b)) adjacency.set(b, new Set());
|
||||||
adjacency.get(a).add(b); adjacency.get(b).add(a);
|
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 || []) {
|
for (const item of worldRef.items || []) {
|
||||||
if (!item || item.dead) continue;
|
if (!item || item.dead) continue;
|
||||||
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) {
|
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) {
|
||||||
item._wireSignalDriven = false; item._wireSignalOn = false;
|
item._wireSignalDriven = false; item._wireSignalOn = false;
|
||||||
item._directSignalDriven = false; item._directSignalOn = 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 || []) {
|
for (const wire of worldRef.items || []) {
|
||||||
if (!isWire(wire)) continue;
|
if (!isWire(wire)) continue;
|
||||||
const pb = global.TarinaiPhysicsBodySystem;
|
const pb = global.TarinaiPhysicsBodySystem;
|
||||||
const endpointA = pb?.endpoint?.(wire, 0);
|
const endpointA = pb?.endpoint?.(wire, 0);
|
||||||
const endpointB = pb?.endpoint?.(wire, 1);
|
const endpointB = pb?.endpoint?.(wire, 1);
|
||||||
const a = endpointObject(endpointA, worldRef);
|
const objectA = endpointObject(endpointA, worldRef);
|
||||||
const b = endpointObject(endpointB, worldRef);
|
const objectB = endpointObject(endpointB, worldRef);
|
||||||
|
const nodeA = endpointSignalNode(endpointA, objectA, worldRef);
|
||||||
|
const nodeB = endpointSignalNode(endpointB, objectB, worldRef);
|
||||||
const pointA = endpointPoint(endpointA, worldRef);
|
const pointA = endpointPoint(endpointA, worldRef);
|
||||||
const pointB = endpointPoint(endpointB, worldRef);
|
const pointB = endpointPoint(endpointB, worldRef);
|
||||||
if (pointA && pointB) {
|
if (pointA && pointB) {
|
||||||
|
|
@ -227,39 +250,81 @@
|
||||||
if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; }
|
if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; }
|
||||||
if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; }
|
if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; }
|
||||||
}
|
}
|
||||||
wires.push({ wire, a, b });
|
wires.push({ wire, a: objectA, b: objectB, nodeA, nodeB });
|
||||||
if (a && b) addEdge(a, b);
|
if (nodeA && nodeB) addEdge(nodeA, nodeB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const components = [];
|
||||||
|
const componentOf = new Map();
|
||||||
const visited = new Set();
|
const visited = new Set();
|
||||||
for (const start of connected) {
|
for (const startNode of connectedNodes) {
|
||||||
if (visited.has(start)) continue;
|
if (visited.has(startNode)) continue;
|
||||||
const stack = [start];
|
const stack = [startNode], nodes = [];
|
||||||
const component = [];
|
|
||||||
let active = false;
|
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
const node = stack.pop();
|
const node = stack.pop();
|
||||||
if (!node || visited.has(node)) continue;
|
if (!node || visited.has(node)) continue;
|
||||||
visited.add(node); component.push(node);
|
visited.add(node); nodes.push(node);
|
||||||
if (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) active = true;
|
|
||||||
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
|
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 component = { nodes, active: false, hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")) };
|
||||||
const componentSet = new Set(component);
|
components.push(component);
|
||||||
for (const node of 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;
|
if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue;
|
||||||
node._wireSignalDriven = hasSource;
|
node._wireSignalDriven = component.hasSource;
|
||||||
node._wireSignalOn = hasSource && active;
|
node._wireSignalOn = component.hasSource && component.active;
|
||||||
setEffectivePower(node, hasSource, active, worldRef);
|
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 || []) {
|
for (const item of worldRef.items || []) {
|
||||||
if (!item || item.dead) continue;
|
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;
|
item._wireSignalDriven = false; item._wireSignalOn = false;
|
||||||
setEffectivePower(item, false, false, worldRef);
|
setEffectivePower(item, false, false, worldRef);
|
||||||
}
|
}
|
||||||
|
|
@ -272,12 +337,7 @@
|
||||||
return wires;
|
return wires;
|
||||||
}
|
}
|
||||||
|
|
||||||
function distanceToSegment(px, py, ax, ay, bx, by) {
|
const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shockTarget(worldRef, wire, target, now, { connected = false } = {}) {
|
function shockTarget(worldRef, wire, target, now, { connected = false } = {}) {
|
||||||
if (!target || target.dead) return false;
|
if (!target || target.dead) return false;
|
||||||
|
|
@ -306,12 +366,14 @@
|
||||||
life: 0.28,
|
life: 0.28,
|
||||||
color: "rgba(118,224,255,0.96)",
|
color: "rgba(118,224,255,0.96)",
|
||||||
});
|
});
|
||||||
|
global.TarinaiAchievements?.recordWireShock?.(wire, target, { world: worldRef, connected });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function damageFromWires(worldRef, wires) {
|
function damageFromWires(worldRef, wires) {
|
||||||
const now = num(worldRef.time);
|
const now = num(worldRef.time);
|
||||||
for (const { wire, a, b } of wires) {
|
for (const { wire, a, b } of wires) {
|
||||||
|
global.TarinaiAchievements?.recordWirePowerState?.(wire, Boolean(wire.signalActive));
|
||||||
if (!wire.signalActive) continue;
|
if (!wire.signalActive) continue;
|
||||||
// Living endpoints are directly energized even through insulated wire.
|
// 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 });
|
if (a && (a.radius || a.kind === "worker" || a.kind === "queen")) shockTarget(worldRef, wire, a, now, { connected: true });
|
||||||
|
|
@ -358,6 +420,9 @@
|
||||||
isWire,
|
isWire,
|
||||||
isMinuteInRange,
|
isMinuteInRange,
|
||||||
currentMinuteOfDay,
|
currentMinuteOfDay,
|
||||||
|
isSignalConnectable,
|
||||||
|
DIRECT_SIGNAL_SINK_TYPES,
|
||||||
|
SIGNAL_SOURCE_TYPES,
|
||||||
SENSOR_METRICS: DETECTOR_METRICS,
|
SENSOR_METRICS: DETECTOR_METRICS,
|
||||||
DETECTOR_METRICS,
|
DETECTOR_METRICS,
|
||||||
PRESSURE_TARGETS,
|
PRESSURE_TARGETS,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
return Number.isFinite(n) ? n / scale : fallback;
|
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 || ""));
|
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
|
||||||
if (match) {
|
if (match) {
|
||||||
const parsed = parseInt(match[1], 36);
|
const parsed = parseInt(match[1], 36);
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
h = Math.imul(h, 16777619);
|
h = Math.imul(h, 16777619);
|
||||||
}
|
}
|
||||||
return h >>> 0;
|
return h >>> 0;
|
||||||
}
|
});
|
||||||
function compactBirthDescriptor(t = null, fallbackSerial = 1) {
|
function compactBirthDescriptor(t = null, fallbackSerial = 1) {
|
||||||
const seed = String(t?.birthSeed || t?.familyKey || t?.id || "");
|
const seed = String(t?.birthSeed || t?.familyKey || t?.id || "");
|
||||||
const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0;
|
const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0;
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
const proto = Object.getPrototypeOf(value);
|
const proto = Object.getPrototypeOf(value);
|
||||||
return proto === Object.prototype || proto === null;
|
return proto === Object.prototype || proto === null;
|
||||||
}
|
}
|
||||||
function clonePlain(value, depth = 0) {
|
function cloneSnapshotPlain(value, depth = 0) {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
const type = typeof value;
|
const type = typeof value;
|
||||||
if (type === "number") return Number.isFinite(value) ? value : 0;
|
if (type === "number") return Number.isFinite(value) ? value : 0;
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const v of value) {
|
for (const v of value) {
|
||||||
const c = clonePlain(v, depth + 1);
|
const c = cloneSnapshotPlain(v, depth + 1);
|
||||||
if (c !== undefined) out.push(c);
|
if (c !== undefined) out.push(c);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const [key, v] of Object.entries(value)) {
|
for (const [key, v] of Object.entries(value)) {
|
||||||
if (DEFAULT_SKIP.has(key)) continue;
|
if (DEFAULT_SKIP.has(key)) continue;
|
||||||
const c = clonePlain(v, depth + 1);
|
const c = cloneSnapshotPlain(v, depth + 1);
|
||||||
if (c !== undefined) out[key] = c;
|
if (c !== undefined) out[key] = c;
|
||||||
}
|
}
|
||||||
return out;
|
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 === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)];
|
||||||
if (type === "signboard") return [item.text || ""];
|
if (type === "signboard") return [item.text || ""];
|
||||||
if (type === "robot_cleaner") return [Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0];
|
if (type === "robot_cleaner") return [Math.max(0, Math.min(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)) {
|
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
|
||||||
const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex);
|
const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex);
|
||||||
if (packed) return packed;
|
if (packed) return packed;
|
||||||
|
|
@ -427,7 +428,7 @@
|
||||||
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
|
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
|
||||||
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
|
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
|
||||||
if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 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 === "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 (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)];
|
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"];
|
const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"];
|
||||||
return [
|
return [
|
||||||
Math.max(0, targets.indexOf(item.pressureTarget || "tarinai")),
|
Math.max(0, targets.indexOf(item.pressureTarget || "tarinai")),
|
||||||
item.pressureComparator === "lte" ? 1 : 0,
|
q(item.pressureMin ?? 1, 10),
|
||||||
q(item.pressureThreshold ?? 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.pressureWidth || 220) | 0)),
|
||||||
Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0)),
|
Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0)),
|
||||||
Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0)),
|
Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0)),
|
||||||
|
|
@ -483,8 +484,8 @@
|
||||||
const def = global.StructureRegistry?.get?.(type);
|
const def = global.StructureRegistry?.get?.(type);
|
||||||
if (def) {
|
if (def) {
|
||||||
item.label = def.label;
|
item.label = def.label;
|
||||||
item.roles = clonePlain(def.roles) || item.roles || {};
|
item.roles = cloneSnapshotPlain(def.roles) || item.roles || {};
|
||||||
item.needEffects = clonePlain(def.needEffects) || item.needEffects || {};
|
item.needEffects = cloneSnapshotPlain(def.needEffects) || item.needEffects || {};
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +517,7 @@
|
||||||
} else if (type === "firecracker" || type === "flame_firecracker") {
|
} else if (type === "firecracker" || type === "flame_firecracker") {
|
||||||
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
|
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
|
||||||
} else if (type === "sticky_bomb") {
|
} 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") {
|
} else if (type === "fire") {
|
||||||
item.flameLife = u(extra[0], 10, item.flameLife || 6);
|
item.flameLife = u(extra[0], 10, item.flameLife || 6);
|
||||||
} else if (type === "nest_box") {
|
} else if (type === "nest_box") {
|
||||||
|
|
@ -530,6 +531,11 @@
|
||||||
}
|
}
|
||||||
} else if (global.isPinType(type)) {
|
} 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);
|
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") {
|
} else if (type === "fan") {
|
||||||
const fallback = global.defaultItemAngle(type);
|
const fallback = global.defaultItemAngle(type);
|
||||||
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);
|
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);
|
||||||
|
|
@ -542,8 +548,9 @@
|
||||||
} else if (type === "pressure_switch") {
|
} 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"];
|
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.pressureTarget = targets[Number(extra[0]) | 0] || "tarinai";
|
||||||
item.pressureComparator = Number(extra[1]) === 1 ? "lte" : "gte";
|
item.pressureMin = u(extra[1], 10, 1);
|
||||||
item.pressureThreshold = u(extra[2], 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.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.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));
|
item.pressureTimeStart = Math.max(0, Math.min(1435, Number(extra[5] ?? 360) | 0));
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clonePlain(value) {
|
const clonePlain = global.TarinaiCoreHelpers?.clonePlain || (value => JSON.parse(JSON.stringify(value || {})));
|
||||||
return JSON.parse(JSON.stringify(value || {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
class TarinaiStructure {
|
class TarinaiStructure {
|
||||||
constructor(definition, owner, x, y, world) {
|
constructor(definition, owner, x, y, world) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
{ id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } },
|
{ 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: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } },
|
||||||
{ id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(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: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } },
|
||||||
{ id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } },
|
{ id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } },
|
||||||
{ id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } },
|
{ id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } },
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,22 @@ const TARINAI_ACTION_DEFINITIONS = [
|
||||||
createApproachMateActionSpec(),
|
createApproachMateActionSpec(),
|
||||||
createFightRivalActionSpec(),
|
createFightRivalActionSpec(),
|
||||||
createIntimidateEnemyActionSpec(),
|
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",
|
id: "play",
|
||||||
need: "fulfill",
|
need: "fulfill",
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,11 @@
|
||||||
|
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const registry = new Map();
|
const registry = new Map();
|
||||||
|
const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object"));
|
||||||
function isObject(value) {
|
const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => {
|
||||||
return Boolean(value && typeof value === "object");
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteOr(value, fallback) {
|
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
});
|
||||||
|
|
||||||
|
|
||||||
function hasOwnFn(obj, key) {
|
function hasOwnFn(obj, key) {
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,11 @@
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const Tarinai = global.Tarinai;
|
const Tarinai = global.Tarinai;
|
||||||
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
|
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
|
||||||
|
const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object" && !Array.isArray(value)));
|
||||||
function isObject(value) {
|
const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => {
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteOr(value, fallback) {
|
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
});
|
||||||
|
|
||||||
function nowFor(tarinai = null) {
|
function nowFor(tarinai = null) {
|
||||||
return finiteOr(tarinai?.world?.time, 0);
|
return finiteOr(tarinai?.world?.time, 0);
|
||||||
|
|
@ -82,9 +78,7 @@
|
||||||
return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || "");
|
return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEffectCauseText(text = "") {
|
const isEffectCauseText = global.TarinaiCoreHelpers?.isEffectCauseText || (text => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, "")));
|
||||||
return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
|
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
|
||||||
if (!isObject(input)) return null;
|
if (!isObject(input)) return null;
|
||||||
|
|
|
||||||
|
|
@ -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 === "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 === "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 === "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 === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b";
|
||||||
if (actionId === "wander_lightly") return "\u5c11\u3057\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 = "") {
|
function isEffectCauseText(text = "") {
|
||||||
return typeof globalThis.isTarinaiEffectCauseText === "function"
|
return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text)
|
||||||
? globalThis.isTarinaiEffectCauseText(text)
|
?? globalThis.isTarinaiEffectCauseText?.(text));
|
||||||
: /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function forcedCausePhrase(source = "", fallback = "") {
|
function forcedCausePhrase(source = "", fallback = "") {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
(function (global) {
|
(function (global) {
|
||||||
function updateCandidate(tarinai, ctx, it, dt = 0) {
|
function updateCandidate(tarinai, ctx, it, dt = 0) {
|
||||||
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
|
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 { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx;
|
||||||
|
|
||||||
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" });
|
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" });
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||||
isServingFoodItem(it) {
|
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));
|
return Boolean(it && isServingFoodType(it.type));
|
||||||
},
|
},
|
||||||
foodItemHasServingLeft(it) {
|
foodItemHasServingLeft(it) {
|
||||||
if (!it || it.dead) return false;
|
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;
|
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
|
||||||
this.ensureFoodServings(it);
|
this.ensureFoodServings(it);
|
||||||
return (it.foodServingsRemaining || 0) > 0;
|
return (it.foodServingsRemaining || 0) > 0;
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
},
|
},
|
||||||
consumeFoodServing(it, nutrition) {
|
consumeFoodServing(it, nutrition) {
|
||||||
if (it?.type === "duplicator") {
|
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);
|
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
|
||||||
it.usedCount = (it.usedCount || 0) + 1;
|
it.usedCount = (it.usedCount || 0) + 1;
|
||||||
return value;
|
return value;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function effectiveFoodRoleMatches(item, role) {
|
||||||
function roleMatches(item, role) {
|
function roleMatches(item, role) {
|
||||||
if (!item || item.dead) return false;
|
if (!item || item.dead) return false;
|
||||||
if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") 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 (item.roles?.[role]) return true;
|
||||||
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
|
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";
|
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed";
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ const TARINAI_ACTION_PRIORITY = {
|
||||||
build_plushie: 28,
|
build_plushie: 28,
|
||||||
use_plushie: 24,
|
use_plushie: 24,
|
||||||
return_owned_structure: 22,
|
return_owned_structure: 22,
|
||||||
|
play_seesaw: 27,
|
||||||
play: 20,
|
play: 20,
|
||||||
wander_lightly: 10,
|
wander_lightly: 10,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ function actionSelectionBonus(action, need, tarinai, options = {}) {
|
||||||
const p = tarinai?.fulfillReasonParts || {};
|
const p = tarinai?.fulfillReasonParts || {};
|
||||||
if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0);
|
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 === "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")) {
|
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;
|
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);
|
const status = tarinai?.temperatureStatus || tarinai?.world?.temperatureStatusFor?.(tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15), tarinai);
|
||||||
return Boolean(status && (status.discomfort || 0) >= 2.5);
|
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);
|
if (need !== "social" || !action?.subNeed) return shouldStartNeedAction(tarinai, need, needs);
|
||||||
const parts = tarinai?.socialReasonParts || {};
|
const parts = tarinai?.socialReasonParts || {};
|
||||||
const subNeed = action.subNeed;
|
const subNeed = action.subNeed;
|
||||||
|
|
@ -376,6 +379,9 @@ function choosePriorityNeedAction(needs, tarinai, world) {
|
||||||
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
|
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
|
||||||
priority = (status?.harmful || (status?.harmExcess || 0) > 0) ? 92 : 53;
|
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") {
|
if (action.id === "eat_food") {
|
||||||
const hunger = Number(tarinai?.hunger || 0) || 0;
|
const hunger = Number(tarinai?.hunger || 0) || 0;
|
||||||
if (hunger >= 98 || needValue >= 88) priority = Math.max(priority, 120);
|
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 === "panic_escape" && (tarinai.fearTimer || 0) > 0.04 && tarinai.state === "panic")
|
||||||
|| (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04)
|
|| (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04)
|
||||||
|| (action.id === "sunbath" && (tarinai.sunbathTimer || 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);
|
|| ((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);
|
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))) {
|
if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || isCurrentBehaviorForced(tarinai))) {
|
||||||
|
|
|
||||||
|
|
@ -473,8 +473,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.target && !this.target.dead) {
|
if (this.target && !this.target.dead) {
|
||||||
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target;
|
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", "ant_attack", "seek_temperature"]);
|
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 routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target;
|
||||||
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
|
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
|
||||||
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
|
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
|
||||||
|
|
@ -555,6 +555,9 @@
|
||||||
} else if (this.state === "play_ball") {
|
} else if (this.state === "play_ball") {
|
||||||
strength = d < this.radius + 18 ? 1.75 : 1.28;
|
strength = d < this.radius + 18 ? 1.75 : 1.28;
|
||||||
sign = 1;
|
sign = 1;
|
||||||
|
} else if (this.state === "play_seesaw") {
|
||||||
|
strength = d < this.radius + 12 ? 0.42 : 1.18;
|
||||||
|
sign = 1;
|
||||||
} else if (this.state === "fight") {
|
} else if (this.state === "fight") {
|
||||||
const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37);
|
const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37);
|
||||||
if (d < 18) {
|
if (d < 18) {
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
|
if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
|
||||||
lane = "birth";
|
lane = "birth";
|
||||||
interval = isVisible ? 0.12 : 0.24;
|
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";
|
lane = "moving";
|
||||||
interval = isVisible ? 0.10 : (isNear ? 0.22 : 0.55);
|
interval = isVisible ? 0.10 : (isNear ? 0.22 : 0.55);
|
||||||
} else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) {
|
} else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function colonyMoodTip() {
|
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");
|
return String(mood?.description || "\u5927\u304d\u306a\u554f\u984c\u304c\u5c11\u306a\u304f\u3001\u843d\u3061\u7740\u3044\u305f\u72b6\u614b\u3067\u3059\u3002");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
|
||||||
|
|
||||||
function lineageNodeSort(a, b) {
|
function lineageNodeSort(a, b) {
|
||||||
return (a.generation || 1) - (b.generation || 1) ||
|
return (a.generation || 1) - (b.generation || 1) ||
|
||||||
(a.birthTime || 0) - (b.birthTime || 0) ||
|
(a.birthTime || 0) - (b.birthTime || 0) ||
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
|
||||||
|
|
||||||
function lineageSplitByActualEdges(component) {
|
function lineageSplitByActualEdges(component) {
|
||||||
const family = lineageEdgeFamily(world.family || {});
|
const family = lineageEdgeFamily(world.family || {});
|
||||||
const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values());
|
const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
|
||||||
|
|
||||||
function resizeCanvas() {
|
function resizeCanvas() {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const perfScale = window.TarinaiPerf.dprScale();
|
const perfScale = window.TarinaiPerf.dprScale();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
|
||||||
|
|
||||||
function loadLogPushSettings() {
|
function loadLogPushSettings() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("tarinai_log_push_settings_v2");
|
const raw = localStorage.getItem("tarinai_log_push_settings_v2");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const escapeHtml = window.TarinaiUIHelpers.htmlEscape;
|
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
|
||||||
|
|
||||||
function quoteNameInRecordText(text, name) {
|
function quoteNameInRecordText(text, name) {
|
||||||
const raw = String(text || "");
|
const raw = String(text || "");
|
||||||
|
|
@ -334,6 +334,7 @@ function isIdleDisplayText(value = "") {
|
||||||
function reasonLabel(t) {
|
function reasonLabel(t) {
|
||||||
if (!t) return "\u306a\u3057";
|
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 (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);
|
const behavior = behaviorText(t);
|
||||||
if (behavior) {
|
if (behavior) {
|
||||||
const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior;
|
const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior;
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ function renderToolPalette() {
|
||||||
if (!def || def.simulationOnly || toolId === "observe") continue;
|
if (!def || def.simulationOnly || toolId === "observe") continue;
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.className = "tool";
|
btn.className = "tool";
|
||||||
const hasPhysicsIcon = category.id === "physics" && attachPhysicsToolIcon(btn, toolId);
|
const hasPhysicsIcon = attachPhysicsToolIcon(btn, toolId);
|
||||||
const hasTextIcon = !hasPhysicsIcon && applyToolTextIcon(btn, def, toolId);
|
const hasTextIcon = !hasPhysicsIcon && applyToolTextIcon(btn, def, toolId);
|
||||||
if (!hasPhysicsIcon && !hasTextIcon) applyToolImageIcon(btn, def);
|
if (!hasPhysicsIcon && !hasTextIcon) applyToolImageIcon(btn, def);
|
||||||
btn.dataset.tool = toolId;
|
btn.dataset.tool = toolId;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const APP_VERSION = "39.16.60";
|
const APP_VERSION = "39.16.74";
|
||||||
const APP_BUILD = "tarinai-ui-v39-16-60";
|
const APP_BUILD = "tarinai-ui-v39-16-74";
|
||||||
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||||
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ class World { constructor() {
|
||||||
this.liveIdSerial = 0;
|
this.liveIdSerial = 0;
|
||||||
this.liveTarinai = new Map();
|
this.liveTarinai = new Map();
|
||||||
this.lastBirthAt = -999;
|
this.lastBirthAt = -999;
|
||||||
|
this.lastColonyMoodPopulation = NaN;
|
||||||
|
this.nextColonyMoodEvalAt = 0;
|
||||||
this.maxGeneration = 1;
|
this.maxGeneration = 1;
|
||||||
this.family = {};
|
this.family = {};
|
||||||
this.familyVersion = 0;
|
this.familyVersion = 0;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,15 @@
|
||||||
return dialog;
|
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() {
|
function ensureSignboardEditor() {
|
||||||
return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `
|
return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `
|
||||||
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
|
<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 (apply) apply.onclick = () => commit(area?.value || "");
|
||||||
if (clear) clear.onclick = () => commit("");
|
if (clear) clear.onclick = () => commit("");
|
||||||
if (cancel) cancel.onclick = close;
|
bindEditorClose(dialog, close, ["#signboardEditorCancel", "#signboardEditorClose"]);
|
||||||
if (closeBtn) closeBtn.onclick = close;
|
|
||||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
|
||||||
dialog.classList.remove("hidden");
|
dialog.classList.remove("hidden");
|
||||||
syncCount();
|
syncCount();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -211,11 +218,9 @@
|
||||||
};
|
};
|
||||||
writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask);
|
writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask);
|
||||||
if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed);
|
if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed);
|
||||||
if (closeBtn) closeBtn.onclick = close;
|
bindEditorClose(dialog, close, ["#robotCleanerEditorClose", "#robotCleanerCancel"]);
|
||||||
if (cancel) cancel.onclick = close;
|
|
||||||
if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; };
|
if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; };
|
||||||
if (apply) apply.onclick = () => commit(readMask());
|
if (apply) apply.onclick = () => commit(readMask());
|
||||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
|
||||||
dialog.classList.remove("hidden");
|
dialog.classList.remove("hidden");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -310,9 +315,7 @@
|
||||||
};
|
};
|
||||||
if (stop) stop.onclick = () => commit(true);
|
if (stop) stop.onclick = () => commit(true);
|
||||||
if (apply) apply.onclick = () => commit(false);
|
if (apply) apply.onclick = () => commit(false);
|
||||||
if (cancel) cancel.onclick = close;
|
bindEditorClose(dialog, close, ["#fanEditorCancel", "#fanEditorClose"]);
|
||||||
if (closeBtn) closeBtn.onclick = close;
|
|
||||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
|
||||||
dialog.classList.remove("hidden");
|
dialog.classList.remove("hidden");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -445,9 +448,7 @@
|
||||||
global.render?.();
|
global.render?.();
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
if (cancel) cancel.onclick = close;
|
bindEditorClose(dialog, close, ["#rotatorCancel", "#rotatorEditorClose"]);
|
||||||
if (closeBtn) closeBtn.onclick = close;
|
|
||||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
|
||||||
controller.draw();
|
controller.draw();
|
||||||
dialog.classList.remove("hidden");
|
dialog.classList.remove("hidden");
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -591,9 +592,7 @@
|
||||||
global.render?.();
|
global.render?.();
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
if (cancel) cancel.onclick = close;
|
bindEditorClose(dialog, close, ["#reciprocatorCancel", "#reciprocatorEditorClose"]);
|
||||||
if (closeBtn) closeBtn.onclick = close;
|
|
||||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
|
||||||
controller.draw();
|
controller.draw();
|
||||||
dialog.classList.remove("hidden");
|
dialog.classList.remove("hidden");
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -659,13 +658,14 @@
|
||||||
fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null,
|
fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null,
|
||||||
};
|
};
|
||||||
if (item.type === "signboard") data.text = item.text || "";
|
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 === "gate_fence") data.gateOpen = Boolean(item.gateOpen);
|
||||||
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
|
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 === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); }
|
||||||
if (item.type === "pressure_switch") {
|
if (item.type === "pressure_switch") {
|
||||||
data.pressureTarget = item.pressureTarget || "tarinai";
|
data.pressureTarget = item.pressureTarget || "tarinai";
|
||||||
data.pressureComparator = item.pressureComparator === "lte" ? "lte" : "gte";
|
data.pressureMin = Math.max(-999, Math.min(999, Number(item.pressureMin ?? 1) || 0));
|
||||||
data.pressureThreshold = Math.max(-999, Math.min(999, Number(item.pressureThreshold ?? 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.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.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));
|
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;
|
item.fanBodyAngle = Number(data.fanBodyAngle ?? item.fanBodyAngle ?? item.angle ?? 0) || 0;
|
||||||
}
|
}
|
||||||
if (item.type === "signboard") item.text = String(data.text || "");
|
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 === "gate_fence") item.gateOpen = Boolean(data.gateOpen);
|
||||||
if (item.type === "duplicator") {
|
if (item.type === "duplicator") {
|
||||||
item.storedFoodType = String(data.storedFoodType || "");
|
item.storedFoodType = String(data.storedFoodType || "");
|
||||||
|
|
@ -734,8 +735,9 @@
|
||||||
if (item.type === "pressure_switch") {
|
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"]);
|
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.pressureTarget = targets.has(data.pressureTarget) ? data.pressureTarget : "tarinai";
|
||||||
item.pressureComparator = data.pressureComparator === "lte" ? "lte" : "gte";
|
item.pressureMin = Math.max(-999, Math.min(999, Number(data.pressureMin ?? 1) || 0));
|
||||||
item.pressureThreshold = Math.max(-999, Math.min(999, Number(data.pressureThreshold ?? 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.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.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));
|
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-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">
|
<div class="rotator-editor-controls">
|
||||||
<label>\u691c\u77e5\u5bfe\u8c61 <select id="pressureSwitchTarget">${options}</select></label>
|
<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-time-control">
|
||||||
<div id="pressureSwitchCountRow" class="pressure-numeric-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 class="pressure-time-summary"><span>\u3057\u304d\u3044\u5024</span><output id="pressureSwitchCountLabel">1</output></div>
|
<div id="pressureSwitchValueRange" class="pressure-time-range">
|
||||||
<input id="pressureSwitchCount" class="pressure-value-slider" type="range" min="0" max="300" step="1">
|
<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>
|
||||||
<div id="pressureSwitchWidthRow" class="pressure-numeric-control pressure-spatial-control">
|
<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>
|
<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;
|
if (!item || item.type !== "pressure_switch") return false;
|
||||||
const dialog = ensureSignalItemEditor();
|
const dialog = ensureSignalItemEditor();
|
||||||
const target = dialog.querySelector("#pressureSwitchTarget");
|
const target = dialog.querySelector("#pressureSwitchTarget");
|
||||||
const comparator = dialog.querySelector("#pressureSwitchComparator");
|
const valueRow = dialog.querySelector("#pressureSwitchCountRow");
|
||||||
const count = dialog.querySelector("#pressureSwitchCount");
|
const valueRange = dialog.querySelector("#pressureSwitchValueRange");
|
||||||
const countLabel = dialog.querySelector("#pressureSwitchCountLabel");
|
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 width = dialog.querySelector("#pressureSwitchWidth");
|
||||||
const widthLabel = dialog.querySelector("#pressureSwitchWidthLabel");
|
const widthLabel = dialog.querySelector("#pressureSwitchWidthLabel");
|
||||||
const height = dialog.querySelector("#pressureSwitchHeight");
|
const height = dialog.querySelector("#pressureSwitchHeight");
|
||||||
const heightLabel = dialog.querySelector("#pressureSwitchHeightLabel");
|
const heightLabel = dialog.querySelector("#pressureSwitchHeightLabel");
|
||||||
const comparatorRow = dialog.querySelector("#pressureSwitchComparatorRow");
|
|
||||||
const countRow = dialog.querySelector("#pressureSwitchCountRow");
|
|
||||||
const widthRow = dialog.querySelector("#pressureSwitchWidthRow");
|
const widthRow = dialog.querySelector("#pressureSwitchWidthRow");
|
||||||
const heightRow = dialog.querySelector("#pressureSwitchHeightRow");
|
const heightRow = dialog.querySelector("#pressureSwitchHeightRow");
|
||||||
const timeRow = dialog.querySelector("#pressureSwitchTimeRow");
|
const timeRow = dialog.querySelector("#pressureSwitchTimeRow");
|
||||||
|
|
@ -885,13 +898,24 @@
|
||||||
const timeEndLabel = dialog.querySelector("#pressureSwitchTimeEndLabel");
|
const timeEndLabel = dialog.querySelector("#pressureSwitchTimeEndLabel");
|
||||||
const normalizeMinute = (value, fallback) => Math.max(0, Math.min(1435, Math.round((Number.isFinite(Number(value)) ? Number(value) : fallback) / 5) * 5));
|
const normalizeMinute = (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 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";
|
target.value = DETECTOR_TARGETS[item.pressureTarget] ? item.pressureTarget : "tarinai";
|
||||||
comparator.value = item.pressureComparator === "lte" ? "lte" : "gte";
|
const initialDef = targetDef();
|
||||||
count.value = String(Number(item.pressureThreshold ?? DETECTOR_TARGETS[target.value].threshold));
|
valueMin.value = String(normalizeValue(item.pressureMin, initialDef.threshold, initialDef));
|
||||||
|
valueMax.value = String(normalizeValue(item.pressureMax, initialDef.max, initialDef));
|
||||||
width.value = String(item.pressureWidth || 220);
|
width.value = String(item.pressureWidth || 220);
|
||||||
height.value = String(item.pressureHeight || 160);
|
height.value = String(item.pressureHeight || 160);
|
||||||
timeStart.value = String(normalizeMinute(item.pressureTimeStart, 360));
|
timeStart.value = String(normalizeMinute(item.pressureTimeStart, 360));
|
||||||
timeEnd.value = String(normalizeMinute(item.pressureTimeEnd, 1080));
|
timeEnd.value = String(normalizeMinute(item.pressureTimeEnd, 1080));
|
||||||
|
|
||||||
const updateTimeRange = () => {
|
const updateTimeRange = () => {
|
||||||
const start = normalizeMinute(timeStart.value, 360), end = normalizeMinute(timeEnd.value, 1080);
|
const start = normalizeMinute(timeStart.value, 360), end = normalizeMinute(timeEnd.value, 1080);
|
||||||
timeStart.value = String(start); timeEnd.value = String(end);
|
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%)`;
|
: `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";
|
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 updateOverlay = () => {
|
||||||
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
|
const def = targetDef();
|
||||||
if (!def.spatial) {
|
if (!def.spatial) {
|
||||||
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
|
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -912,13 +953,7 @@
|
||||||
}
|
}
|
||||||
global.render?.();
|
global.render?.();
|
||||||
};
|
};
|
||||||
const formatDetectorValue = (value, def) => `${Number(value) || 0}${def?.unit || ""}`;
|
|
||||||
const updateNumericDisplays = () => {
|
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`;
|
widthLabel.textContent = `${Number(width.value) || 0}px`;
|
||||||
heightLabel.textContent = `${Number(height.value) || 0}px`;
|
heightLabel.textContent = `${Number(height.value) || 0}px`;
|
||||||
width.setAttribute("aria-valuetext", widthLabel.textContent);
|
width.setAttribute("aria-valuetext", widthLabel.textContent);
|
||||||
|
|
@ -926,32 +961,59 @@
|
||||||
};
|
};
|
||||||
let previousTarget = target.value;
|
let previousTarget = target.value;
|
||||||
const updateMode = (targetChanged = false) => {
|
const updateMode = (targetChanged = false) => {
|
||||||
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
|
const def = targetDef();
|
||||||
count.min = String(def.min ?? 0);
|
for (const control of [valueMin, valueMax, valueMinNumber, valueMaxNumber]) {
|
||||||
count.max = String(def.max ?? 100);
|
control.min = String(def.min ?? 0); control.max = String(def.max ?? 100); control.step = String(def.step ?? 1);
|
||||||
count.step = String(def.step ?? 1);
|
}
|
||||||
if (targetChanged && previousTarget !== target.value) count.value = String(def.threshold);
|
if (targetChanged && previousTarget !== target.value) {
|
||||||
|
valueMin.value = String(def.threshold);
|
||||||
|
valueMax.value = String(def.max);
|
||||||
|
}
|
||||||
previousTarget = target.value;
|
previousTarget = target.value;
|
||||||
comparatorRow.hidden = Boolean(def.time);
|
valueRow.hidden = Boolean(def.time);
|
||||||
countRow.hidden = Boolean(def.time);
|
|
||||||
widthRow.hidden = !def.spatial;
|
widthRow.hidden = !def.spatial;
|
||||||
heightRow.hidden = !def.spatial;
|
heightRow.hidden = !def.spatial;
|
||||||
timeRow.hidden = !def.time;
|
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(); };
|
width.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
||||||
height.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
height.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
||||||
timeStart.oninput = updateTimeRange; timeEnd.oninput = updateTimeRange;
|
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);
|
target.onchange = () => updateMode(true);
|
||||||
updateMode(false);
|
updateMode(false);
|
||||||
const close = () => { dialog.classList.add("hidden"); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
|
const close = () => { dialog.classList.add("hidden"); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
|
||||||
dialog.querySelector("#signalItemEditorApply").onclick = () => {
|
dialog.querySelector("#signalItemEditorApply").onclick = () => {
|
||||||
global.TarinaiHistory.capture(worldRef, "detector-edit");
|
global.TarinaiHistory.capture(worldRef, "detector-edit");
|
||||||
item.pressureTarget = DETECTOR_TARGETS[target.value] ? target.value : "tarinai";
|
item.pressureTarget = DETECTOR_TARGETS[target.value] ? target.value : "tarinai";
|
||||||
item.pressureComparator = comparator.value === "lte" ? "lte" : "gte";
|
const def = targetDef();
|
||||||
const targetDef = DETECTOR_TARGETS[item.pressureTarget] || DETECTOR_TARGETS.tarinai;
|
item.pressureMin = normalizeValue(valueMin.value, def.threshold, def);
|
||||||
item.pressureThreshold = Math.max(Number(targetDef.min ?? 0), Math.min(Number(targetDef.max ?? 100), Number(count.value || 0)));
|
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.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.pressureHeight = Math.max(60, Math.min(840, Number(height.value || 160) | 0));
|
||||||
item.pressureTimeStart = normalizeMinute(timeStart.value, 360);
|
item.pressureTimeStart = normalizeMinute(timeStart.value, 360);
|
||||||
|
|
@ -966,7 +1028,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableItem(item) {
|
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) {
|
function findEditableItemAt(worldRef, x, y) {
|
||||||
|
|
@ -995,6 +1057,7 @@
|
||||||
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
|
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
|
||||||
if (item.type === "fan") return openFanEditor(item, worldRef);
|
if (item.type === "fan") return openFanEditor(item, worldRef);
|
||||||
if (item.type === "pressure_switch") return openSignalItemEditor(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") {
|
if (item.type === "gate_fence") {
|
||||||
item.gateOpen = !item.gateOpen;
|
item.gateOpen = !item.gateOpen;
|
||||||
worldRef?.markSpatialDirty?.("gate-toggle");
|
worldRef?.markSpatialDirty?.("gate-toggle");
|
||||||
|
|
@ -1474,6 +1537,9 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!found && !this.findDeleteToolTargetAt?.(x, y)) {
|
||||||
|
global.TarinaiAchievements?.recordEmptyClick?.({ world: this, x, y });
|
||||||
|
}
|
||||||
const wasSelectionEmpty = !this.selected;
|
const wasSelectionEmpty = !this.selected;
|
||||||
if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true;
|
if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true;
|
||||||
else this._scrollSelectedDataTopOnNextSelection = false;
|
else this._scrollSelectedDataTopOnNextSelection = false;
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,13 @@
|
||||||
if (!it || it.dead || it.type === "splat") continue;
|
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 ((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 && ["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 isMechanism = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(it.type));
|
||||||
const isFence = this.isFenceType?.(it.type) || false;
|
const isFence = this.isFenceType?.(it.type) || false;
|
||||||
const isConstraint = ["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type);
|
const isConstraint = ["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type);
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,9 @@
|
||||||
const previous = this.groundType || "soil";
|
const previous = this.groundType || "soil";
|
||||||
this.groundType = next;
|
this.groundType = next;
|
||||||
if (next !== previous || opts.force) {
|
if (next !== previous || opts.force) {
|
||||||
|
if (next !== previous && !opts.silent) {
|
||||||
|
window.TarinaiAchievements?.recordGroundChange?.({ world: this, previous, next, silent: false });
|
||||||
|
}
|
||||||
window.TarinaiWeatherSystem.enforceFixedWeather(this);
|
window.TarinaiWeatherSystem.enforceFixedWeather(this);
|
||||||
this.markTerrainDirty?.("ground-type");
|
this.markTerrainDirty?.("ground-type");
|
||||||
this.enforceGrassLimit?.("ground-type");
|
this.enforceGrassLimit?.("ground-type");
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ function createHarness(existingStorage = null, search = "", options = {}) {
|
||||||
setInterval() { timerId += 1; return timerId; },
|
setInterval() { timerId += 1; return timerId; },
|
||||||
clearInterval() {},
|
clearInterval() {},
|
||||||
fetch: options.fetch || (async () => { throw new Error("offline"); }),
|
fetch: options.fetch || (async () => { throw new Error("offline"); }),
|
||||||
TARINAI_VERSION: "39.16.60",
|
TARINAI_VERSION: "39.16.65",
|
||||||
TarinaiGameDialogs: { confirm: async () => true },
|
TarinaiGameDialogs: { confirm: async () => true },
|
||||||
};
|
};
|
||||||
context.window = context;
|
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",
|
"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",
|
"chaos_seeker_666_fights", "clean_freak_robot_only", "true_tarinai_observer",
|
||||||
"sniper_333_shots", "megalopolis", "fertility_seeker_721_love_births",
|
"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() {
|
(async function run() {
|
||||||
let h = createHarness();
|
let h = createHarness();
|
||||||
let storage;
|
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");
|
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);
|
const categorizedIds = h.api.categories.flatMap(category => category.ids);
|
||||||
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
|
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.sniper_333_shots === "狙撃手", "sniper title mismatch");
|
||||||
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
|
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
|
||||||
assert(titles.fertility_seeker_721_love_births === "豊穣ヲ希求スル者", "fertility 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]));
|
const descriptions = Object.fromEntries(h.api.definitions.map(def => [def.id, def.description]));
|
||||||
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
|
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
|
||||||
assert(descriptions.natural_tarinai_king === "たりない王が出現する。", "king 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.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.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.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("TarinaiTooltips"), "achievement tooltips were not removed");
|
||||||
assert(source.includes("PRE_UNLOCK_DESCRIPTION_IDS"), "pre-unlock disclosure allowlist is missing");
|
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; }
|
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 disclosedRevolutionCard = cards.find(node => node.dataset.achievementId === "revolution");
|
||||||
const disclosedLaxativeCard = cards.find(node => node.dataset.achievementId === "laxative_starvation");
|
const disclosedLaxativeCard = cards.find(node => node.dataset.achievementId === "laxative_starvation");
|
||||||
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
|
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");
|
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");
|
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(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");
|
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");
|
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
|
||||||
const categoryTitles = h.api.categories.map(category => category.title);
|
const categoryTitles = h.api.categories.map(category => category.title);
|
||||||
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
|
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
|
||||||
|
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
|
||||||
|
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");
|
h.api.unlock("natural_zunchi_slave");
|
||||||
const rerenderedGroups = h.elements.get("achievementList").children;
|
const rerenderedGroups = h.elements.get("achievementList").children;
|
||||||
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
||||||
|
|
@ -403,6 +429,14 @@ const expectedIds = [
|
||||||
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
||||||
assert(unlocked(h, "safe_colony_25_5_minutes"), "safe colony did not unlock");
|
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();
|
h = createHarness();
|
||||||
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||||
h.api.evaluateWorld(antWorld, { id: "relaxed" });
|
h.api.evaluateWorld(antWorld, { id: "relaxed" });
|
||||||
|
|
@ -418,6 +452,26 @@ const expectedIds = [
|
||||||
h.api.recordAntKilled({ world: {} });
|
h.api.recordAntKilled({ world: {} });
|
||||||
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
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 = createHarness();
|
||||||
h.api.recordSaveSlotsFilled({ filled: 8 });
|
h.api.recordSaveSlotsFilled({ filled: 8 });
|
||||||
assert(!unlocked(h, "secret_collection_9_slots"), "save-slot collection unlocked before 9");
|
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");
|
assert(unlocked(h, "secret_collection_9_slots"), "save-slot collection did not unlock at 9");
|
||||||
|
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
[0, 250, 500].forEach(now => h.api.recordPauseClick({ now }));
|
[0, 250, 500].forEach(now => h.api.recordEmptyClick({ now }));
|
||||||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked before four clicks");
|
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked before four clicks");
|
||||||
h.api.recordPauseClick({ now: 999 });
|
h.api.recordEmptyClick({ now: 999 });
|
||||||
assert(unlocked(h, "pause_spam_4_in_1_second"), "pause-spam did not unlock within one second");
|
assert(unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement did not unlock within one second");
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
[0, 400, 800, 1201].forEach(now => h.api.recordPauseClick({ now }));
|
[0, 400, 800, 1201].forEach(now => h.api.recordEmptyClick({ now }));
|
||||||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked outside one-second window");
|
assert(!unlocked(h, "pause_spam_4_in_1_second"), "empty-click achievement unlocked outside one-second window");
|
||||||
|
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
h.api.recordContinuousPlay({ elapsedMs: 3599999 });
|
h.api.recordContinuousPlay({ elapsedMs: 3599999 });
|
||||||
assert(!unlocked(h, "continuous_play_1_hour"), "continuous play unlocked before one hour");
|
assert(!unlocked(h, "continuous_play_1_hour"), "continuous play unlocked before one hour");
|
||||||
h.api.recordContinuousPlay({ elapsedMs: 3600000 });
|
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_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();
|
h = createHarness();
|
||||||
const thermalWorld = {
|
const thermalWorld = {
|
||||||
|
|
@ -583,7 +668,7 @@ const expectedIds = [
|
||||||
h.api.evaluateCleanFreak(cleanWorld);
|
h.api.evaluateCleanFreak(cleanWorld);
|
||||||
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
|
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 => {
|
})().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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_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])
|
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(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('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('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('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('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 ['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')
|
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 = {
|
checks = {
|
||||||
|
|
@ -67,12 +67,23 @@ checks = {
|
||||||
'ant population spawn hook': read('js/world_ants_system.js').count('recordAntPopulation?.(this)') >= 2,
|
'ant population spawn hook': read('js/world_ants_system.js').count('recordAntPopulation?.(this)') >= 2,
|
||||||
'ant herd threshold': 'if (aliveAnts >= 25)' in ach,
|
'ant herd threshold': 'if (aliveAnts >= 25)' in ach,
|
||||||
'safe colony timer': 'now - safeStart >= 300' 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,
|
'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,
|
'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,
|
'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'),
|
'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'),
|
'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 'recordContinuousPlay' in ach and 'visibilitychange' 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,
|
'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,
|
'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,
|
'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,
|
'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,
|
'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,
|
'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,
|
'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,
|
'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,
|
'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():
|
for name, ok in checks.items():
|
||||||
require(ok, name)
|
require(ok, name)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
341
scripts/achievement_server_state_audit.py
Normal file
341
scripts/achievement_server_state_audit.py
Normal 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()
|
||||||
|
|
@ -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`);
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
@ -25,7 +25,7 @@ function harness(storage = new Map()) {
|
||||||
location:{protocol:"file:",search:""},crypto:{randomUUID:()=>"11111111-1111-4111-8111-111111111111"},
|
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)},
|
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(){}},
|
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"});
|
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
|
||||||
return {c,api:c.TarinaiAchievements,storage,elements};
|
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(){
|
(function run(){
|
||||||
let h=harness();
|
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();
|
h.api.open();
|
||||||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||||||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine 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 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");
|
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.
|
// 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),achievementLastTarinaiDamageAt:0};
|
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});}
|
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");
|
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");
|
||||||
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");
|
h.api.recordDamageSource(ew.tarinai[0],{type:"firecracker"},{world:ew});
|
||||||
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");
|
assert((ew.achievementEnemyAntKills||0)===0,"dangerous tarinai damage did not reset enemy streak");
|
||||||
const noCollateral={time:50,tarinai:alive(30),achievementLastTarinaiDamageAt:-Infinity};
|
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});}
|
||||||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"food"},{world:noCollateral});
|
assert(on(h,"enemy_enemy_friend"),"enemy achievement did not unlock after 10 clean danger kills");
|
||||||
assert(noCollateral.achievementLastTarinaiDamageAt===-Infinity,"ordinary damage incorrectly blocked enemy achievement");
|
h=harness();const ew2={time:20,tarinai:alive(30)};
|
||||||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"firecracker"},{world:noCollateral});
|
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});}
|
||||||
assert(noCollateral.achievementLastTarinaiDamageAt===50,"danger collateral was not recorded");
|
ew2.tarinai[0].dead=true;const resetAnt={world:ew2};h.api.recordAntDamageSource(resetAnt,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:resetAnt});
|
||||||
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((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.
|
// Static integration guards for all production hook points.
|
||||||
const checks={
|
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_local_environment_system.js":["enclosedShelterFor","nest_box","pipe"],
|
||||||
"js/tarinai_update_step_frame.js":["isSheltered?.(t.world, t.x, t.y, t)"],
|
"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/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}`);}
|
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");
|
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
||||||
|
|
|
||||||
|
|
@ -2106,7 +2106,7 @@ def check_connection_overlay_and_time_detector() -> None:
|
||||||
fail("detector dual-thumb slider styling is missing")
|
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:
|
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")
|
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")
|
fail("expanded detector copy persistence is missing")
|
||||||
if 'item.pressureTarget === "time" || item.pressureTarget === "temperature"' not in render:
|
if 'item.pressureTarget === "time" || item.pressureTarget === "temperature"' not in render:
|
||||||
fail("non-spatial detectors still render a detection rectangle")
|
fail("non-spatial detectors still render a detection rectangle")
|
||||||
|
|
@ -2587,8 +2587,8 @@ def check_parasol_rain_and_preset_updates() -> None:
|
||||||
|
|
||||||
required = [
|
required = [
|
||||||
(placement, 'overlay: overlayForItem(tmp)', "parasol placement preview overlay is not exported"),
|
(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"),
|
(placement, 'shape: "roundedRect", 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"),
|
(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"),
|
(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, '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"),
|
(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 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 preview = overlayContext.TarinaiPlacementPreviewSystem.forWorld(previewWorld);
|
||||||
const shade = overlayContext.TarinaiParasolSystem.parasolFootprint(preview.item);
|
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 }};
|
const ambientContext = {{ console, globalThis: null, window: null, Math, Object }};
|
||||||
ambientContext.globalThis = ambientContext; ambientContext.window = ambientContext;
|
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(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(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, '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"]:
|
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}")
|
contains_local(tools, f'toolId === "{tool_id}"', f"distinct physics icon branch missing: {tool_id}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="検索"'), "search placeholder was not shortened");
|
|
||||||
console.log("[OK] achievement grouping, inline disclosures, and in-game confirmation dialogs passed");
|
|
||||||
|
|
@ -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");
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const APP_VERSION = "39.16.60";
|
const APP_VERSION = "39.16.74";
|
||||||
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||||
const v = `v=${APP_VERSION}`;
|
const v = `v=${APP_VERSION}`;
|
||||||
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
||||||
const coreScriptNames = [
|
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 = [
|
const CORE_ASSETS = [
|
||||||
"./",
|
"./",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue