This commit is contained in:
33333-33333 2026-07-15 16:27:56 +09:00
commit fc7c94bd69
26 changed files with 1167 additions and 271 deletions

View file

@ -96,10 +96,17 @@ This section records current boundaries only. Version-by-version cleanup history
- Hair Trigger preset: `js/world_reset_presets.js`; physics tool icon renderer: `js/ui_tools.js`; world tooltip offset: `js/render.js`.
## Achievement system (v39.16.56)
- `js/achievements.js`: 51 definitions, local unlock state, persistent placement/ant-kill/reproduction/fight/Undo/Redo/quick-delete progress, field-scoped direct-feed/cleaner/self-sufficiency/enemy-ant progress, individual care/hold/temperature/fight markers, allowlisted inline disclosures and local progress, shared-rate bars/counts, event-driven generation/status-effect/first-aid/plushie checks, completionist chaining, cached hidden-dialog rendering, debounced pending synchronization, and periodic additive full-state recovery synchronization.
## Achievement system (v39.16.60)
- `js/achievements.js`: 54 definitions, local unlock state, persistent placement/ant-kill/reproduction/fight-mochi/shot/love-mochi/Undo/Redo/quick-delete progress, field-scoped direct-feed/cleaner/self-sufficiency/enemy-ant progress, individual care/hold/temperature/fight markers, allowlisted inline disclosures and local progress, shared-rate bars/counts, event-driven generation/status-effect/first-aid/plushie checks, completionist chaining, cached hidden-dialog rendering, debounced pending synchronization, and periodic additive full-state recovery synchronization.
- `achievement_api.php`: validates current IDs, reads legacy verbose or compact v2 aggregate state, writes compact indexed arrays, migrates the intersection of the retired automation/link achievements into `mechanized_industry`, accepts idempotent bulk unlock synchronization, and returns anonymous global percentages plus achiever counts.
- `css/components.css`: achievement rows use `--achievement-rate` to render horizontal background fills; unlocked rows use a distinct fill.
- `js/world.js`, `js/snapshot_system.js`, and `js/save_codec.js`: field-local achievement counters and per-entity/item markers reset with a new field and persist within current-format saves; the current save schema intentionally has no backward compatibility.
- `js/world.js`, `js/snapshot_system.js`, and `js/save_codec.js`: field-local achievement counters and per-entity/item markers reset with a new field and persist within current-format saves; schema 43 is current; compact schema 42 and legacy JSON schema 41 remain readable for migration.
- Hooks span placement/deletion/history, robot cleaning, direct care and consumables, fight starts/deaths, ant damage attribution, save slots, pause input, hold state, population/temperature/weather evaluation, first-aid recovery, shared poke targeting/highlights, plushie fling, and nest-box/pipe weather blocking.
- Audits: `scripts/achievement_audit.js` checks legacy/current boundaries and recovery; `scripts/playstyle_achievement_audit.js` checks the play-style conditions, reset scopes, false-positive guards, tooltips, and production hooks; `scripts/achievement_integration_audit.py` checks IDs and integration wiring.
### Achievement spell payload (v39.16.60)
- `TarinaiAchievements.exportSpellState()` emits a compact array: unlock bitset, delta-coded timestamps, and packed persistent counters.
- `snapshot_system.js` includes it as `snapshot.g.s` only for exported spells and merges it through `importSpellState()` on load.
- `save_system.js` requests achievement inclusion for spell export; internal save slots do not include global achievement state.
- `save_codec.js` schema 42 writes achievement metadata directly as sparse binary fields instead of a JSON string and can still decode schema 41 spell saves.
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.

View file

@ -5,8 +5,8 @@ tarinai_compact_save_stage4
- The binary header contains one schema byte; its high bit signals whether a string table follows. Empty string tables and the duplicate snapshot version are omitted.
- Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte.
- 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map.
- Previous binary schemas are intentionally unsupported.
- Version: 39.16.56
- Binary schema 41 is accepted for legacy v39.16.57 spells; new exports use schema 42.
- Version: 39.16.60
- 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.
@ -85,3 +85,8 @@ tarinai_compact_save_stage4
39.16.55: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration.
39.16.56: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards.
39.16.57: Compactly embeds unlocked achievements and persistent achievement progress in exported spell saves; spell loads merge achievements without erasing local progress and resync restored unlocks to the server.
39.16.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.

View file

@ -1,6 +1,6 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
"version": "39.16.56",
"version": "39.16.60",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",

View file

@ -73,9 +73,9 @@ Manual save and restore use browser storage. Reset returns to field selection an
Display settings control effects, shadows, drawing detail, and quality choices for crowded scenes. Crowded fields are an intended part of the sandbox, so the game uses spatial lookup, scheduling, cached drawing, and incremental maintenance systems to keep large colonies and many objects playable.
## Achievement system (v39.16.56)
- Fifty-one 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.
- Locked rows reveal only the configured condition allowlist; all others show ``. There are no achievement tooltips. 大いなる母, めっちゃたりない観察, and 敵の敵は味方 include local progress percentages, and unlocked achievements are sorted first inside each category.
## Achievement system (v39.16.60)
- Fifty-four achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, and ant-related play styles.
- Locked rows reveal only the configured condition allowlist; all others show ``. There are no achievement tooltips. 大いなる母, めっちゃたりない観察, 敵の敵は味方, 混沌ヲ希求スル者, and 豊穣ヲ希求スル者 include local progress percentages, and unlocked achievements are sorted first inside each category.
- Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0100%, and the global achiever count is displayed beside it.
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
- `餌やりじいさん` requires 33 direct feedings in one field; resetting the field resets this progress.
@ -84,3 +84,11 @@ Display settings control effects, shadows, drawing detail, and quality choices f
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day.
- Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v2 achievement aggregate JSON while continuing to read the earlier verbose state format.
### Achievement spell persistence (v39.16.60)
- Exported spells compactly include non-debug achievement unlocks, unlock times, and persistent progress.
- Loading a spell merges achievements and keeps the greater progress instead of erasing local achievement data.
- Restored unlocks are queued for the existing server recovery synchronization. Save slots remain world-only.
- Spell achievement metadata uses a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Legacy JSON-metadata spells remain readable.
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.

View file

@ -22,7 +22,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button.
- v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field.
- v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.56`.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.60`.
- v39.16.31 changes: all in-world tooltips use the closer offset; Hair Trigger adds a central bounce-fence ball enclosure with Tarinai and pet-food duplicators outside; physics tools use distinct icons.
- v39.16.29 changes: parasol placement preview now uses the exact shade center and footprint; toilet sand absorbs rain-created water drops. Happy adds parasols, water balloons, and toilet sand beneath each duplicator; Athletic uses fence-safe single-bar rotators and water balloons; War adds rotating fans.
- v39.16.19 changes: food and medicine can be given directly by placement or pinched drop, with a dedicated overlay, food heart bursts, and stronger personality effects; unused collision reasons and duplicate constraint projection dirty checks were removed.
@ -72,11 +72,11 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re
- v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons.
## Achievement system (v39.16.56)
- Fifty-one local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, and ant-related play styles.
## Achievement system (v39.16.60)
- Fifty-four local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, and ant-related play styles.
- Locked entries show the configured pre-unlock disclosure directly in the row; all other locked entries show ``. 大いなる母, めっちゃたりない観察, and 敵の敵は味方 show local progress percentages. Unlocked entries appear first within each category.
- Each entry is a horizontal completion-rate bar whose background fill reflects the optional shared global unlock percentage; the shared achiever count appears beside the percentage.
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 adds two more achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars.
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 added two achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars. v39.16.60 adds three achievements and restricts the chaos counter to fight-mochi-influenced fights.
- Persistent totals include 物でいっぱい, 害虫駆除, 大いなる母3333体, 無計画都市, やっぱなし, and やっぱあり. Field-local progress includes 餌やりじいさん, 清掃業者, 自給自足, and 敵の敵は味方; applicable field-local metadata follows current-format saves.
- `自動化の第一歩` and `リンク職人` are retired and replaced by `機械化産業`, which requires both detector-driven activation and rope/rod/spring coverage. Existing players with both retired achievements migrate automatically.
- Optional same-origin PHP API aggregates anonymous player counts and global unlock percentages. Raw aggregate data is stored under `.achievement_data/state.json` in compact v2 arrays, with transparent reading/migration of the prior verbose format, and is protected by Apache rules.
@ -88,3 +88,5 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re
- On a static-only host, local unlocks still work; the global percentage is shown as unavailable.
- Set `window.TARINAI_ACHIEVEMENT_API` before `achievements.js` to use a different same-origin endpoint.
- The current release tracks fifty-one achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return.
- v39.16.60 changes: achievement metadata in spells is now encoded as sparse binary data instead of embedded JSON; unlock masks, progress, timestamps, per-entity counters, and placement metadata are compactly packed while legacy v39.16.57 spell data remains readable.
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.

View file

@ -57,6 +57,9 @@ const ACHIEVEMENT_IDS = [
'chaos_seeker_666_fights',
'clean_freak_robot_only',
'true_tarinai_observer',
'sniper_333_shots',
'megalopolis',
'fertility_seeker_721_love_births',
];
const DATA_DIR_NAME = '.achievement_data';
const DATA_FILE_NAME = 'state.json';
@ -199,6 +202,7 @@ if (!valid_player_id($playerId)) {
respond(['ok' => false, 'error' => 'invalid_player'], 400);
}
$syncUnlocks = [];
$completionistEligible = null;
if ($action === 'unlock') {
$achievementId = (string)($payload['achievementId'] ?? '');
if (!in_array($achievementId, ACHIEVEMENT_IDS, true)) {
@ -209,6 +213,12 @@ if ($action === 'unlock') {
if (!is_array($submittedUnlocks) || count($submittedUnlocks) > count(ACHIEVEMENT_IDS)) {
respond(['ok' => false, 'error' => 'invalid_unlocks'], 400);
}
if (array_key_exists('completionistEligible', $payload)) {
if (!is_bool($payload['completionistEligible'])) {
respond(['ok' => false, 'error' => 'invalid_completionist_state'], 400);
}
$completionistEligible = $payload['completionistEligible'];
}
foreach ($submittedUnlocks as $achievementId => $timestamp) {
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true)) {
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
@ -256,6 +266,9 @@ try {
$state['unlocks'][$achievementId][$playerId] = $now;
}
} elseif ($action === 'sync') {
if ($completionistEligible === false && isset($state['unlocks']['true_tarinai_observer'][$playerId])) {
unset($state['unlocks']['true_tarinai_observer'][$playerId]);
}
foreach ($syncUnlocks as $achievementId => $submittedTimestamp) {
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
$state['unlocks'][$achievementId] = [];

View file

@ -1,5 +1,5 @@
{
"version": "39.16.56",
"version": "39.16.60",
"css": [
"css/base.css",
"css/layout.css",

View file

@ -1229,3 +1229,14 @@
/* v39.16.56: keep the upper-limit heading on one line in the compact panel. */
.colony-limit-section .colony-setting-section-head > span { font-size: 10.8px; white-space: nowrap; }
.colony-limit-section .colony-setting-section-head > small { font-size: 9px; white-space: nowrap; }
/* v39.16.60: align both upper-limit sliders independently of label length. */
.colony-limit-grid { display: grid; gap: 7px; }
.colony-limit-row { display: grid; grid-template-columns: 82px minmax(0, 1fr) 58px; align-items: center; gap: 8px; min-width: 0; }
.colony-limit-row > span { min-width: 0; font-size: 11px; font-weight: 700; white-space: nowrap; }
.colony-limit-slider { width: 100%; min-width: 0; margin: 0; }
.colony-limit-number { width: 58px; min-width: 0; box-sizing: border-box; }
@media (max-width: 520px) {
.colony-limit-row { grid-template-columns: 72px minmax(0, 1fr) 54px; gap: 6px; }
.colony-limit-number { width: 54px; }
}

View file

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

View file

@ -4,7 +4,7 @@
const STORAGE_KEY = "tarinai_achievements_v2";
const PLAYER_KEY = "tarinai_achievement_player_v1";
const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php");
const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.56");
const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.60");
const DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug"));
const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ABSOLUTE_ZERO_C = -273.15;
@ -13,10 +13,15 @@
const COMPLETIONIST_ID = "true_tarinai_observer";
const GENERATION_TARGET = 10;
const CHAOS_FIGHT_TARGET = 666;
const SNIPER_SHOT_TARGET = 333;
const FERTILITY_BIRTH_TARGET = 721;
const ROBOT_CLEAN_TARGET = 200;
const ROBOT_ALL_TARGET_MASK = 0x3f;
const SYNC_DEBOUNCE_MS = 450;
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
const SPELL_STATE_VERSION = 3;
const PREVIOUS_SPELL_STATE_VERSION = 2;
const LEGACY_SPELL_STATE_VERSION = 1;
const MEDICINE_LEDGER_TYPES = Object.freeze(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
const DIRECT_TREATMENT_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
const PRE_UNLOCK_DESCRIPTION_IDS = new Set([
@ -42,6 +47,7 @@
"eternal_history_generation_10",
"town_doctor_50",
"chaos_seeker_666_fights",
"fertility_seeker_721_love_births",
]);
const ACHIEVEMENT_CATEGORIES = Object.freeze([
Object.freeze({
@ -54,6 +60,7 @@
"all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
"safe_colony_25_5_minutes", "ants_alive_25", "overprotective",
"self_sufficient", "rain_shelter_all", "eternal_history_generation_10", "town_doctor_50",
"fertility_seeker_721_love_births",
]),
}),
Object.freeze({
@ -73,7 +80,7 @@
title: "\u5efa\u7bc9",
ids: Object.freeze([
"placed_objects_100", "mechanized_industry", "robot_cleaner_100",
"minimalist_happy", "unplanned_city_30", "clean_freak_robot_only",
"minimalist_happy", "unplanned_city_30", "clean_freak_robot_only", "megalopolis",
]),
}),
Object.freeze({
@ -82,7 +89,7 @@
ids: Object.freeze([
"idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second",
"continuous_play_1_hour", "undo_mass_revival", "held_30_seconds",
"undo_20", "redo_20", "poke_plushie_fling", "true_tarinai_observer",
"undo_20", "redo_20", "poke_plushie_fling", "true_tarinai_observer", "sniper_333_shots",
]),
}),
]);
@ -135,9 +142,12 @@
Object.freeze({ id: "slave_zero_satisfaction", title: "\u96f6\u4f53\u6e80\u8db3", description: "\u305a\u3093\u3061\u3069\u308c\u30441\u500b\u4f53\u306b\u30f3\u30a4\u30c6\u30ed\u30d7\u3068\u77ee\u5c0f\u85ac\u306e\u52b9\u679c\u3092\u540c\u6642\u306b\u4ed8\u4e0e\u3059\u308b\u3002" }),
Object.freeze({ id: "town_doctor_50", title: "\u753a\u306e\u304a\u533b\u8005\u3055\u3093", description: "\u6551\u6025\u85ac\u3092\u7d2f\u8a0850\u56de\u4f7f\u7528\u3057\u3001\u4f53\u529b\u3092\u56de\u5fa9\u3055\u305b\u308b\u3002" }),
Object.freeze({ id: "poke_plushie_fling", title: "\u5272\u3068\u3075\u308f\u3075\u308f", description: "\u306c\u3044\u3050\u308b\u307f\u3092\u300c\u3064\u3064\u304f\u300d\u3067\u5f3e\u304d\u98db\u3070\u3059\u3002" }),
Object.freeze({ id: "chaos_seeker_666_fights", title: "\u6df7\u6c8c\u30f2\u5e0c\u6c42\u30b9\u30eb\u8005", description: "\u55a7\u5629\u3092\u7d2f\u8a08666\u56de\u767a\u751f\u3055\u305b\u308b\u3002" }),
Object.freeze({ id: "chaos_seeker_666_fights", title: "\u6df7\u6c8c\u30f2\u5e0c\u6c42\u30b9\u30eb\u8005", description: "\u3051\u3093\u304b\u9905\u306e\u5f71\u97ff\u4e0b\u3067\u55a7\u5629\u3092\u7d2f\u8a08666\u56de\u767a\u751f\u3055\u305b\u308b\u3002" }),
Object.freeze({ id: "clean_freak_robot_only", title: "\u6f54\u7656\u75c7", description: "\u30ed\u30dc\u30c3\u30c8\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u5168\u9805\u76ee\u306b\u30c1\u30a7\u30c3\u30af\u3092\u5165\u308c\u3066\u7a3c\u50cd\u3055\u305b\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u4e0a\u306b\u30ed\u30dc\u30c3\u30c8\u6383\u9664\u6a5f\u4ee5\u5916\u3092\u5b58\u5728\u3055\u305b\u306a\u3044\u3002" }),
Object.freeze({ id: "true_tarinai_observer", title: "\u771f\u30fb\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "\u3053\u308c\u4ee5\u5916\u306e\u5168\u3066\u306e\u5b9f\u7e3e\u3092\u9054\u6210\u3059\u308b\u3002" }),
Object.freeze({ id: "sniper_333_shots", title: "\u72d9\u6483\u624b", description: "\u7d2f\u8a08333\u767a\u6483\u3064\u3002" }),
Object.freeze({ id: "megalopolis", title: "\u30e1\u30ac\u30ed\u30dd\u30ea\u30b9", description: "\u8907\u88fd\u6a5f\u309210\u500b\u4ee5\u4e0a\u3001\u5e72\u8349\u5bdd\u5e8a\u30fb\u5de3\u7bb1\u30fb\u571f\u7ba1\u3092\u5408\u8a0815\u500b\u4ee5\u4e0a\u8a2d\u7f6e\u3057\u3001\u305f\u308a\u306a\u3044\u3092100\u4f53\u4ee5\u4e0a\u751f\u5b58\u3055\u305b\u308b\u3002" }),
Object.freeze({ id: "fertility_seeker_721_love_births", title: "\u8c4a\u7a63\u30f2\u5e0c\u6c42\u30b9\u30eb\u8005", description: "\u3078\u3053\u9905\u306e\u5f71\u97ff\u4e0b\u3067\u7e41\u6b96\u3092721\u56de\u6210\u529f\u3055\u305b\u308b\u3002" }),
]);
const definitionById = new Map(DEFINITIONS.map(def => [def.id, def]));
const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [
@ -177,7 +187,7 @@
let scheduledSyncTimer = 0;
function blankState() {
return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightCount: 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 } };
}
function loadState() {
@ -222,8 +232,16 @@
redoCount: Math.max(0, Math.floor(Number(progressSource.redoCount || 0) || 0)),
medicineTypes: [...new Set((Array.isArray(progressSource.medicineTypes) ? progressSource.medicineTypes : []).filter(type => MEDICINE_LEDGER_TYPES.includes(type)))],
firstAidHeals: Math.max(0, Math.floor(Number(progressSource.firstAidHeals || 0) || 0)),
fightCount: Math.max(0, Math.floor(Number(progressSource.fightCount || 0) || 0)),
fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)),
shotCount: Math.max(0, Math.floor(Number(progressSource.shotCount || 0) || 0)),
loveMochiBirthCount: Math.max(0, Math.floor(Number(progressSource.loveMochiBirthCount || 0) || 0)),
};
const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]);
if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) {
delete unlocked[COMPLETIONIST_ID];
const pendingIndex = pending.indexOf(COMPLETIONIST_ID);
if (pendingIndex >= 0) pending.splice(pendingIndex, 1);
}
return { unlocked, pending: [...new Set(pending)], resetPending: Boolean(parsed.resetPending), debugUnlocked: DEBUG_MODE ? debugUnlocked : [], progress };
} catch (_) {
return blankState();
@ -325,7 +343,12 @@
}
if (id === "chaos_seeker_666_fights") {
const target = CHAOS_FIGHT_TARGET;
const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.fightCount || 0) || 0)));
const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.fightMochiFightCount || 0) || 0)));
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / target * 100).toFixed(1)}%\uff08${count} / ${target}\u56de\uff09`;
}
if (id === "fertility_seeker_721_love_births") {
const target = FERTILITY_BIRTH_TARGET;
const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.loveMochiBirthCount || 0) || 0)));
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / target * 100).toFixed(1)}%\uff08${count} / ${target}\u56de\uff09`;
}
if (id === "continuous_play_1_hour") {
@ -354,7 +377,8 @@
Math.floor(continuousPlayElapsedMs() / 1000),
Math.min(10, Math.max(0, Number(global.world?.achievementEnemyAntKills || 0) || 0)),
Math.min(50, Math.max(0, Number(state.progress?.firstAidHeals || 0) || 0)),
Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Number(state.progress?.fightCount || 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)),
].join(":");
return `${unlocks}|${rates}|${local}`;
}
@ -395,7 +419,7 @@
const meta = document.createElement("div");
meta.className = "achievement-entry-meta";
const status = document.createElement("span");
status.textContent = unlockedAt ? `\u9054\u6210\u6e08\u307f ${formatDate(unlockedAt)}` : "\u672a\u9054\u6210";
status.textContent = unlockedAt ? `\u9054\u6210\u6e08\u307f ${formatDate(unlockedAt)}` : "";
const percentage = document.createElement("span");
percentage.className = "achievement-entry-percentage";
percentage.textContent = sharedAchievementText(definition.id);
@ -627,12 +651,13 @@
? snapshot
: Object.fromEntries(pendingIds.map(id => [id, snapshot[id]]));
const syncedIds = Object.keys(unlocked);
if (syncedIds.length) {
if (syncedIds.length || options.full) {
latest = await request({
action: "sync",
playerId: playerId(),
gameVersion: GAME_VERSION,
unlocked,
...(options.full ? { completionistEligible: allOtherAchievementsUnlocked() } : {}),
});
const syncedSet = new Set(syncedIds);
state.pending = state.pending.filter(id => !syncedSet.has(id));
@ -765,10 +790,195 @@
if (!Array.isArray(state.progress.medicineTypes)) state.progress.medicineTypes = [];
state.progress.medicineTypes = [...new Set(state.progress.medicineTypes.filter(type => MEDICINE_LEDGER_TYPES.includes(type)))];
state.progress.firstAidHeals = Math.max(0, Math.floor(Number(state.progress.firstAidHeals || 0) || 0));
state.progress.fightCount = Math.max(0, Math.floor(Number(state.progress.fightCount || 0) || 0));
state.progress.fightMochiFightCount = Math.max(0, Math.floor(Number(state.progress.fightMochiFightCount || 0) || 0));
state.progress.shotCount = Math.max(0, Math.floor(Number(state.progress.shotCount || 0) || 0));
state.progress.loveMochiBirthCount = Math.max(0, Math.floor(Number(state.progress.loveMochiBirthCount || 0) || 0));
return state.progress;
}
function hexByte(value = 0) {
return Math.max(0, Math.min(255, Number(value) || 0)).toString(16).padStart(2, "0");
}
function unlockedIndexesFromMaskWords(lowWord = 0, highWord = 0) {
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(lowWord) || 0)));
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
const high = Math.max(0, Math.min(highMax, Math.floor(Number(highWord) || 0)));
const indexes = [];
for (let index = 0; index < DEFINITIONS.length; index += 1) {
const word = index < 32 ? low : high;
const bit = index < 32 ? index : index - 32;
if (Math.floor(word / (2 ** bit)) % 2) indexes.push(index);
}
return indexes;
}
function exportSpellState() {
let lowWord = 0;
let highWord = 0;
const timestamps = [];
for (let index = 0; index < DEFINITIONS.length; index += 1) {
const id = DEFINITIONS[index].id;
const timestamp = Number(state.unlocked[id] || 0) || 0;
if (timestamp <= 0 || state.debugUnlocked?.includes?.(id)) continue;
if (index < 32) lowWord += 2 ** index;
else highWord += 2 ** (index - 32);
timestamps.push(Math.max(1, Math.floor(timestamp / 60000)));
}
const baseTimestamp = timestamps.length ? Math.min(...timestamps) : 0;
const deltas = timestamps.map(timestamp => Math.max(0, timestamp - baseTimestamp));
const progress = ensureProgress();
let linkFlags = progress.signalActivated ? 8 : 0;
if (progress.linkTypes.includes("rope")) linkFlags |= 1;
if (progress.linkTypes.includes("rod")) linkFlags |= 2;
if (progress.linkTypes.includes("spring")) linkFlags |= 4;
let medicineMask = 0;
MEDICINE_LEDGER_TYPES.forEach((type, index) => {
if (progress.medicineTypes.includes(type)) medicineMask |= (1 << index);
});
return [
SPELL_STATE_VERSION,
lowWord >>> 0,
highWord >>> 0,
baseTimestamp,
deltas,
[
state.unlocked.placed_objects_100 ? 0 : Math.min(100, progress.placementCount),
state.unlocked.mechanized_industry ? 0 : linkFlags,
state.unlocked.ants_killed_100 ? 0 : Math.min(100, progress.antKills),
state.unlocked.great_mother_1000_births ? 0 : Math.min(3333, progress.birthCount),
state.unlocked.unplanned_city_30 ? 0 : Math.min(30, progress.quickDeleteCount),
state.unlocked.undo_20 ? 0 : Math.min(20, progress.undoCount),
state.unlocked.redo_20 ? 0 : Math.min(20, progress.redoCount),
state.unlocked.medicine_ledger_all ? 0 : medicineMask,
state.unlocked.town_doctor_50 ? 0 : Math.min(50, progress.firstAidHeals),
state.unlocked.chaos_seeker_666_fights ? 0 : Math.min(CHAOS_FIGHT_TARGET, progress.fightMochiFightCount),
state.unlocked.sniper_333_shots ? 0 : Math.min(SNIPER_SHOT_TARGET, progress.shotCount),
state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount),
],
];
}
function parseSpellState(payload = null) {
if (!Array.isArray(payload)) return null;
const version = Number(payload[0]);
if (version === LEGACY_SPELL_STATE_VERSION) {
const maskHex = String(payload[1] || "").toLowerCase();
const expectedBytes = Math.ceil(DEFINITIONS.length / 8);
if (!/^[0-9a-f]*$/.test(maskHex) || maskHex.length !== expectedBytes * 2) {
throw new Error("invalid achievement spell mask");
}
const maskBytes = [];
for (let offset = 0; offset < maskHex.length; offset += 2) maskBytes.push(parseInt(maskHex.slice(offset, offset + 2), 16));
const unlockedIndexes = [];
for (let index = 0; index < DEFINITIONS.length; index += 1) {
if (maskBytes[Math.floor(index / 8)] & (1 << (index % 8))) unlockedIndexes.push(index);
}
return {
unlockedIndexes,
baseTimestamp: Math.max(0, Math.floor(Number(payload[2] || 0) || 0)),
deltas: Array.isArray(payload[3]) ? payload[3] : [],
packed: Array.isArray(payload[4]) ? payload[4] : [],
timestampUnitMs: 1000,
progressVersion: LEGACY_SPELL_STATE_VERSION,
};
}
if (version !== PREVIOUS_SPELL_STATE_VERSION && version !== SPELL_STATE_VERSION) return null;
const lowWord = Number(payload[1]);
const highWord = Number(payload[2]);
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
if (!Number.isSafeInteger(lowWord) || lowWord < 0 || lowWord > 0xffffffff ||
!Number.isSafeInteger(highWord) || highWord < 0 || highWord > highMax) {
throw new Error("invalid achievement spell mask");
}
return {
unlockedIndexes: unlockedIndexesFromMaskWords(lowWord, highWord),
baseTimestamp: Math.max(0, Math.floor(Number(payload[3] || 0) || 0)),
deltas: Array.isArray(payload[4]) ? payload[4] : [],
packed: Array.isArray(payload[5]) ? payload[5] : [],
timestampUnitMs: 60000,
progressVersion: version,
};
}
function importSpellState(payload = null) {
const parsed = parseSpellState(payload);
if (!parsed) return { included: false, unlockedAdded: 0, progressChanged: false };
const { unlockedIndexes, baseTimestamp, deltas, packed, timestampUnitMs, progressVersion } = parsed;
if (deltas.length !== unlockedIndexes.length) throw new Error("invalid achievement spell timestamps");
const importedUnlocks = [];
let importedCompletionistAt = 0;
unlockedIndexes.forEach((definitionIndex, orderIndex) => {
const definition = DEFINITIONS[definitionIndex];
if (!definition) return;
const delta = Math.max(0, Math.floor(Number(deltas[orderIndex] || 0) || 0));
const timestamp = Math.max(1, (baseTimestamp + delta) * timestampUnitMs);
if (definition.id === COMPLETIONIST_ID) {
importedCompletionistAt = timestamp;
return;
}
importedUnlocks.push([definition.id, timestamp]);
});
let unlockedAdded = 0;
let changed = false;
for (const [id, timestamp] of importedUnlocks) {
const current = Number(state.unlocked[id] || 0) || 0;
if (!current) {
state.unlocked[id] = timestamp;
queuePending(id);
unlockedAdded += 1;
changed = true;
} else if (timestamp < current) {
state.unlocked[id] = timestamp;
changed = true;
}
}
const progress = ensureProgress();
const previousProgress = JSON.stringify(progress);
progress.placementCount = Math.max(progress.placementCount, Math.min(100, Math.max(0, Math.floor(Number(packed[0] || 0) || 0))));
const linkFlags = Math.max(0, Math.floor(Number(packed[1] || 0) || 0));
const importedLinks = [];
if (linkFlags & 1) importedLinks.push("rope");
if (linkFlags & 2) importedLinks.push("rod");
if (linkFlags & 4) importedLinks.push("spring");
progress.linkTypes = [...new Set([...(progress.linkTypes || []), ...importedLinks])];
progress.signalActivated = Boolean(progress.signalActivated || (linkFlags & 8));
progress.antKills = Math.max(progress.antKills, Math.min(100, Math.max(0, Math.floor(Number(packed[2] || 0) || 0))));
progress.birthCount = Math.max(progress.birthCount, Math.min(3333, Math.max(0, Math.floor(Number(packed[3] || 0) || 0))));
progress.quickDeleteCount = Math.max(progress.quickDeleteCount, Math.min(30, Math.max(0, Math.floor(Number(packed[4] || 0) || 0))));
progress.undoCount = Math.max(progress.undoCount, Math.min(20, Math.max(0, Math.floor(Number(packed[5] || 0) || 0))));
progress.redoCount = Math.max(progress.redoCount, Math.min(20, Math.max(0, Math.floor(Number(packed[6] || 0) || 0))));
const medicineMask = Math.max(0, Math.floor(Number(packed[7] || 0) || 0));
const importedMedicines = MEDICINE_LEDGER_TYPES.filter((_type, index) => medicineMask & (1 << index));
progress.medicineTypes = [...new Set([...(progress.medicineTypes || []), ...importedMedicines])];
progress.firstAidHeals = Math.max(progress.firstAidHeals, Math.min(50, Math.max(0, Math.floor(Number(packed[8] || 0) || 0))));
if (progressVersion === SPELL_STATE_VERSION) {
progress.fightMochiFightCount = Math.max(progress.fightMochiFightCount, Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Math.floor(Number(packed[9] || 0) || 0))));
progress.shotCount = Math.max(progress.shotCount, Math.min(SNIPER_SHOT_TARGET, Math.max(0, Math.floor(Number(packed[10] || 0) || 0))));
progress.loveMochiBirthCount = Math.max(progress.loveMochiBirthCount, Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Math.floor(Number(packed[11] || 0) || 0))));
}
const progressChanged = previousProgress !== JSON.stringify(progress);
changed = changed || progressChanged;
if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) {
const latestImportedAt = importedUnlocks.reduce((latest, entry) => Math.max(latest, Number(entry[1]) || 0), 0);
state.unlocked[COMPLETIONIST_ID] = importedCompletionistAt || latestImportedAt || Date.now();
queuePending(COMPLETIONIST_ID);
unlockedAdded += 1;
changed = true;
}
if (changed) {
saveState();
lastListSignature = "";
render({ forceList: true });
if (!DEBUG_MODE && unlockedAdded > 0) schedulePendingSync();
}
return { included: true, unlockedAdded, progressChanged };
}
function recordPlayerPlacement(detail = {}) {
const world = detailWorld(detail);
if (!world) return false;
@ -779,6 +989,7 @@
item._achievementPlacedAt = Math.max(0, Number(world.time || 0) || 0);
}
let changed = evaluateCleanFreak(world, detail);
changed = evaluateMegalopolis(world, detail) || changed;
if (!state.unlocked.placed_objects_100) {
const progress = ensureProgress();
progress.placementCount = Math.min(100, progress.placementCount + 1);
@ -794,6 +1005,7 @@
const item = detail.item || null;
if (!world || !item) return false;
let changed = evaluateCleanFreak(world, detail);
changed = evaluateMegalopolis(world, detail) || changed;
if (!item._achievementPlayerPlaced) return changed;
const now = Math.max(0, Number(world.time || 0) || 0);
const placedAtRaw = item._achievementPlacedAt;
@ -1106,7 +1318,13 @@
const tarinai = detail.tarinai || null;
const world = detailWorld(detail);
let changed = false;
if (type === "fight_mochi" && tarinai && world) tarinai._achievementFightMochiAt = Math.max(0, Number(world.time || 0) || 0);
if (type === "fight_mochi" && tarinai && world) {
const serial = Math.max(1, Math.floor(Number(world.achievementFightMochiSerial || 0) || 0) + 1);
world.achievementFightMochiSerial = serial;
tarinai._achievementFightMochiAt = Math.max(0, Number(world.time || 0) || 0);
tarinai._achievementFightMochiSerial = serial;
tarinai._achievementFightMochiWorld = world;
}
if (MEDICINE_LEDGER_TYPES.includes(type) && !state.unlocked.medicine_ledger_all) {
const progress = ensureProgress();
if (!progress.medicineTypes.includes(type)) {
@ -1146,11 +1364,14 @@
const world = detailWorld(detail);
if (!world) return false;
let changed = false;
if (!state.unlocked.chaos_seeker_666_fights) {
const fightMochiInfluenced = [detail.a, detail.b].some(tarinai => Boolean(tarinai) && (
(Number(tarinai.fightMochiTimer || 0) > 0.04) || Boolean(tarinai.hasFightMochiEffect?.())
));
if (!state.unlocked.chaos_seeker_666_fights && fightMochiInfluenced) {
const progress = ensureProgress();
progress.fightCount = Math.min(CHAOS_FIGHT_TARGET, progress.fightCount + 1);
if (progress.fightCount >= CHAOS_FIGHT_TARGET) {
changed = unlock("chaos_seeker_666_fights", { ...detail, world, count: progress.fightCount }) || changed;
progress.fightMochiFightCount = Math.min(CHAOS_FIGHT_TARGET, progress.fightMochiFightCount + 1);
if (progress.fightMochiFightCount >= CHAOS_FIGHT_TARGET) {
changed = unlock("chaos_seeker_666_fights", { ...detail, world, count: progress.fightMochiFightCount, fightMochiInfluenced: true }) || changed;
} else {
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
@ -1161,14 +1382,53 @@
for (const tarinai of candidates) {
const givenAtRaw = tarinai._achievementFightMochiAt;
const givenAt = givenAtRaw == null ? NaN : Number(givenAtRaw);
if (Number.isFinite(givenAt) && now >= givenAt && now - givenAt <= 30.0001) {
changed = unlock("fuel_to_fire", { ...detail, world, tarinai, elapsed: now - givenAt }) || changed;
const serial = Math.floor(Number(tarinai._achievementFightMochiSerial || 0) || 0);
const validCurrentSessionMochi = serial > 0 && tarinai._achievementFightMochiWorld === world;
if (validCurrentSessionMochi && Number.isFinite(givenAt) && now >= givenAt && now - givenAt <= 30.0001) {
changed = unlock("fuel_to_fire", { ...detail, world, tarinai, elapsed: now - givenAt, serial }) || changed;
break;
}
}
return changed;
}
function recordShotFired(detail = {}) {
if (state.unlocked.sniper_333_shots) return false;
const progress = ensureProgress();
progress.shotCount = Math.min(SNIPER_SHOT_TARGET, progress.shotCount + 1);
if (progress.shotCount >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count: progress.shotCount });
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
return false;
}
function recordLoveMochiBirth(detail = {}) {
if (state.unlocked.fertility_seeker_721_love_births || !detail.loveMochiInfluenced) return false;
const progress = ensureProgress();
progress.loveMochiBirthCount = Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount + 1);
if (progress.loveMochiBirthCount >= FERTILITY_BIRTH_TARGET) {
return unlock("fertility_seeker_721_love_births", { ...detail, count: progress.loveMochiBirthCount });
}
saveState();
if (!dom.dialog?.classList.contains("hidden")) render();
return false;
}
function evaluateMegalopolis(world = global.world, detail = {}) {
if (!world || state.unlocked.megalopolis) return false;
const aliveCount = (world.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0);
if (aliveCount < 100) return false;
let duplicatorCount = 0;
let shelterCount = 0;
for (const item of world.items || []) {
if (!item || item.dead) continue;
if (item.type === "duplicator") duplicatorCount += 1;
else if (item.type === "grass_bed" || item.type === "nest_box" || item.type === "pipe") shelterCount += 1;
}
if (duplicatorCount < 10 || shelterCount < 15) return false;
return unlock("megalopolis", { ...detail, world, aliveCount, duplicatorCount, shelterCount });
}
function resetWorldProgress(world = global.world) {
if (!world) return false;
world.achievementDirectFeedCount = 0;
@ -1179,6 +1439,13 @@
world.achievementLastDirectFeedAt = -Infinity;
world.achievementNoDeathStartAt = -1;
world.achievementHappyStartAt = -1;
world.achievementFightMochiSerial = 0;
for (const tarinai of world.tarinai || []) {
if (!tarinai) continue;
tarinai._achievementFightMochiAt = null;
tarinai._achievementFightMochiSerial = 0;
tarinai._achievementFightMochiWorld = null;
}
world.achievementNaturalSlaveGenerations = [];
world.achievementNaturalKingGenerations = [];
return true;
@ -1214,6 +1481,7 @@
const world = detail.world || child?.world || null;
const generation = Math.max(1, Math.floor(Number(child?.generation || world?.maxGeneration || 1) || 1));
if (generation >= GENERATION_TARGET) unlock("eternal_history_generation_10", { ...detail, world, child, generation });
evaluateMegalopolis(world, detail);
const progress = ensureProgress();
if (!state.unlocked.great_mother_1000_births) {
progress.birthCount = Math.min(3333, progress.birthCount + 1);
@ -1234,6 +1502,7 @@
recordFightPairDangerDeath(detail);
const world = detail.world || detail.tarinai?.world || null;
if (world) {
evaluateMegalopolis(world, detail);
const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
}
@ -1400,6 +1669,7 @@
if (!state.unlocked.ants_alive_25) recordAntPopulation(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.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" });
return true;
}
@ -1523,6 +1793,9 @@
recordRobotClean,
evaluateCleanFreak,
recordFightStarted,
recordShotFired,
recordLoveMochiBirth,
evaluateMegalopolis,
recordHeldTarinai,
resetWorldProgress,
recordLinkPlaced,
@ -1536,6 +1809,8 @@
recordDamageSource,
recordAntDamageSource,
debugUnlockAll: unlockAllAchievementsForDebug,
exportSpellState,
importSpellState,
evaluateWorld,
recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); },
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },

View file

@ -1459,6 +1459,251 @@
return items;
}
const LEGACY_JSON_BINARY_SCHEMA_VERSION = 41;
const LEGACY_COMPACT_BINARY_SCHEMA_VERSION = 42;
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
const ACHIEVEMENT_SPELL_BINARY_VERSION = 3;
const PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION = 2;
const ACHIEVEMENT_MASK_BYTES = 7;
const LEGACY_ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10]);
const LEGACY_ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666]);
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10]);
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721]);
function finiteNumberOrNull(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function quantizedTenths(value = 0) { return Math.round((Number(value) || 0) * 10); }
function unquantizedTenths(value = 0) { return (Number(value) || 0) / 10; }
function varUintLength(value = 0) { return encodeVarUint(value).length; }
function writePositiveDeltaList(writer, values = []) {
const list = [...new Set((Array.isArray(values) ? values : []).map(value => Math.max(1, toSafeUInt(value))))].sort((a, b) => a - b);
writer.u(list.length);
let previous = 0;
for (const value of list) { writer.u(value - previous); previous = value; }
}
function readPositiveDeltaList(reader) {
const count = reader.u();
const out = [];
let previous = 0;
for (let i = 0; i < count; i++) { previous += reader.u(); out.push(previous); }
return out;
}
function achievementMaskBytesFromSpell(spell = null) {
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
const version = Number(spell[0]);
let baseMinute = 0;
let deltas = [];
let progress = [];
if (version === 1) {
const maskHex = String(spell[1] || "").toLowerCase();
if (!/^[0-9a-f]{14}$/.test(maskHex)) throw new Error("invalid achievement spell mask");
for (let i = 0; i < ACHIEVEMENT_MASK_BYTES; i++) bytes[i] = parseInt(maskHex.slice(i * 2, i * 2 + 2), 16);
const baseSecond = toSafeUInt(spell[2]);
const secondDeltas = Array.isArray(spell[3]) ? spell[3] : [];
const absoluteMinutes = secondDeltas.map(delta => Math.floor((baseSecond + toSafeUInt(delta)) / 60));
baseMinute = absoluteMinutes.length ? Math.min(...absoluteMinutes) : 0;
deltas = absoluteMinutes.map(value => value - baseMinute);
progress = Array.isArray(spell[4]) ? spell[4] : [];
} else if (version === PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION || version === ACHIEVEMENT_SPELL_BINARY_VERSION) {
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
const high = Math.max(0, Math.min(0xffffff, Math.floor(Number(spell[2]) || 0)));
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255;
for (let i = 0; i < 3; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
baseMinute = toSafeUInt(spell[3]);
deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : [];
const rawProgress = Array.isArray(spell[5]) ? spell[5] : [];
progress = version === ACHIEVEMENT_SPELL_BINARY_VERSION
? rawProgress
: [...rawProgress.slice(0, 9), 0, 0, 0];
} else {
throw new Error("unsupported achievement spell version");
}
let unlockedCount = 0;
for (const byte of bytes) {
let value = byte;
while (value) { unlockedCount += value & 1; value >>>= 1; }
}
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
return { bytes, baseMinute, deltas, progress };
}
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) {
let low = 0;
let high = 0;
for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
for (let i = 0; i < 3; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8));
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) {
const normalized = maxima.map((max, index) => Math.max(0, Math.min(max, toSafeUInt(values?.[index]))));
let sparseMask = 0;
let sparseBytes = Math.ceil(maxima.length / 8) + 1;
normalized.forEach((value, index) => {
if (!value) return;
sparseMask |= 1 << index;
sparseBytes += varUintLength(value);
});
const fixedBytes = Math.ceil(bits.reduce((sum, width) => sum + width, 0) / 8);
const useSparse = sparseBytes < fixedBytes;
writer.b(useSparse ? 1 : 0);
if (useSparse) {
writeBitFields(writer, [sparseMask], [maxima.length]);
normalized.forEach((value, index) => { if (sparseMask & (1 << index)) writer.u(value); });
} else {
writeBitFields(writer, normalized, bits);
}
}
function readAchievementProgress(reader, bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
const sparse = Boolean(reader.b());
if (!sparse) return readBitFields(reader, bits);
const [mask] = readBitFields(reader, [maxima.length]);
return maxima.map((max, index) => mask & (1 << index) ? Math.min(max, reader.u()) : 0);
}
function writeAchievementSpell(writer, spell = null) {
const normalized = achievementMaskBytesFromSpell(spell);
writer.raw(normalized.bytes);
writer.u(normalized.baseMinute);
for (const delta of normalized.deltas) writer.u(delta);
writeAchievementProgress(writer, normalized.progress);
}
function readAchievementSpell(reader, schema = BINARY_SCHEMA_VERSION) {
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
let unlockedCount = 0;
for (const byte of bytes) {
let value = byte;
while (value) { unlockedCount += value & 1; value >>>= 1; }
}
const baseMinute = reader.u();
const deltas = [];
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
const legacy = schema === LEGACY_COMPACT_BINARY_SCHEMA_VERSION;
const progress = legacy
? readAchievementProgress(reader, LEGACY_ACHIEVEMENT_PROGRESS_BITS, LEGACY_ACHIEVEMENT_PROGRESS_MAX)
: readAchievementProgress(reader);
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, legacy ? [...progress.slice(0, 9), 0, 0, 0] : progress);
}
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
const value = metadata && typeof metadata === "object" ? metadata : {};
const w = Array.isArray(value.w) ? value.w : [];
writer.b(ACHIEVEMENT_METADATA_BINARY_VERSION);
writeBitFields(writer, [Array.isArray(value.s) ? 1 : 0], [1]);
writePositiveDeltaList(writer, w[0]);
writePositiveDeltaList(writer, w[1]);
writeBitFields(writer, [
Math.min(33, toSafeUInt(w[2])),
Math.min(200, toSafeUInt(w[3])),
Math.min(10, toSafeUInt(w[4])),
], [6, 8, 4]);
const timeDefaults = [null, -1, null, 0, -1, -1];
const timeValues = [w[5], w[6], w[7], w[8], w[9], w[10]];
let timeMask = 0;
const encodedTimes = [];
timeValues.forEach((raw, index) => {
const finite = finiteNumberOrNull(raw);
const defaultValue = timeDefaults[index];
const sameAsDefault = finite == null ? defaultValue == null : finite === defaultValue;
if (sameAsDefault) return;
timeMask |= 1 << index;
encodedTimes.push(quantizedTenths(finite == null ? defaultValue || 0 : finite));
});
writeBitFields(writer, [timeMask], [timeDefaults.length]);
encodedTimes.forEach(value => writer.s(value));
const tarRows = Array.isArray(value.t) ? value.t : [];
const activeTarRows = [];
for (let index = 0; index < Math.min(tarinaiCount, tarRows.length); index++) {
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
const feed = toSafeUInt(row[0]);
const treatment = toSafeUInt(row[1]);
const fightAt = finiteNumberOrNull(row[2]);
const saunaAt = finiteNumberOrNull(row[3]);
if (feed || treatment || fightAt != null || saunaAt != null) activeTarRows.push({ index, feed, treatment, fightAt, saunaAt });
}
writer.u(activeTarRows.length);
let previousTarIndex = -1;
for (const row of activeTarRows) { writer.u(row.index - previousTarIndex - 1); previousTarIndex = row.index; }
for (const row of activeTarRows) { writer.u(row.feed); writer.u(row.treatment); }
const tarFlags = [];
for (const row of activeTarRows) tarFlags.push(row.fightAt != null ? 1 : 0, row.saunaAt != null ? 1 : 0);
if (tarFlags.length) writeBitFields(writer, tarFlags, tarFlags.map(() => 1));
for (const row of activeTarRows) {
if (row.fightAt != null) writer.s(quantizedTenths(row.fightAt));
if (row.saunaAt != null) writer.s(quantizedTenths(row.saunaAt));
}
const itemRows = Array.isArray(value.i) ? value.i : [];
const activeItemRows = [];
for (let index = 0; index < Math.min(itemCount, itemRows.length); index++) {
const row = Array.isArray(itemRows[index]) ? itemRows[index] : [];
const placed = Boolean(row[0]);
const placedAt = finiteNumberOrNull(row[1]);
if (placed || placedAt != null) activeItemRows.push({ index, placed, placedAt });
}
writer.u(activeItemRows.length);
let previousItemIndex = -1;
for (const row of activeItemRows) { writer.u(row.index - previousItemIndex - 1); previousItemIndex = row.index; }
const itemFlags = [];
for (const row of activeItemRows) itemFlags.push(row.placed ? 1 : 0, row.placedAt != null ? 1 : 0);
if (itemFlags.length) writeBitFields(writer, itemFlags, itemFlags.map(() => 1));
for (const row of activeItemRows) if (row.placedAt != null) writer.s(quantizedTenths(row.placedAt));
if (Array.isArray(value.s)) writeAchievementSpell(writer, value.s);
}
function readAchievementMetadata(reader, tarinaiCount = 0, itemCount = 0, schema = BINARY_SCHEMA_VERSION) {
const version = reader.b();
if (version !== ACHIEVEMENT_METADATA_BINARY_VERSION) throw new Error("unsupported achievement metadata schema");
const [flags] = readBitFields(reader, [1]);
const w = [readPositiveDeltaList(reader), readPositiveDeltaList(reader)];
const [directFeed, robotClean, enemyAntKills] = readBitFields(reader, [6, 8, 4]);
w.push(directFeed, robotClean, enemyAntKills);
const timeDefaults = [null, -1, null, 0, -1, -1];
const [timeMask] = readBitFields(reader, [timeDefaults.length]);
for (let index = 0; index < timeDefaults.length; index++) {
w.push(timeMask & (1 << index) ? unquantizedTenths(reader.s()) : timeDefaults[index]);
}
const t = Array.from({ length: tarinaiCount }, () => [0, 0, null, null]);
const tarCount = reader.u();
const tarIndexes = [];
let previousTarIndex = -1;
for (let i = 0; i < tarCount; i++) {
previousTarIndex += reader.u() + 1;
if (previousTarIndex >= tarinaiCount) throw new Error("invalid achievement tarinai metadata index");
tarIndexes.push(previousTarIndex);
}
const tarCounters = tarIndexes.map(() => [reader.u(), reader.u()]);
const tarFlagWidths = Array.from({ length: tarCount * 2 }, () => 1);
const tarFlags = tarFlagWidths.length ? readBitFields(reader, tarFlagWidths) : [];
tarIndexes.forEach((index, rowIndex) => {
const fightAt = tarFlags[rowIndex * 2] ? unquantizedTenths(reader.s()) : null;
const saunaAt = tarFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
t[index] = [tarCounters[rowIndex][0], tarCounters[rowIndex][1], fightAt, saunaAt];
});
const i = Array.from({ length: itemCount }, () => [0, null]);
const itemRowCount = reader.u();
const itemIndexes = [];
let previousItemIndex = -1;
for (let row = 0; row < itemRowCount; row++) {
previousItemIndex += reader.u() + 1;
if (previousItemIndex >= itemCount) throw new Error("invalid achievement item metadata index");
itemIndexes.push(previousItemIndex);
}
const itemFlagWidths = Array.from({ length: itemRowCount * 2 }, () => 1);
const itemFlags = itemFlagWidths.length ? readBitFields(reader, itemFlagWidths) : [];
itemIndexes.forEach((index, rowIndex) => {
const placed = itemFlags[rowIndex * 2] ? 1 : 0;
const placedAt = itemFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
i[index] = [placed, placedAt];
});
const metadata = { w, t, i };
if (flags & 1) metadata.s = readAchievementSpell(reader, schema);
return metadata;
}
function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; }
function summaryFromPayload(w = [], tarinaiCount = 0) {
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
@ -1490,7 +1735,7 @@
tarinaiRows.forEach((row, index) => writer.category("tarinaiBasic", () => writeTarinaiPayload(writer, row || [], tarinaiMetas[index], tarCoordState, worldSeed, index)));
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
writeItemBlocks(writer, itemRows);
writer.category("achievementMetadata", () => writer.str(JSON.stringify(snapshot.g || null)));
writer.category("achievementMetadata", () => writeAchievementMetadata(writer, snapshot.g || null, tarinaiRows.length, itemRows.length));
return writer.out();
} finally {
activeStringWriteMap = null;
@ -1504,7 +1749,7 @@
const formatHeader = reader.b();
const schema = formatHeader & 0x7f;
const hasStringTable = Boolean(formatHeader & 0x80);
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
if (![BINARY_SCHEMA_VERSION, LEGACY_COMPACT_BINARY_SCHEMA_VERSION, LEGACY_JSON_BINARY_SCHEMA_VERSION].includes(schema)) throw new Error("unsupported binary save schema");
readStringTable(reader, hasStringTable);
const w = readWorld(reader);
const tCount = reader.u();
@ -1515,8 +1760,12 @@
const items = readItemBlocks(reader);
let g = null;
try {
if (schema === LEGACY_JSON_BINARY_SCHEMA_VERSION) {
const encodedMetadata = reader.str();
g = encodedMetadata ? JSON.parse(encodedMetadata) : null;
} else {
g = readAchievementMetadata(reader, tCount, items.length, schema);
}
} catch (_) {
throw new Error("invalid achievement metadata in binary save");
}

View file

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

View file

@ -9,8 +9,8 @@
const SLOT_COUNT = Storage.SLOT_COUNT;
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function createSnapshot(worldRef = global.world) {
return Snapshot.createSnapshot(worldRef);
function createSnapshot(worldRef = global.world, options = {}) {
return Snapshot.createSnapshot(worldRef, options);
}
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
@ -188,7 +188,7 @@
exportBtn.textContent = "\u751f\u6210\u4e2d...";
}
try {
const hash = await encodeSnapshot(createSnapshot(global.world));
const hash = await encodeSnapshot(createSnapshot(global.world, { includeAchievements: true }));
if (!hash || typeof hash !== "string") throw new Error("save text was not generated");
if (area) {
area.value = hash;
@ -214,11 +214,11 @@
if (!text.trim()) { global.showToast?.("\u30ed\u30fc\u30c9\u3059\u308b\u546a\u6587\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
if (!await requestConfirmation({
title: "\u546a\u6587\u306e\u30ed\u30fc\u30c9",
message: "\u8cbc\u308a\u4ed8\u3051\u305f\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002",
message: "\u8cbc\u308a\u4ed8\u3051\u305f\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002",
confirmText: "\u30ed\u30fc\u30c9\u3059\u308b",
})) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world);
const result = restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();
global.showToast?.("\u546a\u6587\u304b\u3089\u30ed\u30fc\u30c9\u3057\u307e\u3057\u305f\u3002");
} catch (error) {

View file

@ -221,32 +221,50 @@
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function compactAchievementMetadata(worldRef, tarinaiLive = [], itemRecords = []) {
return {
function compactAchievementMetadata(worldRef, tarinaiLive = [], itemRecords = [], options = {}) {
const isUnlocked = id => Boolean(global.TarinaiAchievements?.isUnlocked?.(id));
const needSlaveGenerations = !isUnlocked("natural_zunchi_slave_5_generations");
const needKingGenerations = !isUnlocked("natural_tarinai_king_3_generations");
const needDirectFeed = !isUnlocked("direct_feed_33");
const needRobotClean = !isUnlocked("robot_cleaner_100");
const needEnemyFriend = !isUnlocked("enemy_enemy_friend");
const needSelfSufficient = !isUnlocked("self_sufficient");
const needIdleObserver = !isUnlocked("idle_observer_5_minutes");
const needSafeColony = !isUnlocked("safe_colony_25_5_minutes");
const needHappyColony = !isUnlocked("colony_happy");
const needOverprotective = !isUnlocked("overprotective");
const needSauna = !isUnlocked("sauna_cold_plunge");
const needQuickDelete = !isUnlocked("unplanned_city_30");
const metadata = {
w: [
[...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
[...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
Math.max(0, Math.floor(Number(worldRef?.achievementDirectFeedCount || 0) || 0)),
Math.max(0, Math.floor(Number(worldRef?.achievementRobotCleanCount || 0) || 0)),
Math.max(0, Math.floor(Number(worldRef?.achievementEnemyAntKills || 0) || 0)),
finiteOrNull(worldRef?.achievementLastTarinaiDamageAt),
finiteOrNull(worldRef?.achievementSelfSufficientStartAt),
finiteOrNull(worldRef?.achievementLastDirectFeedAt),
finiteOrNull(worldRef?.achievementLastInterventionAt),
finiteOrNull(worldRef?.achievementNoDeathStartAt),
finiteOrNull(worldRef?.achievementHappyStartAt),
needSlaveGenerations ? [...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
needKingGenerations ? [...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
needDirectFeed ? Math.max(0, Math.floor(Number(worldRef?.achievementDirectFeedCount || 0) || 0)) : 0,
needRobotClean ? Math.max(0, Math.floor(Number(worldRef?.achievementRobotCleanCount || 0) || 0)) : 0,
needEnemyFriend ? Math.max(0, Math.floor(Number(worldRef?.achievementEnemyAntKills || 0) || 0)) : 0,
needEnemyFriend ? finiteOrNull(worldRef?.achievementLastTarinaiDamageAt) : null,
needSelfSufficient ? finiteOrNull(worldRef?.achievementSelfSufficientStartAt) : -1,
needSelfSufficient ? finiteOrNull(worldRef?.achievementLastDirectFeedAt) : null,
needIdleObserver ? finiteOrNull(worldRef?.achievementLastInterventionAt) : 0,
needSafeColony ? finiteOrNull(worldRef?.achievementNoDeathStartAt) : -1,
needHappyColony ? finiteOrNull(worldRef?.achievementHappyStartAt) : -1,
],
t: tarinaiLive.map(t => [
Math.max(0, Math.floor(Number(t?._achievementDirectFeedCount || 0) || 0)),
Math.max(0, Math.floor(Number(t?._achievementDirectTreatmentCount || 0) || 0)),
finiteOrNull(t?._achievementFightMochiAt),
finiteOrNull(t?._achievementSaunaHotAt),
needOverprotective ? Math.max(0, Math.floor(Number(t?._achievementDirectFeedCount || 0) || 0)) : 0,
needOverprotective ? Math.max(0, Math.floor(Number(t?._achievementDirectTreatmentCount || 0) || 0)) : 0,
null,
needSauna ? finiteOrNull(t?._achievementSaunaHotAt) : null,
]),
i: itemRecords.map(rec => [
rec?.item?._achievementPlayerPlaced ? 1 : 0,
finiteOrNull(rec?.item?._achievementPlacedAt),
needQuickDelete && rec?.item?._achievementPlayerPlaced ? 1 : 0,
needQuickDelete ? finiteOrNull(rec?.item?._achievementPlacedAt) : null,
]),
};
if (options.includeAchievements) {
const spellState = global.TarinaiAchievements?.exportSpellState?.();
if (Array.isArray(spellState)) metadata.s = spellState;
}
return metadata;
}
function applyAchievementMetadata(worldRef, metadata = null) {
if (!worldRef || !metadata || typeof metadata !== "object") return false;
@ -267,7 +285,9 @@
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
t._achievementDirectFeedCount = Math.max(0, Math.floor(Number(row[0] || 0) || 0));
t._achievementDirectTreatmentCount = Math.max(0, Math.floor(Number(row[1] || 0) || 0));
t._achievementFightMochiAt = row[2] != null && Number.isFinite(Number(row[2])) ? Number(row[2]) : null;
t._achievementFightMochiAt = null;
t._achievementFightMochiSerial = 0;
t._achievementFightMochiWorld = null;
t._achievementSaunaHotAt = row[3] != null && Number.isFinite(Number(row[3])) ? Number(row[3]) : null;
});
const itemRows = Array.isArray(metadata.i) ? metadata.i : [];
@ -537,7 +557,7 @@
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); item.amount = item.foodServingsRemaining; global.updateServingFoodVisualSize(item);
}
}
function createSnapshot(worldRef = global.world) {
function createSnapshot(worldRef = global.world, options = {}) {
if (!worldRef) throw new Error("world is not ready");
const tarinaiLive = (worldRef.tarinai || [])
.filter(t => t && !t.dead)
@ -564,7 +584,7 @@
w: compactWorld(worldRef),
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
i: itemRows,
g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords),
g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords, options),
};
}
function restoreSnapshot(snapshot, worldRef = global.world) {
@ -675,7 +695,10 @@
worldRef.rebuildSpatial?.(true);
worldRef.markTerrainDirty?.("load");
worldRef.clampCamera?.();
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION };
const achievementImport = snapshot.g?.s
? global.TarinaiAchievements?.importSpellState?.(snapshot.g.s) || { included: true, unlockedAdded: 0, progressChanged: false }
: { included: false, unlockedAdded: 0, progressChanged: false };
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION, achievementImport };
}
global.TarinaiSnapshot = {

View file

@ -66,7 +66,7 @@ function renderStats() {
if (ui.tarinaiPopulationLimitInput && document.activeElement !== ui.tarinaiPopulationLimitInput) ui.tarinaiPopulationLimitInput.value = tarinaiLimit > 0 ? String(tarinaiLimit) : "";
if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : "";
}
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `\u305f\u308a\u306a\u3044 ${values.pop} / ${tarinaiLimit || "\u221e"}\u3000\u30aa\u30d6\u30b8\u30a7\u30af\u30c8 ${objectCount} / ${objectLimit || "\u221e"}`);
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `\u305f\u308a\u306a\u3044\u306e\u6570 ${values.pop} / ${tarinaiLimit || "\u221e"}\u3000\u7269\u4f53\u6570 ${objectCount} / ${objectLimit || "\u221e"}`);
bindColonyMoodTooltip();
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
window.TarinaiGroundUI.update(world);

View file

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

View file

@ -767,6 +767,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
finishBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return null;
const loveMochiInfluenced = [a, b].some(tarinai => (Number(tarinai?.loveMochiTimer || 0) > 0.04) || Boolean(tarinai?.hasLoveMochiEffect?.()));
const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease;
a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null;
@ -790,7 +791,10 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
const child = this.spawnChild(a, b, i, childCount);
if (child) { child.postBirthPeaceTimer = 18; children.push(child); }
}
if (children.length > 0) this.lastBirthAt = this.time;
if (children.length > 0) {
this.lastBirthAt = this.time;
globalThis.TarinaiAchievements?.recordLoveMochiBirth?.({ world: this, a, b, children, loveMochiInfluenced });
}
if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] });
return children[0] || null;
},

View file

@ -393,6 +393,7 @@
fireShootTool(x = null, y = null) {
const aim = (x == null || y == null) ? this.shootAimPoint?.() : { x: Number(x) || 0, y: Number(y) || 0, inside: true };
if (!aim?.inside) return false;
global.TarinaiAchievements?.recordShotFired?.({ world: this, x: aim.x, y: aim.y });
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "shoot", x: aim.x, y: aim.y });
const hit = this.findShootToolTargetAt?.(aim.x, aim.y);
audio.shoot?.();

View file

@ -80,7 +80,7 @@ function createHarness(existingStorage = null, search = "", options = {}) {
setInterval() { timerId += 1; return timerId; },
clearInterval() {},
fetch: options.fetch || (async () => { throw new Error("offline"); }),
TARINAI_VERSION: "39.16.56",
TARINAI_VERSION: "39.16.60",
TarinaiGameDialogs: { confirm: async () => true },
};
context.window = context;
@ -108,12 +108,13 @@ const expectedIds = [
"revolution", "fuel_to_fire", "undo_20", "redo_20",
"eternal_history_generation_10", "king_full_satisfaction", "slave_zero_satisfaction", "town_doctor_50", "poke_plushie_fling",
"chaos_seeker_666_fights", "clean_freak_robot_only", "true_tarinai_observer",
"sniper_333_shots", "megalopolis", "fertility_seeker_721_love_births",
];
(async function run() {
let h = createHarness();
let storage;
assert(h.api.definitions.length === 51, "definition count must be 51");
assert(h.api.definitions.length === 54, "definition count must be 54");
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
const categorizedIds = h.api.categories.flatMap(category => category.ids);
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
@ -149,6 +150,9 @@ const expectedIds = [
assert(titles.chaos_seeker_666_fights === "混沌ヲ希求スル者", "chaos seeker title mismatch");
assert(titles.clean_freak_robot_only === "潔癖症", "clean freak title mismatch");
assert(titles.true_tarinai_observer === "真・たりない観察", "completionist title mismatch");
assert(titles.sniper_333_shots === "狙撃手", "sniper title mismatch");
assert(titles.megalopolis === "メガロポリス", "megalopolis title mismatch");
assert(titles.fertility_seeker_721_love_births === "豊穣ヲ希求スル者", "fertility title mismatch");
const descriptions = Object.fromEntries(h.api.definitions.map(def => [def.id, def.description]));
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
assert(descriptions.natural_tarinai_king === "たりない王が出現する。", "king description mismatch");
@ -158,7 +162,10 @@ const expectedIds = [
assert(descriptions.great_mother_1000_births.includes("3333体"), "great mother description threshold mismatch");
assert(descriptions.robot_cleaner_100.includes("200個"), "cleaner description threshold mismatch");
assert(descriptions.minimalist_happy.includes("6日目以降"), "minimalist start-day description mismatch");
assert(descriptions.chaos_seeker_666_fights.includes("666回"), "chaos seeker description mismatch");
assert(descriptions.chaos_seeker_666_fights.includes("けんか餅") && descriptions.chaos_seeker_666_fights.includes("666回"), "chaos seeker 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.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
assert(!source.includes("TarinaiTooltips"), "achievement tooltips were not removed");
assert(source.includes("PRE_UNLOCK_DESCRIPTION_IDS"), "pre-unlock disclosure allowlist is missing");
function walk(node, out = []) { for (const child of node?.children || []) { out.push(child); walk(child, out); } return out; }
@ -490,6 +497,7 @@ const expectedIds = [
await h.api.refresh();
assert(recoveryRequests.length === 1, "recovery sync did not issue exactly one request");
assert(recoveryRequests[0].action === "sync", "recovery request did not use bulk sync");
assert(recoveryRequests[0].completionistEligible === false, "full recovery sync did not report completionist eligibility");
assert(recoveryRequests[0].unlocked.first_birth === 1700000000123, "recovery sync omitted first birth timestamp");
assert(recoveryRequests[0].unlocked.lifespan_completed === 1700000000456, "recovery sync omitted lifespan timestamp");
@ -508,12 +516,55 @@ const expectedIds = [
storage = new Map();
h = createHarness(storage);
const chaosWorld = { time: 0 };
for (let i = 0; i < 400; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
for (let i = 0; i < 700; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
assert(!unlocked(h, "chaos_seeker_666_fights"), "ordinary fights counted toward chaos seeker");
const fightMochiTarinai = { fightMochiTimer: 10 };
for (let i = 0; i < 400; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
h = createHarness(storage);
for (let i = 0; i < 265; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
assert(!unlocked(h, "chaos_seeker_666_fights"), "chaos seeker unlocked at 665 fights");
h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
assert(unlocked(h, "chaos_seeker_666_fights"), "chaos seeker did not persist to 666 fights");
for (let i = 0; i < 265; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
assert(!unlocked(h, "chaos_seeker_666_fights"), "chaos seeker unlocked at 665 influenced fights");
h.api.recordFightStarted({ world: chaosWorld, a: fightMochiTarinai, b: {} });
assert(unlocked(h, "chaos_seeker_666_fights"), "chaos seeker did not persist to 666 influenced fights");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 332; i += 1) h.api.recordShotFired({});
assert(!unlocked(h, "sniper_333_shots"), "sniper unlocked before 333 shots");
h = createHarness(storage);
h.api.recordShotFired({});
assert(unlocked(h, "sniper_333_shots"), "sniper did not persist to 333 shots");
storage = new Map();
h = createHarness(storage);
for (let i = 0; i < 800; i += 1) h.api.recordLoveMochiBirth({ loveMochiInfluenced: false });
assert(!unlocked(h, "fertility_seeker_721_love_births"), "ordinary reproduction counted toward fertility seeker");
for (let i = 0; i < 720; i += 1) h.api.recordLoveMochiBirth({ loveMochiInfluenced: true });
assert(!unlocked(h, "fertility_seeker_721_love_births"), "fertility seeker unlocked before 721 influenced reproductions");
h = createHarness(storage);
h.api.recordLoveMochiBirth({ loveMochiInfluenced: true });
assert(unlocked(h, "fertility_seeker_721_love_births"), "fertility seeker did not persist to 721 influenced reproductions");
h = createHarness();
const metroWorld = {
tarinai: Array.from({ length: 99 }, () => ({ dead: false })),
items: [
...Array.from({ length: 10 }, () => ({ type: "duplicator", dead: false })),
...Array.from({ length: 15 }, (_v, i) => ({ type: i % 3 === 0 ? "grass_bed" : (i % 3 === 1 ? "nest_box" : "pipe"), dead: false })),
],
};
h.api.evaluateMegalopolis(metroWorld);
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 100-tarinai threshold");
metroWorld.tarinai.push({ dead: false });
metroWorld.items[0].dead = true;
h.api.evaluateMegalopolis(metroWorld);
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 10-duplicator threshold");
metroWorld.items[0].dead = false;
metroWorld.items[10].dead = true;
h.api.evaluateMegalopolis(metroWorld);
assert(!unlocked(h, "megalopolis"), "megalopolis ignored the 15-shelter threshold");
metroWorld.items[10].dead = false;
h.api.evaluateMegalopolis(metroWorld);
assert(unlocked(h, "megalopolis"), "megalopolis did not unlock at exact thresholds");
h = createHarness();
const cleaner = { type: "robot_cleaner", dead: false, robotCleanerMask: 0x3f, robotCleanerHighSpeed: true, _signalDriven: true, _signalOn: false };
@ -532,7 +583,7 @@ const expectedIds = [
h.api.evaluateCleanFreak(cleanWorld);
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
console.log("[OK] all 51 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
console.log("[OK] all 54 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
})().catch(error => {
console.error(error);
process.exitCode = 1;

View file

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

View file

@ -17,14 +17,14 @@ css = read('css/components.css')
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
require(len(ids_js) == 51, 'JS achievement count is not 51')
require(len(ids_js) == 54, 'JS achievement count is not 54')
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer']), 'v56 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 ['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 = {
@ -84,13 +84,19 @@ checks = {
'status effect combinations are event-driven': 'evaluateStatusEffectAchievements' in ach and 'king_full_satisfaction' in ach and 'slave_zero_satisfaction' in ach,
'doctor counts actual recovery only': 'recordFirstAidRecovery' in ach and 'recovered > 0.0001' in read('js/item_effect_definitions.js'),
'plushie poke uses shared target and overlay': 'findPokeTargetAt' in read('js/world_placement_log.js') and 'hoverPokeTarget' in read('js/ui_input_shared.js') and 'recordPlushiePokeFling' in ach,
'persistent chaos fight counter': 'progress.fightCount' in ach and 'CHAOS_FIGHT_TARGET = 666' in ach and 'recordFightStarted' in ach,
'persistent fight-mochi chaos counter': 'progress.fightMochiFightCount' in ach and 'CHAOS_FIGHT_TARGET = 666' in ach and 'fightMochiInfluenced' in ach and 'recordFightStarted' in ach,
'persistent sniper counter': 'progress.shotCount' in ach and 'SNIPER_SHOT_TARGET = 333' in ach and 'recordShotFired' in ach and 'recordShotFired?.' in read('js/world_tool_actions.js'),
'persistent love-mochi fertility counter': 'progress.loveMochiBirthCount' in ach and 'FERTILITY_BIRTH_TARGET = 721' in ach and 'recordLoveMochiBirth' in ach and 'loveMochiInfluenced' in read('js/world_family_social.js'),
'megalopolis exact thresholds': 'duplicatorCount < 10' in ach and 'shelterCount < 15' in ach and 'aliveCount < 100' in ach and 'evaluateMegalopolis' in ach,
'clean-freak robot-only evaluation': 'evaluateCleanFreak' in ach and 'ROBOT_ALL_TARGET_MASK = 0x3f' in ach and 'robotCleanerHighSpeed' in ach and 'signal-power' in read('js/signal_system.js'),
'medicine ledger excludes chain and crown': 'slave_chain' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0] and 'golden_crown' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0],
'cleaner target raised to 200': 'ROBOT_CLEAN_TARGET = 200' in ach and r'\u7d2f\u8a08200\u500b' in ach,
'minimalist starts on day six': 'day >= 6 && !state.unlocked.minimalist_happy' in ach,
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
'compact server state v2': 'function compact_state' in api and "['v' => 2, 'p' => $players, 'u' => $unlocks]" in api,
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
'old generic fight progress is not migrated': 'progressSource.fightCount' not in ach and 'progressVersion === SPELL_STATE_VERSION' in ach,
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
}
for name, ok in checks.items():
require(ok, name)

View file

@ -22,8 +22,14 @@ const context = {
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: 41,
BINARY_SCHEMA_VERSION: 43,
FIELD_IDS: ["garden"],
itemTypeValue(_id, fallback = "") { return fallback; },
},
@ -35,23 +41,28 @@ vm.runInContext(fs.readFileSync(path.join(root, "js", "save_codec.js"), "utf8"),
(async () => {
const metadata = {
w: [[1, 2], [3], 12, 87, 6, 145.5, 20, 18, 100, 90],
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: [],
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)) throw new Error("achievement metadata changed during binary save roundtrip");
console.log("[OK] achievement metadata survives current binary save roundtrip");
if (JSON.stringify(decoded.g) !== JSON.stringify(metadata)) {
console.error("expected", JSON.stringify(metadata));
console.error("actual ", JSON.stringify(decoded.g));
throw new Error("achievement metadata changed during binary save roundtrip");
}
console.log(`[OK] sparse binary achievement metadata roundtrip passed (${encoded.length} spell chars)`);
})().catch(error => {
console.error(error);
process.exitCode = 1;

View file

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

View file

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

View file

@ -25,7 +25,7 @@ function harness(storage = new Map()) {
location:{protocol:"file:",search:""},crypto:{randomUUID:()=>"11111111-1111-4111-8111-111111111111"},
localStorage:{getItem:k=>storage.has(k)?storage.get(k):null,setItem:(k,v)=>storage.set(k,String(v)),removeItem:k=>storage.delete(k)},
document:{hidden:false,getElementById:id=>elements.get(id)||null,createElement:()=>new Element(),addEventListener(){}},
TarinaiEvents:{emit(){}},showToast(){},TarinaiGameDialogs:{confirm:async()=>true},audio:{uiClick(){}},setTimeout(){return ++timer;},clearTimeout(){},setInterval(){return ++timer;},clearInterval(){},fetch:async()=>{throw new Error("offline");},TARINAI_VERSION:"39.16.56"};
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"};
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
return {c,api:c.TarinaiAchievements,storage,elements};
}
@ -35,7 +35,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
(function run(){
let h=harness();
assert(h.api.definitions.length===51,"must have 51 achievements");
assert(h.api.definitions.length===54,"must have 54 achievements");
h.api.open();
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");

View file

@ -1,6 +1,6 @@
"use strict";
const APP_VERSION = "39.16.56";
const APP_VERSION = "39.16.60";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.