"use strict"; (function initAchievementSystem(global) { // v7 uses a fresh namespace so tabs left open on older builds cannot write // stale full-state envelopes into the current persistence layer. const STORAGE_KEY = "tarinai_achievements_v7"; const STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v7"; const STORAGE_SESSION_KEY = "tarinai_achievements_session_v7"; const PREVIOUS_STORAGE_KEY = "tarinai_achievements_v6"; const PREVIOUS_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v6"; const PREVIOUS_STORAGE_SESSION_KEY = "tarinai_achievements_session_v6"; const OLDER_STORAGE_KEY = "tarinai_achievements_v5"; const OLDER_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v5"; const OLDER_STORAGE_SESSION_KEY = "tarinai_achievements_session_v5"; const LEGACY_STORAGE_KEY = "tarinai_achievements_v4"; const LEGACY_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v4"; const LEGACY_STORAGE_SESSION_KEY = "tarinai_achievements_session_v4"; const ANCIENT_STORAGE_KEY = "tarinai_achievements_v2"; const ANCIENT_STORAGE_BACKUP_KEY = "tarinai_achievements_backup_v3"; const ANCIENT_STORAGE_SESSION_KEY = "tarinai_achievements_session_v3"; const PERSISTENCE_FORMAT = "tarinai-achievements-envelope-v1"; const PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v5"; const PREVIOUS_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v4"; const OLDER_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v3"; const LEGACY_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v2"; const ANCIENT_PERSISTENCE_DB_NAME = "tarinai_achievements_durable_v1"; const PERSISTENCE_DB_STORE = "records"; const PERSISTENCE_DB_KEY = "achievement-state"; const PERSISTENCE_CHANNEL_NAME = "tarinai-achievements-persistence-v5"; const PREHYDRATION_JOURNAL_PREFIX = "tarinai_achievements_pre_hydration_v7_"; const PREHYDRATION_JOURNAL_FORMAT = "tarinai-achievements-pre-hydration-v1"; const PREHYDRATION_JOURNAL_DB_PREFIX = "pre-hydration-journal:"; const PLAYER_KEY = "tarinai_achievement_player_v1"; const PLAYER_SECRET_KEY = "tarinai_achievement_player_secret_v1"; const LEGACY_PLAYER_KEY = "tarinai_achievement_legacy_player_v4"; const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php"); const GAME_VERSION = String(global.TARINAI_VERSION || "39.18.18"); 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}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const LEGACY_PLAYER_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; const PLAYER_SECRET_PATTERN = /^[0-9a-f]{64}$/i; const ABSOLUTE_ZERO_C = -273.15; const CONTINUOUS_PLAY_TARGET_MS = 60 * 60 * 1000; const CONTINUOUS_PLAY_24H_TARGET_MS = 24 * 60 * 60 * 1000; const HELD_TARGET_MS = 30 * 1000; const ACHIEVEMENT_CATALOG = global.TARINAI_ACHIEVEMENT_CATALOG; if (!ACHIEVEMENT_CATALOG || !Array.isArray(ACHIEVEMENT_CATALOG.definitions) || !Array.isArray(ACHIEVEMENT_CATALOG.categories)) { throw new Error("achievement catalog unavailable"); } const COMPLETIONIST_ID = String(ACHIEVEMENT_CATALOG.completionistId || "true_tarinai_observer"); const ACHIEVEMENT_CATALOG_REVISION = String(ACHIEVEMENT_CATALOG.revision || "unknown"); const GENERATION_TARGET = 10; const DAY_MS = 24 * 60 * 60 * 1000; const SYNC_DEBOUNCE_MS = 450; const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000; const SYNC_PROTOCOL_VERSION = 5; const SPELL_STATE_VERSION = 11; // v11 stores additive counters only in the canonical ledger. const MEDICINE_LEDGER_TYPES = Object.freeze([...(ACHIEVEMENT_CATALOG.medicineLedgerTypes || [])]); const LINK_PROGRESS_TYPES = Object.freeze([...(ACHIEVEMENT_CATALOG.linkProgressTypes || [])]); const ADDITIVE_PROGRESS_CAPS = Object.freeze({ ...(ACHIEVEMENT_CATALOG.additiveProgressCaps || {}) }); const ADDITIVE_PROGRESS_KEYS = Object.freeze(Object.keys(ADDITIVE_PROGRESS_CAPS)); const CHAOS_FIGHT_TARGET = ADDITIVE_PROGRESS_CAPS.fightMochiFightCount; const SNIPER_SHOT_TARGET = ADDITIVE_PROGRESS_CAPS.shotCount; const FERTILITY_BIRTH_TARGET = ADDITIVE_PROGRESS_CAPS.loveMochiBirthCount; const DAILY_PLAY_TARGET = 7; const WIRE_SHOCK_TARGET = 7; const ROBOT_CLEAN_TARGET = ADDITIVE_PROGRESS_CAPS.robotCleanCount; const ROBOT_ALL_TARGET_MASK = 0x7f; const OBSERVER_GAME_DAYS = 3; const STRENGTH_IN_NUMBERS_TARGET = 100; const ELITE_FEW_MAX_POPULATION = 25; const ELITE_FEW_TARGET_DAYS = 5; const STATISTICIAN_TARGET = ADDITIVE_PROGRESS_CAPS.statsButtonPressCount; const LIVELY_MAKING_TARGET = ADDITIVE_PROGRESS_CAPS.manualTarinaiAddedCount; const MEMENTO_MORI_TARGET = ADDITIVE_PROGRESS_CAPS.totalDeathCount; const MAD_SCIENTIST_TARGET = Number(ACHIEVEMENT_CATALOG.definitions.find(def => def.id === "mad_scientist")?.progress?.target || 10); const FULL_SEASON_CYCLE_DAYS = 20; const DEFINITIONS = Object.freeze(ACHIEVEMENT_CATALOG.definitions.map(definition => Object.freeze({ ...definition }))); const PRE_UNLOCK_DESCRIPTION_IDS = new Set(DEFINITIONS.filter(definition => definition.showLockedDescription).map(definition => definition.id)); const ACHIEVEMENT_CATEGORIES = Object.freeze(ACHIEVEMENT_CATALOG.categories.map(category => Object.freeze({ id: String(category.id || ""), title: String(category.title || ""), ids: Object.freeze(DEFINITIONS.filter(definition => definition.category === category.id).map(definition => definition.id)), }))); const definitionById = new Map(DEFINITIONS.map(def => [def.id, def])); const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [ category.id, Object.freeze(category.ids.map(id => definitionById.get(id)).filter(Boolean)), ])); const dom = { button: document.getElementById("achievementsBtn"), buttonCount: document.getElementById("achievementButtonCount"), dialog: document.getElementById("achievementsDialog"), dialogCount: document.getElementById("achievementDialogCount"), closeButton: document.getElementById("achievementsCloseBtn"), closeIconButton: document.getElementById("achievementsCloseIconBtn"), refreshButton: document.getElementById("achievementRefreshBtn"), resetButton: document.getElementById("achievementResetBtn"), unlockAllButton: document.getElementById("achievementUnlockAllBtn"), list: document.getElementById("achievementList"), sharedStatus: document.getElementById("achievementSharedStatus"), toast: document.getElementById("achievementToast"), toastTitle: document.getElementById("achievementToastTitle"), toastCondition: document.getElementById("achievementToastCondition"), pauseButton: document.getElementById("pauseBtn"), }; function createPersistenceActorId() { if (global.crypto?.randomUUID) return global.crypto.randomUUID(); return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; } const persistenceActorId = createPersistenceActorId(); let persistenceGeneration = 0; let persistenceRevision = 0; let persistenceHydrated = false; let persistenceDirty = false; let durableWriteQueue = Promise.resolve(); let persistenceReadyPromise = Promise.resolve(); let persistenceChannel = null; const initialPersistence = loadStateRecord(); const initialPersistenceBaseline = normalizeState(initialPersistence.state); let state = initialPersistence.state; persistenceGeneration = initialPersistence.generation; persistenceRevision = initialPersistence.revision; let shared = { connected: false, loading: false, totalPlayers: 0, achievements: {}, statisticsTrust: "client-reported" }; let requestSerial = 0; let runtimeByWorld = new WeakMap(); let dangerFightDeathsByWorld = new WeakMap(); let wireShockTrackers = new WeakMap(); let syncQueue = Promise.resolve(); let toastQueue = []; let toastActive = false; let emptyClickTimes = []; let groundChangeTimes = []; let continuousPlayAccumulatedMs = 0; let continuousPlayLastHeartbeatAt = Date.now(); let continuousPlayActive = !document.hidden; let lastListSignature = ""; let scheduledSyncTimer = 0; let scheduledProgressSaveTimer = 0; let progressMutationRevision = 0; let progressSyncedRevision = 0; function blankProgressLedger() { return { base: Object.fromEntries(ADDITIVE_PROGRESS_KEYS.map(key => [key, 0])), shards: Object.fromEntries(ADDITIVE_PROGRESS_KEYS.map(key => [key, {}])), }; } function blankState() { return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], serverIdentity: null, progress: { linkTypes: [], signalActivated: false, medicineTypes: [], dailyPlayStreak: 0, dailyPlayLastDay: 0, mysteryDrugTarinaiIds: [] }, _progressLedger: blankProgressLedger(), }; } function ledgerCounterValue(ledgerInput, key) { if (!Object.prototype.hasOwnProperty.call(ADDITIVE_PROGRESS_CAPS, key)) return 0; const cap = ADDITIVE_PROGRESS_CAPS[key]; const ledger = ledgerInput && typeof ledgerInput === "object" ? ledgerInput : {}; const base = Math.min(cap, Math.max(0, Math.floor(Number(ledger.base?.[key] || 0) || 0))); const shards = ledger.shards?.[key] && typeof ledger.shards[key] === "object" ? ledger.shards[key] : {}; return Math.min(cap, base + Object.values(shards).reduce((sum, value) => sum + Math.max(0, Math.floor(Number(value || 0) || 0)), 0)); } function sanitizeProgressLedger(parsed, legacyProgress = {}) { const source = parsed?._progressLedger && typeof parsed._progressLedger === "object" ? parsed._progressLedger : {}; const baseSource = source.base && typeof source.base === "object" ? source.base : {}; const shardsSource = source.shards && typeof source.shards === "object" ? source.shards : {}; const ledger = blankProgressLedger(); for (const key of ADDITIVE_PROGRESS_KEYS) { const cap = ADDITIVE_PROGRESS_CAPS[key]; let base = Math.min(cap, Math.max(0, Math.floor(Number(baseSource[key] || 0) || 0))); const shards = {}; const rawShards = shardsSource[key] && typeof shardsSource[key] === "object" ? shardsSource[key] : {}; for (const [actorId, rawValue] of Object.entries(rawShards)) { const id = String(actorId || "").slice(0, 160); const value = Math.min(cap, Math.max(0, Math.floor(Number(rawValue || 0) || 0))); if (id && value > 0) shards[id] = value; } let total = Math.min(cap, base + Object.values(shards).reduce((sum, value) => sum + value, 0)); const legacyFloor = Math.min(cap, Math.max(0, Math.floor(Number(legacyProgress[key] || 0) || 0))); if (legacyFloor > total) { base = Math.min(cap, base + (legacyFloor - total)); total = legacyFloor; } if (total >= cap) { base = cap; for (const actorId of Object.keys(shards)) delete shards[actorId]; } ledger.base[key] = base; ledger.shards[key] = shards; } return ledger; } function normalizeServerIdentity(value) { if (!value || typeof value !== "object") return null; const id = String(value.playerId || "").toLowerCase(); const secret = String(value.playerSecret || "").toLowerCase(); if (!PLAYER_ID_PATTERN.test(id) || !PLAYER_SECRET_PATTERN.test(secret)) return null; return { playerId: id, playerSecret: secret }; } function normalizeState(parsed) { if (!parsed || typeof parsed !== "object") return blankState(); const unlockedSource = parsed.unlocked && typeof parsed.unlocked === "object" ? parsed.unlocked : {}; const unlocked = {}; for (const [id, timestamp] of Object.entries(unlockedSource)) { if (definitionById.has(id) && Number(timestamp) > 0) unlocked[id] = Number(timestamp); } const debugUnlockedSource = Array.isArray(parsed.debugUnlocked) ? parsed.debugUnlocked : []; const debugUnlocked = [...new Set(debugUnlockedSource.filter(id => definitionById.has(id)))]; if (!DEBUG_MODE) { for (const id of debugUnlocked) delete unlocked[id]; } // v6 and older persisted a redundant synced list. It is read once only to // avoid re-queuing already acknowledged legacy unlocks, then discarded. const legacySynced = new Set((Array.isArray(parsed.synced) ? parsed.synced : []).filter(id => definitionById.has(id))); const pendingSource = Array.isArray(parsed.pending) ? parsed.pending : []; const pending = pendingSource.filter(id => definitionById.has(id) && unlocked[id] && !debugUnlocked.includes(id) && !legacySynced.has(id)); const progressSource = parsed.progress && typeof parsed.progress === "object" ? parsed.progress : {}; const progress = { linkTypes: [...new Set((Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []).filter(type => LINK_PROGRESS_TYPES.includes(type)))], signalActivated: Boolean(progressSource.signalActivated), medicineTypes: [...new Set((Array.isArray(progressSource.medicineTypes) ? progressSource.medicineTypes : []).filter(type => MEDICINE_LEDGER_TYPES.includes(type)))], dailyPlayStreak: Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(progressSource.dailyPlayStreak || 0) || 0))), dailyPlayLastDay: Math.max(0, Math.floor(Number(progressSource.dailyPlayLastDay || 0) || 0)), mysteryDrugTarinaiIds: [...new Set((Array.isArray(progressSource.mysteryDrugTarinaiIds) ? progressSource.mysteryDrugTarinaiIds : []).map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET), }; const progressLedger = sanitizeProgressLedger(parsed, progressSource); const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]); if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) { delete unlocked[COMPLETIONIST_ID]; const pendingIndex = pending.indexOf(COMPLETIONIST_ID); if (pendingIndex >= 0) pending.splice(pendingIndex, 1); } return { unlocked, pending: [...new Set(pending)], resetPending: Boolean(parsed.resetPending), debugUnlocked: DEBUG_MODE ? debugUnlocked : [], serverIdentity: normalizeServerIdentity(parsed.serverIdentity), progress, _progressLedger: progressLedger }; } function decodePersistenceEnvelope(raw) { try { const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; if (!parsed || typeof parsed !== "object") return null; if (parsed.format === PERSISTENCE_FORMAT && parsed.state && typeof parsed.state === "object") { return { generation: Math.max(0, Math.floor(Number(parsed.generation || 0) || 0)), revision: Math.max(0, Math.floor(Number(parsed.revision || 0) || 0)), updatedAt: Math.max(0, Number(parsed.updatedAt || 0) || 0), migrationComplete: Boolean(parsed.migrationComplete), state: normalizeState(parsed.state), }; } return { generation: 0, revision: 0, updatedAt: 0, migrationComplete: false, state: normalizeState(parsed) }; } catch (_) { return null; } } function mergeStateData(targetInput, sourceInput) { const target = normalizeState(targetInput); const source = normalizeState(sourceInput); for (const [id, timestamp] of Object.entries(source.unlocked)) { const current = Number(target.unlocked[id] || 0) || 0; target.unlocked[id] = current > 0 ? Math.min(current, timestamp) : timestamp; } target.pending = [...new Set([...target.pending, ...source.pending])] .filter(id => definitionById.has(id) && target.unlocked[id] && !target.debugUnlocked.includes(id)); if (DEBUG_MODE) target.debugUnlocked = [...new Set([...target.debugUnlocked, ...source.debugUnlocked])]; if (!target.serverIdentity && source.serverIdentity) target.serverIdentity = { ...source.serverIdentity }; for (const key of ADDITIVE_PROGRESS_KEYS) { target._progressLedger.base[key] = Math.max( Number(target._progressLedger.base[key] || 0) || 0, Number(source._progressLedger.base[key] || 0) || 0, ); const targetShards = target._progressLedger.shards[key] || (target._progressLedger.shards[key] = {}); const sourceShards = source._progressLedger.shards[key] || {}; for (const [actorId, value] of Object.entries(sourceShards)) { targetShards[actorId] = Math.max(Number(targetShards[actorId] || 0) || 0, Number(value || 0) || 0); } } // Daily streak and its last-day marker are one logical record. Merging the // two numbers independently can pair an old high streak with a new date. const targetDay = Number(target.progress.dailyPlayLastDay || 0) || 0; const sourceDay = Number(source.progress.dailyPlayLastDay || 0) || 0; if (sourceDay > targetDay) { target.progress.dailyPlayLastDay = sourceDay; target.progress.dailyPlayStreak = Number(source.progress.dailyPlayStreak || 0) || 0; } else if (sourceDay === targetDay) { target.progress.dailyPlayStreak = Math.max(Number(target.progress.dailyPlayStreak || 0) || 0, Number(source.progress.dailyPlayStreak || 0) || 0); } target.progress.signalActivated = Boolean(target.progress.signalActivated || source.progress.signalActivated); target.progress.linkTypes = [...new Set([...(target.progress.linkTypes || []), ...(source.progress.linkTypes || [])])]; target.progress.medicineTypes = [...new Set([...(target.progress.medicineTypes || []), ...(source.progress.medicineTypes || [])])]; target.progress.mysteryDrugTarinaiIds = [...new Set([...(target.progress.mysteryDrugTarinaiIds || []), ...(source.progress.mysteryDrugTarinaiIds || [])])].slice(0, MAD_SCIENTIST_TARGET); return normalizeState(target); } function growOnlyStateDelta(baseInput, currentInput) { const base = normalizeState(baseInput); const current = normalizeState(currentInput); const delta = blankState(); for (const [id, timestamp] of Object.entries(current.unlocked)) { const previous = Number(base.unlocked[id] || 0) || 0; if (!previous || timestamp < previous) delta.unlocked[id] = timestamp; } delta.pending = current.pending.filter(id => delta.unlocked[id]); if (DEBUG_MODE) delta.debugUnlocked = current.debugUnlocked.filter(id => !base.debugUnlocked.includes(id)); if (current.serverIdentity && (!base.serverIdentity || current.serverIdentity.playerId !== base.serverIdentity.playerId || current.serverIdentity.playerSecret !== base.serverIdentity.playerSecret)) { delta.serverIdentity = { ...current.serverIdentity }; } for (const key of ADDITIVE_PROGRESS_KEYS) { const currentBase = Number(current._progressLedger.base[key] || 0) || 0; const baselineBase = Number(base._progressLedger.base[key] || 0) || 0; if (currentBase > baselineBase) delta._progressLedger.base[key] = currentBase; const currentShards = current._progressLedger.shards[key] || {}; const baselineShards = base._progressLedger.shards[key] || {}; const deltaShards = delta._progressLedger.shards[key] || (delta._progressLedger.shards[key] = {}); for (const [actorId, value] of Object.entries(currentShards)) { if ((Number(value) || 0) > (Number(baselineShards[actorId]) || 0)) { deltaShards[actorId] = Number(value) || 0; } } } const currentDay = Number(current.progress.dailyPlayLastDay || 0) || 0; const baseDay = Number(base.progress.dailyPlayLastDay || 0) || 0; const currentStreak = Number(current.progress.dailyPlayStreak || 0) || 0; const baseStreak = Number(base.progress.dailyPlayStreak || 0) || 0; if (currentDay !== baseDay) { delta.progress.dailyPlayLastDay = currentDay; delta.progress.dailyPlayStreak = currentStreak; } else if (currentStreak > baseStreak) { delta.progress.dailyPlayStreak = currentStreak; } delta.progress.signalActivated = Boolean(current.progress.signalActivated && !base.progress.signalActivated); delta.progress.linkTypes = current.progress.linkTypes.filter(value => !base.progress.linkTypes.includes(value)); delta.progress.medicineTypes = current.progress.medicineTypes.filter(value => !base.progress.medicineTypes.includes(value)); delta.progress.mysteryDrugTarinaiIds = current.progress.mysteryDrugTarinaiIds.filter(value => !base.progress.mysteryDrugTarinaiIds.includes(value)); return normalizeState(delta); } function readStorageCandidate(storage, key) { try { return decodePersistenceEnvelope(storage?.getItem?.(key) || null); } catch (_) { return null; } } function webStorageCandidates() { return [ readStorageCandidate(global.localStorage, STORAGE_KEY), readStorageCandidate(global.localStorage, STORAGE_BACKUP_KEY), readStorageCandidate(global.sessionStorage, STORAGE_SESSION_KEY), ].filter(Boolean); } function legacyWebStorageCandidates() { return [ readStorageCandidate(global.localStorage, PREVIOUS_STORAGE_KEY), readStorageCandidate(global.localStorage, PREVIOUS_STORAGE_BACKUP_KEY), readStorageCandidate(global.sessionStorage, PREVIOUS_STORAGE_SESSION_KEY), readStorageCandidate(global.localStorage, OLDER_STORAGE_KEY), readStorageCandidate(global.localStorage, OLDER_STORAGE_BACKUP_KEY), readStorageCandidate(global.sessionStorage, OLDER_STORAGE_SESSION_KEY), readStorageCandidate(global.localStorage, LEGACY_STORAGE_KEY), readStorageCandidate(global.localStorage, LEGACY_STORAGE_BACKUP_KEY), readStorageCandidate(global.sessionStorage, LEGACY_STORAGE_SESSION_KEY), readStorageCandidate(global.localStorage, ANCIENT_STORAGE_KEY), readStorageCandidate(global.localStorage, ANCIENT_STORAGE_BACKUP_KEY), readStorageCandidate(global.sessionStorage, ANCIENT_STORAGE_SESSION_KEY), ].filter(Boolean); } function decodePreHydrationJournal(raw) { try { const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; if (!parsed || parsed.format !== PREHYDRATION_JOURNAL_FORMAT || !parsed.delta || typeof parsed.delta !== "object") return null; return { format: PREHYDRATION_JOURNAL_FORMAT, actorId: String(parsed.actorId || "").slice(0, 160), baseGeneration: Math.max(0, Math.floor(Number(parsed.baseGeneration || 0) || 0)), updatedAt: Math.max(0, Number(parsed.updatedAt || 0) || 0), delta: normalizeState(parsed.delta), }; } catch (_) { return null; } } function storageJournalCandidates(storage) { const journals = []; try { const length = Math.max(0, Number(storage?.length || 0) || 0); for (let index = 0; index < length; index += 1) { const key = String(storage?.key?.(index) || ""); if (!key.startsWith(PREHYDRATION_JOURNAL_PREFIX)) continue; const journal = decodePreHydrationJournal(storage?.getItem?.(key)); if (journal) journals.push(journal); } } catch (_) {} return journals; } function webStorageJournals() { return [...storageJournalCandidates(global.localStorage), ...storageJournalCandidates(global.sessionStorage)]; } function currentPreHydrationJournal() { return { format: PREHYDRATION_JOURNAL_FORMAT, actorId: persistenceActorId, baseGeneration: persistenceGeneration, updatedAt: Date.now(), delta: growOnlyStateDelta(initialPersistenceBaseline, state), }; } function writePreHydrationJournalToWebStorage(journalInput) { const journal = decodePreHydrationJournal(journalInput); if (!journal) return false; let serialized = ""; try { serialized = JSON.stringify(journal); } catch (_) { return false; } const key = `${PREHYDRATION_JOURNAL_PREFIX}${persistenceActorId}`; let stored = false; try { global.localStorage?.setItem?.(key, serialized); stored = true; } catch (_) {} try { global.sessionStorage?.setItem?.(key, serialized); stored = true; } catch (_) {} return stored; } function removeWebStorageJournals(maxGeneration = persistenceGeneration) { for (const storage of [global.localStorage, global.sessionStorage]) { try { const keys = []; const length = Math.max(0, Number(storage?.length || 0) || 0); for (let index = 0; index < length; index += 1) { const key = String(storage?.key?.(index) || ""); if (key.startsWith(PREHYDRATION_JOURNAL_PREFIX)) keys.push(key); } for (const key of keys) { const journal = decodePreHydrationJournal(storage?.getItem?.(key)); if (!journal || journal.baseGeneration <= maxGeneration) storage?.removeItem?.(key); } } catch (_) {} } } function persistenceStateSignature(generation, stateInput) { try { return `${Math.max(0, Number(generation) || 0)}:${JSON.stringify(normalizeState(stateInput))}`; } catch (_) { return `${Math.max(0, Number(generation) || 0)}:`; } } function normalizePersistenceCandidate(candidate) { if (candidate && typeof candidate === "object" && candidate.state && typeof candidate.state === "object" && Number.isFinite(Number(candidate.generation))) { return { generation: Math.max(0, Math.floor(Number(candidate.generation || 0) || 0)), revision: Math.max(0, Math.floor(Number(candidate.revision || 0) || 0)), updatedAt: Math.max(0, Number(candidate.updatedAt || 0) || 0), migrationComplete: Boolean(candidate.migrationComplete), state: normalizeState(candidate.state), }; } return decodePersistenceEnvelope(candidate); } function mergePersistenceEnvelopes(candidatesInput, options = {}) { const candidates = (Array.isArray(candidatesInput) ? candidatesInput : []) .map(candidate => normalizePersistenceCandidate(candidate)) .filter(Boolean); if (!candidates.length) { return { format: PERSISTENCE_FORMAT, generation: 0, revision: options.incrementRevision ? 1 : 0, updatedAt: Date.now(), migrationComplete: false, state: blankState(), }; } const generation = Math.max(...candidates.map(candidate => candidate.generation)); const active = candidates .filter(candidate => candidate.generation === generation) .sort((a, b) => (a.revision - b.revision) || (a.updatedAt - b.updatedAt)); let merged = blankState(); for (const candidate of active) merged = mergeStateData(merged, candidate.state); const latest = active[active.length - 1]; merged.resetPending = Boolean(latest?.state?.resetPending); const latestIdentity = [...active].reverse().map(candidate => normalizeServerIdentity(candidate.state?.serverIdentity)).find(Boolean); if (latestIdentity) merged.serverIdentity = { ...latestIdentity }; const maxRevision = Math.max(0, ...active.map(candidate => candidate.revision)); return { format: PERSISTENCE_FORMAT, generation, revision: maxRevision + (options.incrementRevision ? 1 : 0), updatedAt: Date.now(), migrationComplete: active.some(candidate => candidate.migrationComplete), state: normalizeState(merged), }; } function loadStateRecord() { const currentCandidates = webStorageCandidates(); const currentGeneration = currentCandidates.length ? Math.max(...currentCandidates.map(candidate => candidate.generation)) : 0; // v6 is written only by this build. Import older namespaces once, before a // completed v6 envelope exists. This still repairs the v39.17.7 failure // where an empty v5 envelope hid v4/v2 data, without reading old namespaces // forever and allowing an old open tab to contaminate current storage. const migrationComplete = currentCandidates.some(candidate => candidate.migrationComplete); const legacyCandidates = currentGeneration === 0 && !migrationComplete ? legacyWebStorageCandidates() : []; const candidates = [...currentCandidates, ...legacyCandidates]; const source = currentCandidates.length && legacyCandidates.length ? "mixed" : (currentCandidates.length ? "current" : (legacyCandidates.length ? "legacy" : "empty")); const merged = mergePersistenceEnvelopes(candidates); return { generation: merged.generation, revision: merged.revision, updatedAt: merged.updatedAt, migrationComplete: merged.migrationComplete, state: merged.state, source, }; } function persistenceEnvelope() { return { format: PERSISTENCE_FORMAT, generation: persistenceGeneration, revision: persistenceRevision, updatedAt: Date.now(), migrationComplete: true, state: normalizeState(state), }; } function adoptPersistenceEnvelope(envelopeInput) { const envelope = normalizePersistenceCandidate(envelopeInput); if (!envelope || envelope.generation < persistenceGeneration) return false; const before = persistenceStateSignature(persistenceGeneration, state); if (envelope.generation > persistenceGeneration) { persistenceGeneration = envelope.generation; persistenceRevision = envelope.revision; state = normalizeState(envelope.state); } else { state = mergeStateData(state, envelope.state); if (envelope.revision >= persistenceRevision) { state.resetPending = Boolean(envelope.state.resetPending); const newerIdentity = normalizeServerIdentity(envelope.state.serverIdentity); if (newerIdentity) state.serverIdentity = { ...newerIdentity }; } persistenceRevision = Math.max(persistenceRevision, envelope.revision); } return before !== persistenceStateSignature(persistenceGeneration, state); } function writeEnvelopeToWebStorage(envelopeInput) { const envelope = normalizePersistenceCandidate(envelopeInput); if (!envelope) return false; let serialized = ""; try { serialized = JSON.stringify({ format: PERSISTENCE_FORMAT, generation: envelope.generation, revision: envelope.revision, updatedAt: envelope.updatedAt || Date.now(), migrationComplete: Boolean(envelope.migrationComplete), state: normalizeState(envelope.state), }); } catch (_) { return false; } let stored = false; try { global.localStorage?.setItem?.(STORAGE_BACKUP_KEY, serialized); stored = true; } catch (_) {} try { global.localStorage?.setItem?.(STORAGE_KEY, serialized); stored = true; } catch (_) {} try { global.sessionStorage?.setItem?.(STORAGE_SESSION_KEY, serialized); stored = true; } catch (_) {} return stored; } function broadcastPersistenceEnvelope(envelopeInput) { const envelope = normalizePersistenceCandidate(envelopeInput); if (!envelope) return; try { persistenceChannel?.postMessage?.(envelope); } catch (_) {} } function openPersistenceDatabase(databaseName = PERSISTENCE_DB_NAME) { return new Promise((resolve, reject) => { if (!global.indexedDB?.open) return reject(new Error("indexeddb unavailable")); let request; try { request = global.indexedDB.open(databaseName, 1); } catch (error) { reject(error); return; } request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(PERSISTENCE_DB_STORE)) db.createObjectStore(PERSISTENCE_DB_STORE); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error("indexeddb open failed")); request.onblocked = () => reject(new Error("indexeddb blocked")); }); } async function readDurableEnvelope(databaseName = PERSISTENCE_DB_NAME) { let db = null; try { db = await openPersistenceDatabase(databaseName); return await new Promise((resolve, reject) => { const tx = db.transaction(PERSISTENCE_DB_STORE, "readonly"); const request = tx.objectStore(PERSISTENCE_DB_STORE).get(PERSISTENCE_DB_KEY); request.onsuccess = () => resolve(decodePersistenceEnvelope(request.result)); request.onerror = () => reject(request.error || new Error("indexeddb read failed")); tx.onabort = () => reject(tx.error || new Error("indexeddb read aborted")); }); } catch (_) { return null; } finally { try { db?.close?.(); } catch (_) {} } } async function readDurableJournals() { let db = null; try { db = await openPersistenceDatabase(); return await new Promise((resolve, reject) => { const journals = []; const tx = db.transaction(PERSISTENCE_DB_STORE, "readonly"); const store = tx.objectStore(PERSISTENCE_DB_STORE); const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) return; if (String(cursor.key || "").startsWith(PREHYDRATION_JOURNAL_DB_PREFIX)) { const journal = decodePreHydrationJournal(cursor.value); if (journal) journals.push(journal); } cursor.continue(); }; request.onerror = () => reject(request.error || new Error("indexeddb journal read failed")); tx.oncomplete = () => resolve(journals); tx.onerror = () => reject(tx.error || new Error("indexeddb journal transaction failed")); tx.onabort = () => reject(tx.error || new Error("indexeddb journal read aborted")); }); } catch (_) { return []; } finally { try { db?.close?.(); } catch (_) {} } } async function writeDurableJournal(journalInput) { const journal = decodePreHydrationJournal(journalInput); if (!journal) return false; let db = null; try { db = await openPersistenceDatabase(); return await new Promise((resolve, reject) => { const tx = db.transaction(PERSISTENCE_DB_STORE, "readwrite"); tx.objectStore(PERSISTENCE_DB_STORE).put(journal, `${PREHYDRATION_JOURNAL_DB_PREFIX}${journal.actorId || persistenceActorId}`); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("indexeddb journal write failed")); tx.onabort = () => reject(tx.error || new Error("indexeddb journal write aborted")); }); } catch (_) { return false; } finally { try { db?.close?.(); } catch (_) {} } } async function removeDurableJournals(maxGeneration = persistenceGeneration) { let db = null; try { db = await openPersistenceDatabase(); await new Promise((resolve, reject) => { const tx = db.transaction(PERSISTENCE_DB_STORE, "readwrite"); const store = tx.objectStore(PERSISTENCE_DB_STORE); const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) return; if (String(cursor.key || "").startsWith(PREHYDRATION_JOURNAL_DB_PREFIX)) { const journal = decodePreHydrationJournal(cursor.value); if (!journal || journal.baseGeneration <= maxGeneration) cursor.delete(); } cursor.continue(); }; request.onerror = () => reject(request.error || new Error("indexeddb journal cleanup failed")); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("indexeddb journal cleanup transaction failed")); tx.onabort = () => reject(tx.error || new Error("indexeddb journal cleanup aborted")); }); } catch (_) { return false; } finally { try { db?.close?.(); } catch (_) {} } return true; } async function writeDurableEnvelope(envelopeInput) { const incoming = normalizePersistenceCandidate(envelopeInput); if (!incoming) return null; let db = null; try { db = await openPersistenceDatabase(); return await new Promise((resolve, reject) => { const tx = db.transaction(PERSISTENCE_DB_STORE, "readwrite"); const store = tx.objectStore(PERSISTENCE_DB_STORE); const request = store.get(PERSISTENCE_DB_KEY); let committed = incoming; request.onsuccess = () => { const existing = normalizePersistenceCandidate(request.result); if (existing?.generation > incoming.generation) { committed = existing; } else if (existing?.generation === incoming.generation) { committed = mergePersistenceEnvelopes([existing, incoming], { incrementRevision: true }); } if (existing?.generation > incoming.generation) return; try { store.put(committed, PERSISTENCE_DB_KEY); } catch (error) { reject(error); } }; request.onerror = () => reject(request.error || new Error("indexeddb read before write failed")); tx.oncomplete = () => resolve(committed); tx.onerror = () => reject(tx.error || new Error("indexeddb write failed")); tx.onabort = () => reject(tx.error || new Error("indexeddb write aborted")); }); } catch (_) { return null; } finally { try { db?.close?.(); } catch (_) {} } } function handleExternalPersistenceEnvelope(envelopeInput, options = {}) { const envelope = normalizePersistenceCandidate(envelopeInput); if (!envelope) return false; const changed = adoptPersistenceEnvelope(envelope); if (!changed) return false; const merged = mergePersistenceEnvelopes([persistenceEnvelope(), envelope], { incrementRevision: true }); adoptPersistenceEnvelope(merged); writeEnvelopeToWebStorage(merged); if (options.broadcast !== false) broadcastPersistenceEnvelope(merged); durableWriteQueue = durableWriteQueue.then( () => writeDurableEnvelope(merged), () => writeDurableEnvelope(merged), ).then(committed => { if (!committed) return null; const durableChanged = adoptPersistenceEnvelope(committed); writeEnvelopeToWebStorage(committed); if (durableChanged) { broadcastPersistenceEnvelope(committed); render({ forceList: true }); } return committed; }); render({ forceList: true }); return true; } function setupPersistenceCrossTabSync() { try { if (typeof global.BroadcastChannel === "function") { persistenceChannel = new global.BroadcastChannel(PERSISTENCE_CHANNEL_NAME); persistenceChannel.addEventListener?.("message", event => { handleExternalPersistenceEnvelope(event?.data, { broadcast: true }); }); } } catch (_) { persistenceChannel = null; } global.addEventListener?.("storage", event => { if (![STORAGE_KEY, STORAGE_BACKUP_KEY].includes(String(event?.key || ""))) return; handleExternalPersistenceEnvelope(event?.newValue, { broadcast: true }); }); } function persistStateNow() { persistenceDirty = false; const current = persistenceEnvelope(); const envelope = mergePersistenceEnvelopes([current, ...webStorageCandidates()], { incrementRevision: true }); adoptPersistenceEnvelope(envelope); const stored = writeEnvelopeToWebStorage(envelope); broadcastPersistenceEnvelope(envelope); durableWriteQueue = durableWriteQueue.then( () => writeDurableEnvelope(envelope), () => writeDurableEnvelope(envelope), ).then(committed => { if (!committed) return null; const changed = adoptPersistenceEnvelope(committed); writeEnvelopeToWebStorage(committed); if (changed) { broadcastPersistenceEnvelope(committed); render({ forceList: true }); } return committed; }); return stored; } function saveState() { persistenceDirty = true; if (!persistenceHydrated) { const journal = currentPreHydrationJournal(); const stored = writePreHydrationJournalToWebStorage(journal); durableWriteQueue = durableWriteQueue.then( () => writeDurableJournal(journal), () => writeDurableJournal(journal), ); return stored; } return persistStateNow(); } async function hydrateDurableState() { try { const persistRequest = global.navigator?.storage?.persist?.(); persistRequest?.catch?.(() => false); } catch (_) {} const [currentDurable, previousDurable, olderDurable, legacyDurable, ancientDurable, durableJournals] = await Promise.all([ readDurableEnvelope(PERSISTENCE_DB_NAME), readDurableEnvelope(PREVIOUS_PERSISTENCE_DB_NAME), readDurableEnvelope(OLDER_PERSISTENCE_DB_NAME), readDurableEnvelope(LEGACY_PERSISTENCE_DB_NAME), readDurableEnvelope(ANCIENT_PERSISTENCE_DB_NAME), readDurableJournals(), ]); const currentCandidates = [...webStorageCandidates(), currentDurable].filter(Boolean); const currentGeneration = currentCandidates.length ? Math.max(...currentCandidates.map(candidate => candidate.generation)) : 0; const migrationComplete = currentCandidates.some(candidate => candidate.migrationComplete); const legacyCandidates = currentGeneration === 0 && !migrationComplete ? [...legacyWebStorageCandidates(), previousDurable, olderDurable, legacyDurable, ancientDurable].filter(Boolean) : []; const baseEnvelope = mergePersistenceEnvelopes([...currentCandidates, ...legacyCandidates]); const preHydrationDelta = growOnlyStateDelta(initialPersistenceBaseline, state); const journals = [...webStorageJournals(), ...durableJournals] .filter(journal => journal.baseGeneration === baseEnvelope.generation); let mergedState = baseEnvelope.state; if (initialPersistence.generation === baseEnvelope.generation) { mergedState = mergeStateData(mergedState, preHydrationDelta); } for (const journal of journals) mergedState = mergeStateData(mergedState, journal.delta); persistenceGeneration = baseEnvelope.generation; persistenceRevision = baseEnvelope.revision; state = normalizeState(mergedState); state.resetPending = Boolean(baseEnvelope.state.resetPending); persistenceHydrated = true; persistStateNow(); await durableWriteQueue.catch(() => null); removeWebStorageJournals(persistenceGeneration); await removeDurableJournals(persistenceGeneration); render({ forceList: true }); return true; } function scheduleProgressSave(delay = 350) { if (scheduledProgressSaveTimer) return; scheduledProgressSaveTimer = global.setTimeout(() => { scheduledProgressSaveTimer = 0; saveState(); }, Math.max(0, Number(delay) || 0)); } function markProgressMutation() { progressMutationRevision += 1; if (!DEBUG_MODE && global.location?.protocol !== "file:") schedulePendingSync(); } function createPlayerId() { if (global.crypto?.randomUUID) return global.crypto.randomUUID(); const hex = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"; return hex.replace(/[xy]/g, char => { const value = Math.floor(Math.random() * 16); return (char === "x" ? value : ((value & 3) | 8)).toString(16); }); } function createPlayerSecret() { const bytes = new Uint8Array(32); if (global.crypto?.getRandomValues) global.crypto.getRandomValues(bytes); else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); return [...bytes].map(value => value.toString(16).padStart(2, "0")).join(""); } function storageServerIdentity(storage) { try { return normalizeServerIdentity({ playerId: storage?.getItem?.(PLAYER_KEY), playerSecret: storage?.getItem?.(PLAYER_SECRET_KEY), }); } catch (_) { return null; } } function legacyPlayerId() { const candidates = []; for (const storage of [global.localStorage, global.sessionStorage]) { try { candidates.push(storage?.getItem?.(LEGACY_PLAYER_KEY)); candidates.push(storage?.getItem?.(PLAYER_KEY)); } catch (_) {} } candidates.push(legacyPlayerId.memory); const legacyId = candidates.map(value => String(value || "")).find(value => LEGACY_PLAYER_ID_PATTERN.test(value)) || ""; if (!legacyId) return ""; legacyPlayerId.memory = legacyId; for (const storage of [global.localStorage, global.sessionStorage]) { try { storage?.setItem?.(LEGACY_PLAYER_KEY, legacyId); } catch (_) {} } return legacyId; } function clearLegacyPlayerId() { legacyPlayerId.memory = ""; for (const storage of [global.localStorage, global.sessionStorage]) { try { storage?.removeItem?.(LEGACY_PLAYER_KEY); } catch (_) {} } } function currentServerIdentity() { return storageServerIdentity(global.localStorage) || storageServerIdentity(global.sessionStorage) || normalizeServerIdentity(state?.serverIdentity) || normalizeServerIdentity(currentServerIdentity.memory); } function persistServerIdentity(identityInput, options = {}) { const identity = normalizeServerIdentity(identityInput); if (!identity) return false; currentServerIdentity.memory = identity; for (const storage of [global.localStorage, global.sessionStorage]) { try { storage?.setItem?.(PLAYER_KEY, identity.playerId); storage?.setItem?.(PLAYER_SECRET_KEY, identity.playerSecret); } catch (_) {} } const before = normalizeServerIdentity(state?.serverIdentity); const changed = !before || before.playerId !== identity.playerId || before.playerSecret !== identity.playerSecret; if (state && typeof state === "object") state.serverIdentity = { ...identity }; if (changed && options.save !== false) saveState(); return true; } function ensureServerIdentity() { // Capture the legacy bearer ID before the current UUID overwrites the old // localStorage slot. It is retained until the server confirms the claim. legacyPlayerId(); const existing = currentServerIdentity(); if (existing) { persistServerIdentity(existing, { save: false }); return existing; } const created = { playerId: createPlayerId(), playerSecret: createPlayerSecret() }; persistServerIdentity(created); return created; } function playerId() { return ensureServerIdentity().playerId; } function playerSecret() { return ensureServerIdentity().playerSecret; } function storeServerCredentials(data) { const stored = persistServerIdentity({ playerId: String(data?.playerId || "").toLowerCase(), playerSecret: String(data?.playerSecret || "").toLowerCase(), }); if (stored && data?.legacyClaimed === true) clearLegacyPlayerId(); return stored; } function unlockedCount() { return DEFINITIONS.reduce((sum, def) => sum + (state.unlocked[def.id] ? 1 : 0), 0); } function formatDate(timestamp) { const value = Number(timestamp) || 0; if (!value) return ""; try { return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(new Date(value)); } catch (_) { return new Date(value).toLocaleString(); } } function percentageValue(id) { const item = shared.achievements?.[id]; const value = Number(item?.percentage); return shared.connected && Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0; } function percentageText(id) { return shared.connected ? `${percentageValue(id).toFixed(1)}%` : "--"; } function achieverCountValue(id) { const value = Number(shared.achievements?.[id]?.unlockedPlayers); return shared.connected && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null; } function achieverCountText(id) { const value = achieverCountValue(id); return value === null ? "--" : `${value}\u4eba`; } function sharedAchievementText(id) { return `達成率 ${percentageText(id)}(達成人数 ${achieverCountText(id)})`; } function continuousPlayElapsedMs(now = Date.now()) { if (state.unlocked.continuous_play_24_hours) return CONTINUOUS_PLAY_24H_TARGET_MS; const timestamp = Number(now); if (!Number.isFinite(timestamp)) return Math.max(0, continuousPlayAccumulatedMs); const activeSegment = continuousPlayActive && !document.hidden && timestamp >= continuousPlayLastHeartbeatAt ? timestamp - continuousPlayLastHeartbeatAt : 0; return Math.max(0, continuousPlayAccumulatedMs + activeSegment); } function formatElapsed(ms = 0) { const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } function localProgressText(id) { const definition = definitionById.get(String(id || "")); const spec = definition?.progress || null; if (spec?.display) { const target = Math.max(1, Math.floor(Number(spec.target || 0) || 0)); let count = 0; if (spec.type === "counter") count = progressCounter(spec.key); else if (spec.type === "value") count = Math.max(0, Math.floor(Number(ensureProgress()[spec.key] || 0) || 0)); else if (spec.type === "unique") count = ensureProgress()[spec.key]?.length || 0; count = state.unlocked[id] ? target : Math.min(target, count); const unit = String(spec.unit || ""); if (spec.display === "percent") return `現在の達成率 ${(count / target * 100).toFixed(1)}%(${count} / ${target}${unit})`; return `現在 ${count} / ${target}${unit}`; } if (id === "elite_few") { const world = global.world || null; const dayLength = Math.max(1, Number(world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120); const now = Math.max(0, Number(world?.time || 0) || 0); const startedAt = Number(world?.achievementEliteFewStartAt); const elapsedDays = Number.isFinite(startedAt) && startedAt >= 0 && now >= startedAt ? Math.min(ELITE_FEW_TARGET_DAYS, (now - startedAt) / dayLength) : 0; const aliveCount = (world?.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0); return `現在 ${elapsedDays.toFixed(1)} / ${ELITE_FEW_TARGET_DAYS}日(個体数 ${aliveCount} / ${ELITE_FEW_MAX_POPULATION}以下)`; } if (id === "continuous_play_1_hour") { const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs()); return `現在の達成率 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%(${formatElapsed(elapsed)} / 60:00)`; } if (id === "continuous_play_24_hours") { const elapsed = Math.min(CONTINUOUS_PLAY_24H_TARGET_MS, continuousPlayElapsedMs()); return `現在の達成率 ${(elapsed / CONTINUOUS_PLAY_24H_TARGET_MS * 100).toFixed(1)}%(${formatElapsed(elapsed)} / 1440:00)`; } if (id === "enemy_enemy_friend") { const world = global.world || null; const count = state.unlocked[id] ? 10 : Math.min(10, Math.max(0, Math.floor(Number(world?.achievementEnemyAntKills || 0) || 0))); return `現在の達成率 ${(count * 10).toFixed(1)}%(${count} / 10体)`; } return ""; } function lockedDescriptionText(definition) { return PRE_UNLOCK_DESCRIPTION_IDS.has(definition.id) ? definition.description : "\uff1f\uff1f\uff1f"; } function achievementListSignature() { const unlocks = DEFINITIONS.map(definition => Number(state.unlocked[definition.id] || 0) || 0).join(","); const rates = shared.connected ? DEFINITIONS.map(definition => `${percentageValue(definition.id).toFixed(1)}:${achieverCountValue(definition.id) ?? "-"}`).join(",") : shared.loading ? "loading" : "offline"; const local = [ progressCounter("birthCount"), Math.floor(continuousPlayElapsedMs() / 1000), Math.max(0, Math.floor(Number(global.__tarinaiFps || 0) || 0)), Math.min(10, Math.max(0, Number(global.world?.achievementEnemyAntKills || 0) || 0)), progressCounter("firstAidHeals"), progressCounter("fightMochiFightCount"), progressCounter("loveMochiBirthCount"), Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)), Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0), progressCounter("statsButtonPressCount"), progressCounter("totalDeathCount"), progressCounter("manualTarinaiAddedCount"), progressCounter("directFeedCount"), progressCounter("robotCleanCount"), Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0), Math.floor(Math.max(0, (Number(global.world?.time || 0) || 0) - Math.max(0, Number(global.world?.achievementEliteFewStartAt || 0) || 0)) / Math.max(1, Number(global.world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120) * 10), ].join(":"); return `${unlocks}|${rates}|${local}`; } function createAchievementCard(definition) { const unlockedAt = Number(state.unlocked[definition.id] || 0) || 0; const rate = percentageValue(definition.id); const card = document.createElement("article"); card.className = `achievement-entry ${unlockedAt ? "unlocked" : "locked"}`; card.dataset.achievementId = definition.id; card.style.setProperty("--achievement-rate", `${rate}%`); const icon = document.createElement("div"); icon.className = "achievement-entry-icon"; icon.textContent = unlockedAt ? "\u2605" : "\u2606"; const copy = document.createElement("div"); copy.className = "achievement-entry-copy"; const heading = document.createElement("div"); heading.className = "achievement-entry-heading"; const title = document.createElement("strong"); title.textContent = definition.title; const description = document.createElement("span"); description.className = "achievement-entry-description"; description.textContent = unlockedAt ? definition.description : lockedDescriptionText(definition); heading.append(title, description); const progressText = !unlockedAt && PRE_UNLOCK_DESCRIPTION_IDS.has(definition.id) ? localProgressText(definition.id) : ""; if (progressText) { const progress = document.createElement("div"); progress.className = "achievement-entry-progress"; progress.textContent = progressText; copy.append(heading, progress); } else { copy.append(heading); } const meta = document.createElement("div"); meta.className = "achievement-entry-meta"; const status = document.createElement("span"); status.textContent = unlockedAt ? `\u9054\u6210\u6e08\u307f ${formatDate(unlockedAt)}` : ""; const percentage = document.createElement("span"); percentage.className = "achievement-entry-percentage"; percentage.textContent = sharedAchievementText(definition.id); meta.append(status, percentage); copy.append(meta); card.append(icon, copy); card.setAttribute("aria-label", `${definition.title}\u3002${description.textContent}\u3002${sharedAchievementText(definition.id)}`); return card; } function createAchievementGroup({ id, title, definitions, countText = "", open = false, emptyText = "" }) { const group = document.createElement("details"); group.className = "achievement-group"; group.dataset.achievementGroup = id; group.dataset.achievementCount = String(definitions.length); group.open = Boolean(open); const summary = document.createElement("summary"); summary.className = "achievement-group-summary"; const label = document.createElement("strong"); label.textContent = title; const count = document.createElement("span"); count.className = "achievement-group-count"; count.textContent = countText; summary.append(label, count); const body = document.createElement("div"); body.className = "achievement-group-body"; if (definitions.length) body.append(...definitions.map(createAchievementCard)); else { const empty = document.createElement("p"); empty.className = "achievement-group-empty"; empty.textContent = emptyText || "\u8a72\u5f53\u3059\u308b\u5b9f\u7e3e\u306f\u3042\u308a\u307e\u305b\u3093\u3002"; body.append(empty); } group.append(summary, body); return group; } function render(options = {}) { const count = unlockedCount(); if (dom.buttonCount) dom.buttonCount.textContent = `${count}/${DEFINITIONS.length}`; if (dom.dialogCount) dom.dialogCount.textContent = `${count} / ${DEFINITIONS.length}`; dom.button?.classList.toggle("complete", count === DEFINITIONS.length); if (dom.sharedStatus) { if (shared.loading) dom.sharedStatus.textContent = "\u5171\u6709\u9054\u6210\u7387\u3092\u78ba\u8a8d\u4e2d\u2026"; else if (shared.connected) dom.sharedStatus.textContent = `共有集計(端末報告値): ${shared.totalPlayers}人のプレイヤー`; else dom.sharedStatus.textContent = "\u5171\u6709\u9054\u6210\u7387: \u672a\u63a5\u7d9a\uff08\u30ed\u30fc\u30ab\u30eb\u5b9f\u7e3e\u306f\u5229\u7528\u53ef\u80fd\uff09"; } if (!dom.list || dom.dialog?.classList.contains("hidden")) return; const signature = achievementListSignature(); if (!options.forceList && signature === lastListSignature) return; lastListSignature = signature; const previousGroups = Array.from(dom.list.children || []); const hadPreviousGroups = previousGroups.some(group => group?.dataset?.achievementGroup); const openGroupIds = new Set(previousGroups.filter(group => group?.open).map(group => group.dataset.achievementGroup)); const previousUnlockedCounts = new Map(previousGroups.map(group => [ group?.dataset?.achievementGroup, Math.max(0, Number(group?.dataset?.achievementUnlockedCount || 0) || 0), ])); const groups = []; for (const category of ACHIEVEMENT_CATEGORIES) { const categoryDefinitions = CATEGORY_DEFINITIONS.get(category.id) || []; const unlockedDefinitions = categoryDefinitions .filter(definition => state.unlocked[definition.id]) .sort((a, b) => Number(state.unlocked[b.id] || 0) - Number(state.unlocked[a.id] || 0)); const lockedDefinitions = categoryDefinitions.filter(definition => !state.unlocked[definition.id]); const orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]; const previousUnlockedCount = previousUnlockedCounts.get(category.id) || 0; const group = createAchievementGroup({ id: category.id, title: category.title, definitions: orderedDefinitions, countText: `${unlockedDefinitions.length} / ${categoryDefinitions.length}`, open: hadPreviousGroups ? (openGroupIds.has(category.id) || unlockedDefinitions.length > previousUnlockedCount) : category.id === "ecology", }); group.dataset.achievementUnlockedCount = String(unlockedDefinitions.length); groups.push(group); } dom.list.replaceChildren(...groups); } function renderIfDialogOpen() { if (!dom.dialog?.classList.contains("hidden")) render(); } function resetUnlockToastVisual() { if (!dom.toast) return; dom.toast.classList.remove("absorbing"); dom.toast.style.removeProperty("--achievement-toast-dx"); dom.toast.style.removeProperty("--achievement-toast-dy"); } function finishUnlockToast() { dom.toast?.classList.add("hidden"); resetUnlockToastVisual(); dom.button?.classList.remove("achievement-absorb-target"); toastActive = false; global.setTimeout(runNextUnlockToast, 80); } function absorbUnlockToast() { if (!dom.toast || dom.toast.classList.contains("hidden")) { finishUnlockToast(); return; } const toastRect = dom.toast.getBoundingClientRect(); const targetRect = dom.button?.getBoundingClientRect?.(); if (!targetRect || !toastRect.width || !toastRect.height) { finishUnlockToast(); return; } const dx = targetRect.left + targetRect.width / 2 - (toastRect.left + toastRect.width / 2); const dy = targetRect.top + targetRect.height / 2 - (toastRect.top + toastRect.height / 2); dom.toast.style.setProperty("--achievement-toast-dx", `${dx}px`); dom.toast.style.setProperty("--achievement-toast-dy", `${dy}px`); dom.toast.classList.add("absorbing"); dom.button?.classList.add("achievement-absorb-target"); clearTimeout(showUnlockToast.hideTimer); showUnlockToast.hideTimer = global.setTimeout(finishUnlockToast, 760); } function runNextUnlockToast() { if (toastActive || !toastQueue.length) return; const definition = toastQueue.shift(); global.TarinaiAudio?.achievementUnlock?.(); if (!dom.toast || !dom.toastTitle) { global.showToast?.(`\u5b9f\u7e3e\u89e3\u9664: ${definition.title} \u2014 ${definition.description || ""}`); global.setTimeout(runNextUnlockToast, 80); return; } toastActive = true; clearTimeout(showUnlockToast.absorbTimer); clearTimeout(showUnlockToast.hideTimer); resetUnlockToastVisual(); dom.button?.classList.remove("achievement-absorb-target"); dom.toastTitle.textContent = definition.title; if (dom.toastCondition) dom.toastCondition.textContent = definition.description || ""; dom.toast.classList.remove("hidden"); void dom.toast.offsetWidth; showUnlockToast.absorbTimer = global.setTimeout(absorbUnlockToast, 2700); } function showUnlockToast(definition) { if (!definition) return; toastQueue.push(definition); runNextUnlockToast(); } function queuePending(id) { if (!state.pending.includes(id)) state.pending.push(id); } async function request(payload = null) { if (DEBUG_MODE || !API_URL || global.location?.protocol === "file:") throw new Error("api unavailable"); const controller = typeof AbortController === "function" ? new AbortController() : null; const timeout = global.setTimeout(() => controller?.abort(), 3500); try { const options = payload ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, playerSecret: playerSecret() }), signal: controller?.signal, cache: "no-store", } : { signal: controller?.signal, cache: "no-store" }; const url = payload ? API_URL : `${API_URL}?action=summary`; const response = await fetch(url, options); let data = null; try { data = await response.json(); } catch (_) {} if (!response.ok || !data?.ok) { const error = new Error(`api ${response.status || 0}`); error.status = Number(response.status || 0) || 0; error.code = String(data?.error || "api_rejected"); error.data = data; throw error; } storeServerCredentials(data); return data; } finally { global.clearTimeout(timeout); } } function applyShared(data) { shared = { connected: true, loading: false, totalPlayers: Math.max(0, Number(data?.totalPlayers || 0) || 0), achievements: data?.achievements && typeof data.achievements === "object" ? data.achievements : {}, statisticsTrust: String(data?.statisticsTrust || "client-reported"), }; render(); } async function refreshShared(options = {}) { const serial = ++requestSerial; shared.loading = true; render(); try { const data = options.session ? await establishSession() : await request(null); if (serial === requestSerial) applyShared(data); return true; } catch (_) { if (serial === requestSerial) { shared = { ...shared, connected: false, loading: false }; render(); } return false; } } function localUnlockSnapshot() { const unlocked = {}; for (const definition of DEFINITIONS) { const timestamp = Number(state.unlocked[definition.id] || 0) || 0; if (timestamp > 0 && !state.debugUnlocked?.includes?.(definition.id)) unlocked[definition.id] = timestamp; } return unlocked; } function localProgressSnapshot() { const normalized = normalizeState(state); return { progress: JSON.parse(JSON.stringify(normalized.progress)), ledger: JSON.parse(JSON.stringify(normalized._progressLedger)), }; } function completionistTimestamp() { let latest = 0; for (const definition of DEFINITIONS) { if (definition.id === COMPLETIONIST_ID) continue; const timestamp = Number(state.unlocked[definition.id] || 0) || 0; if (!timestamp) return 0; latest = Math.max(latest, timestamp); } return latest; } function mergeServerUnlocks(data) { const serverGenerationRaw = Number(data?.playerGeneration); const hasServerGeneration = Number.isFinite(serverGenerationRaw) && serverGenerationRaw >= 0; const serverGeneration = hasServerGeneration ? Math.floor(serverGenerationRaw) : persistenceGeneration; if (serverGeneration < persistenceGeneration) return false; let changed = false; if (serverGeneration > persistenceGeneration) { // A reset committed by another tab/device wins over every older local tab. const identity = currentServerIdentity(); persistenceGeneration = serverGeneration; persistenceRevision = 0; state = blankState(); if (identity) state.serverIdentity = { ...identity }; progressMutationRevision = 0; progressSyncedRevision = 0; changed = true; } const serverProgress = data?.playerProgress; if (serverProgress && typeof serverProgress === "object" && !Array.isArray(serverProgress)) { const beforeProgress = JSON.stringify({ progress: state.progress, ledger: state._progressLedger }); const incoming = blankState(); incoming.progress = serverProgress.progress && typeof serverProgress.progress === "object" ? serverProgress.progress : {}; incoming._progressLedger = serverProgress.ledger && typeof serverProgress.ledger === "object" ? serverProgress.ledger : {}; state = mergeStateData(state, incoming); const afterProgress = JSON.stringify({ progress: state.progress, ledger: state._progressLedger }); if (beforeProgress !== afterProgress) changed = true; } const source = data?.playerUnlocked; if (source && typeof source === "object" && !Array.isArray(source)) { const serverIds = []; for (const [id, serverTimestamp] of Object.entries(source)) { if (!definitionById.has(id) || state.debugUnlocked?.includes?.(id)) continue; const seconds = Number(serverTimestamp); const serverTime = Number.isFinite(seconds) && seconds > 0 ? Math.max(1, Math.floor(seconds * 1000)) : Date.now(); const localTime = Number(state.unlocked[id] || 0) || 0; const sameSecond = localTime > 0 && Math.floor(localTime / 1000) === Math.floor(serverTime / 1000); const mergedTime = localTime > 0 ? (sameSecond ? localTime : Math.min(localTime, serverTime)) : serverTime; if (localTime !== mergedTime) { state.unlocked[id] = mergedTime; changed = true; } serverIds.push(id); } const confirmedSet = new Set(serverIds); const beforePending = state.pending.length; state.pending = state.pending.filter(id => !confirmedSet.has(id)); if (state.pending.length !== beforePending) changed = true; } const completionTime = completionistTimestamp(); if (completionTime > 0) { const current = Number(state.unlocked[COMPLETIONIST_ID] || 0) || 0; const merged = current > 0 ? Math.max(current, completionTime) : completionTime; if (current !== merged) { state.unlocked[COMPLETIONIST_ID] = merged; changed = true; } } if (changed) { saveState(); render(); } return changed; } async function establishSession(options = {}) { const legacyId = legacyPlayerId(); const payload = { action: "session", playerId: playerId(), gameVersion: GAME_VERSION, generation: persistenceGeneration, }; if (legacyId) payload.legacyPlayerId = legacyId; const data = await request(payload); if (options.merge !== false) mergeServerUnlocks(data); return data; } async function performSyncPending(options = {}) { let latest = null; const writeWithIdentityRecovery = async (payload, recoveryOptions = {}) => { try { const data = await request(payload); mergeServerUnlocks(data); return data; } catch (error) { const status = Number(error?.status || 0); const code = String(error?.code || ""); if (status === 409 && code === "stale_generation") { // A reset from another device is authoritative. Merge it, but never // replay this request's older-generation unlock/progress snapshot. error.recoveredData = await establishSession({ merge: true }); throw error; } if (status === 409 && code === "generation_ahead") { // Compatibility recovery for servers that require an explicit full // generation promotion after a backup restore or identity recreation. const data = await request({ action: "sync", playerId: playerId(), gameVersion: GAME_VERSION, generation: persistenceGeneration, generationRecovery: true, syncProtocol: SYNC_PROTOCOL_VERSION, mergeMode: "grow-only-union-v5", unlocked: localUnlockSnapshot(), progressState: localProgressSnapshot(), }); mergeServerUnlocks(data); return data; } const recoverableIdentity = status === 403 && ["unknown_player", "invalid_owner"].includes(code); if (!recoverableIdentity) throw error; const sessionData = await establishSession({ merge: true }); if (recoveryOptions.sessionOnly) return sessionData; if (payload.action === "reset" && !state.resetPending) return sessionData; const retryPayload = payload.action === "sync" ? { action: "sync", playerId: playerId(), gameVersion: GAME_VERSION, generation: persistenceGeneration, generationRecovery: true, syncProtocol: SYNC_PROTOCOL_VERSION, mergeMode: "grow-only-union-v5", unlocked: localUnlockSnapshot(), progressState: localProgressSnapshot(), } : { ...payload, playerId: playerId(), generation: persistenceGeneration, generationRecovery: true, syncProtocol: SYNC_PROTOCOL_VERSION, }; const data = await request(retryPayload); mergeServerUnlocks(data); return data; } }; try { if (state.resetPending) { latest = await writeWithIdentityRecovery({ action: "reset", playerId: playerId(), gameVersion: GAME_VERSION, confirmReset: "RESET_ACHIEVEMENTS", catalogRevision: ACHIEVEMENT_CATALOG_REVISION, generation: persistenceGeneration, syncProtocol: SYNC_PROTOCOL_VERSION, }, { mergeSession: false }); state.resetPending = false; saveState(); } // Unlocks and progress are uploaded as one generation-scoped CRDT // snapshot. The server unions actor shards, set-like progress and the // earliest unlock timestamps, then returns the atomic merged state. const snapshot = localUnlockSnapshot(); const progressSnapshot = localProgressSnapshot(); const progressRevisionAtStart = progressMutationRevision; const pendingIds = [...new Set(state.pending.filter(id => definitionById.has(id) && snapshot[id]))]; const unlocked = options.full ? snapshot : Object.fromEntries(pendingIds.map(id => [id, snapshot[id]])); const syncedIds = Object.keys(unlocked); const hasProgressChanges = progressRevisionAtStart > progressSyncedRevision; if (options.full || syncedIds.length || hasProgressChanges) { latest = await writeWithIdentityRecovery({ action: "sync", playerId: playerId(), gameVersion: GAME_VERSION, unlocked, progressState: progressSnapshot, mergeMode: "grow-only-union-v5", syncProtocol: SYNC_PROTOCOL_VERSION, generation: persistenceGeneration, generationRecovery: true, }, { mergeSession: false }); const confirmedSet = new Set(syncedIds); state.pending = state.pending.filter(id => !confirmedSet.has(id)); progressSyncedRevision = Math.max(progressSyncedRevision, progressRevisionAtStart); saveState(); } else if (!latest) { return refreshShared({ session: true }); } if (latest) applyShared(latest); return true; } catch (error) { if (error?.recoveredData) { applyShared(error.recoveredData); return true; } shared = { ...shared, connected: false, loading: false }; render(); return false; } } function syncPending(options = {}) { const normalized = { full: Boolean(options.full) }; const run = async () => { await persistenceReadyPromise; return performSyncPending(normalized); }; syncQueue = syncQueue.then(run, run); return syncQueue; } function schedulePendingSync() { if (scheduledSyncTimer) global.clearTimeout(scheduledSyncTimer); scheduledSyncTimer = global.setTimeout(() => { scheduledSyncTimer = 0; void syncPending(); }, SYNC_DEBOUNCE_MS); } function allOtherAchievementsUnlocked() { return DEFINITIONS.every(definition => definition.id === COMPLETIONIST_ID || Boolean(state.unlocked[definition.id])); } function unlock(id, detail = {}) { const definition = definitionById.get(String(id || "")); if (!definition || state.unlocked[definition.id]) return false; const now = Date.now(); const unlockedNow = [{ definition, detail }]; state.unlocked[definition.id] = now; if (definition.id !== COMPLETIONIST_ID && !state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) { const completionist = definitionById.get(COMPLETIONIST_ID); state.unlocked[COMPLETIONIST_ID] = now; unlockedNow.push({ definition: completionist, detail: { source: "all-achievements", trigger: definition.id } }); } for (const entry of unlockedNow) { if (DEBUG_MODE) { if (!Array.isArray(state.debugUnlocked)) state.debugUnlocked = []; if (!state.debugUnlocked.includes(entry.definition.id)) state.debugUnlocked.push(entry.definition.id); } else { queuePending(entry.definition.id); } } saveState(); render({ forceList: true }); for (const entry of unlockedNow) { showUnlockToast(entry.definition); global.TarinaiEvents?.emit?.("achievement:unlocked", { achievement: entry.definition, detail: entry.detail }); } if (!DEBUG_MODE) schedulePendingSync(); return true; } function runtimeTracker(world, now = 0) { if (!world || (typeof world !== "object" && typeof world !== "function")) return null; let tracker = runtimeByWorld.get(world); if (!tracker) { tracker = { lastTime: Number(now) || 0, birthTimes: [], deathTimes: [], }; runtimeByWorld.set(world, tracker); } const time = Number(now) || 0; if (time + 0.001 < tracker.lastTime) { tracker.birthTimes = []; tracker.deathTimes = []; } tracker.lastTime = time; return tracker; } function recordNaturalGeneration(world, tarinai, depthKey, required) { if (!world || !tarinai) return 0; const parentIds = Array.isArray(tarinai.parents) ? tarinai.parents.filter(Boolean) : []; let parentDepth = 0; for (const parentId of parentIds) { const parent = (world.tarinai || []).find(candidate => candidate && String(candidate.familyKey || candidate.id || "") === String(parentId)); parentDepth = Math.max(parentDepth, Math.max(0, Math.floor(Number(parent?.[depthKey] || 0) || 0))); } tarinai[depthKey] = Math.max(1, parentDepth + 1); return tarinai[depthKey] >= required ? tarinai[depthKey] : 0; } function detailWorld(detail = {}) { return detail.world || detail.item?.world || detail.tarinai?.world || detail.ant?.world || global.world || null; } function recordIntervention(detail = {}) { const world = detailWorld(detail); if (!world) return false; world.achievementLastInterventionAt = Math.max(0, Number(world.time || 0) || 0); return true; } function ensureProgress() { if (!state.progress || typeof state.progress !== "object") state.progress = blankState().progress; if (!Array.isArray(state.progress.linkTypes)) state.progress.linkTypes = []; state.progress.linkTypes = [...new Set(state.progress.linkTypes.filter(type => LINK_PROGRESS_TYPES.includes(type)))]; state.progress.signalActivated = Boolean(state.progress.signalActivated); 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.dailyPlayStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress.dailyPlayStreak || 0) || 0))); state.progress.dailyPlayLastDay = Math.max(0, Math.floor(Number(state.progress.dailyPlayLastDay || 0) || 0)); if (!Array.isArray(state.progress.mysteryDrugTarinaiIds)) state.progress.mysteryDrugTarinaiIds = []; state.progress.mysteryDrugTarinaiIds = [...new Set(state.progress.mysteryDrugTarinaiIds.map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET); return state.progress; } function ensureProgressLedger() { state._progressLedger = sanitizeProgressLedger(state, state.progress || {}); return state._progressLedger; } function progressCounter(key) { return ledgerCounterValue(ensureProgressLedger(), key); } function raiseProgressCounter(key, minimumValue = 0) { if (!Object.prototype.hasOwnProperty.call(ADDITIVE_PROGRESS_CAPS, key)) return 0; const cap = ADDITIVE_PROGRESS_CAPS[key]; const ledger = ensureProgressLedger(); const current = ledgerCounterValue(ledger, key); const target = Math.min(cap, Math.max(0, Math.floor(Number(minimumValue || 0) || 0))); if (target <= current) return current; ledger.base[key] = Math.min(cap, Math.max(0, Math.floor(Number(ledger.base[key] || 0) || 0)) + (target - current)); if (target >= cap) { ledger.base[key] = cap; ledger.shards[key] = {}; } markProgressMutation(); return target; } function incrementProgressCounter(key, amount = 1) { if (!Object.prototype.hasOwnProperty.call(ADDITIVE_PROGRESS_CAPS, key)) return 0; const ledger = ensureProgressLedger(); const cap = ADDITIVE_PROGRESS_CAPS[key]; const current = ledgerCounterValue(ledger, key); const increment = Math.min(Math.max(0, Math.floor(Number(amount || 0) || 0)), Math.max(0, cap - current)); if (increment <= 0) return current; const shards = ledger.shards[key] || (ledger.shards[key] = {}); shards[persistenceActorId] = Math.max(0, Math.floor(Number(shards[persistenceActorId] || 0) || 0)) + increment; if (current + increment >= cap) { ledger.base[key] = cap; ledger.shards[key] = {}; } markProgressMutation(); return current + increment; } function unlockedIndexesFromMaskWords(lowWord = 0, highWord = 0, extraWord = 0) { const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(lowWord) || 0))); const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(highWord) || 0))); const extraMax = (2 ** Math.max(0, DEFINITIONS.length - 64)) - 1; const extra = Math.max(0, Math.min(extraMax, Math.floor(Number(extraWord) || 0))); const indexes = []; for (let index = 0; index < DEFINITIONS.length; index += 1) { const word = index < 32 ? low : (index < 64 ? high : extra); const bit = index < 32 ? index : (index < 64 ? index - 32 : index - 64); if (Math.floor(word / (2 ** bit)) % 2) indexes.push(index); } return indexes; } function exportSpellState() { let lowWord = 0; let highWord = 0; let extraWord = 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 if (index < 64) highWord += 2 ** (index - 32); else extraWord += 2 ** (index - 64); 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, extraWord >>> 0, baseTimestamp, deltas, [ state.unlocked.placed_objects_100 ? 0 : Math.min(100, progressCounter("placementCount")), state.unlocked.mechanized_industry ? 0 : linkFlags, state.unlocked.ants_killed_100 ? 0 : Math.min(100, progressCounter("antKills")), state.unlocked.great_mother_1000_births ? 0 : Math.min(3333, progressCounter("birthCount")), state.unlocked.unplanned_city_30 ? 0 : Math.min(30, progressCounter("quickDeleteCount")), state.unlocked.undo_20 ? 0 : Math.min(20, progressCounter("undoCount")), state.unlocked.redo_20 ? 0 : Math.min(20, progressCounter("redoCount")), state.unlocked.medicine_ledger_all ? 0 : medicineMask, state.unlocked.town_doctor_50 ? 0 : Math.min(50, progressCounter("firstAidHeals")), state.unlocked.chaos_seeker_666_fights ? 0 : Math.min(CHAOS_FIGHT_TARGET, progressCounter("fightMochiFightCount")), state.unlocked.sniper_333_shots ? 0 : Math.min(SNIPER_SHOT_TARGET, progressCounter("shotCount")), state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progressCounter("loveMochiBirthCount")), state.unlocked.daily_play_7_days ? 0 : Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak), state.unlocked.daily_play_7_days ? 0 : Math.min(131071, progress.dailyPlayLastDay), state.unlocked.statistician ? 0 : Math.min(STATISTICIAN_TARGET, progressCounter("statsButtonPressCount")), state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progressCounter("totalDeathCount")), state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progressCounter("manualTarinaiAddedCount")), state.unlocked.mad_scientist ? 0 : Math.min(MAD_SCIENTIST_TARGET, progress.mysteryDrugTarinaiIds.length), state.unlocked.direct_feed_33 ? 0 : Math.min(33, progressCounter("directFeedCount")), state.unlocked.robot_cleaner_100 ? 0 : Math.min(ROBOT_CLEAN_TARGET, progressCounter("robotCleanCount")), ], state.unlocked.mad_scientist ? [] : progress.mysteryDrugTarinaiIds.slice(0, MAD_SCIENTIST_TARGET), ]; } function parseSpellState(payload = null) { if (!Array.isArray(payload)) return null; const version = Number(payload[0]); if (![9, 10, SPELL_STATE_VERSION].includes(version)) return null; const lowWord = Number(payload[1]); const highWord = Number(payload[2]); const extraWord = Number(payload[3]); const extraMax = (2 ** Math.max(0, DEFINITIONS.length - 64)) - 1; if (!Number.isSafeInteger(lowWord) || lowWord < 0 || lowWord > 0xffffffff || !Number.isSafeInteger(highWord) || highWord < 0 || highWord > 0xffffffff || !Number.isSafeInteger(extraWord) || extraWord < 0 || extraWord > extraMax) { throw new Error("invalid achievement spell mask"); } return { unlockedIndexes: unlockedIndexesFromMaskWords(lowWord, highWord, extraWord), baseTimestamp: Math.max(0, Math.floor(Number(payload[4] || 0) || 0)), deltas: Array.isArray(payload[5]) ? payload[5] : [], packed: Array.isArray(payload[6]) ? payload[6] : [], mysteryDrugIds: version >= 10 && Array.isArray(payload[7]) ? [...new Set(payload[7].map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET) : [], version, timestampUnitMs: 60000, }; } function importSpellState(payload = null) { const parsed = parseSpellState(payload); if (!parsed) return { included: false, unlockedAdded: 0, progressChanged: false }; const { unlockedIndexes, baseTimestamp, deltas, packed, mysteryDrugIds, version, timestampUnitMs } = parsed; if (deltas.length !== unlockedIndexes.length) throw new Error("invalid achievement spell timestamps"); const importedUnlocks = []; let importedCompletionistAt = 0; 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, ledger: ensureProgressLedger() }); raiseProgressCounter("placementCount", packed[0]); const linkFlags = Math.max(0, Math.floor(Number(packed[1] || 0) || 0)); const importedLinks = []; if (linkFlags & 1) importedLinks.push("rope"); 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)); raiseProgressCounter("antKills", packed[2]); raiseProgressCounter("birthCount", packed[3]); raiseProgressCounter("quickDeleteCount", packed[4]); raiseProgressCounter("undoCount", packed[5]); raiseProgressCounter("redoCount", packed[6]); const medicineMask = Math.max(0, Math.floor(Number(packed[7] || 0) || 0)); const importedMedicines = MEDICINE_LEDGER_TYPES.filter((_type, index) => medicineMask & (1 << index)); progress.medicineTypes = [...new Set([...(progress.medicineTypes || []), ...importedMedicines])]; raiseProgressCounter("firstAidHeals", packed[8]); raiseProgressCounter("fightMochiFightCount", packed[9]); raiseProgressCounter("shotCount", packed[10]); raiseProgressCounter("loveMochiBirthCount", packed[11]); const importedDailyStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(packed[12] || 0) || 0))); const importedDailyLastDay = Math.min(131071, Math.max(0, Math.floor(Number(packed[13] || 0) || 0))); if (importedDailyLastDay > progress.dailyPlayLastDay) { progress.dailyPlayLastDay = importedDailyLastDay; progress.dailyPlayStreak = importedDailyStreak; } else if (importedDailyLastDay === progress.dailyPlayLastDay) { progress.dailyPlayStreak = Math.max(progress.dailyPlayStreak, importedDailyStreak); } raiseProgressCounter("statsButtonPressCount", packed[14]); raiseProgressCounter("totalDeathCount", packed[15]); raiseProgressCounter("manualTarinaiAddedCount", packed[16]); if (version >= 10 && mysteryDrugIds.length) { progress.mysteryDrugTarinaiIds = [...new Set([...(progress.mysteryDrugTarinaiIds || []), ...mysteryDrugIds])].slice(0, MAD_SCIENTIST_TARGET); } raiseProgressCounter("directFeedCount", packed[18]); raiseProgressCounter("robotCleanCount", packed[19]); const progressChanged = previousProgress !== JSON.stringify({ progress, ledger: ensureProgressLedger() }); if (progressChanged) markProgressMutation(); changed = changed || progressChanged; if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) { 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) recordIntervention({ ...detail, world, tool: detail.tool || "placement" }); if (!world) return false; recordIntervention({ ...detail, world }); const item = detail.item || null; if (item) { if (!item._achievementPlayerPlaced || item._achievementPlayerPlacedCounted === false) world.achievementPlayerPlacedActiveCount = Math.max(0, Number(world.achievementPlayerPlacedActiveCount || 0) || 0) + 1; item._achievementPlayerPlaced = true; item._achievementPlayerPlacedCounted = true; item._achievementPlacedAt = Math.max(0, Number(world.time || 0) || 0); } let changed = evaluateCleanFreak(world, detail); changed = evaluateMegalopolis(world, detail) || changed; changed = evaluateSandbox(world, detail) || changed; if (!state.unlocked.placed_objects_100) { const count = incrementProgressCounter("placementCount"); saveState(); renderIfDialogOpen(); if (count >= ADDITIVE_PROGRESS_CAPS.placementCount) changed = unlock("placed_objects_100", { ...detail, world, count }) || changed; } return changed; } function recordPlayerDeletion(detail = {}) { const world = detailWorld(detail); 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; const placedAt = placedAtRaw == null ? NaN : Number(placedAtRaw); if (!Number.isFinite(placedAt) || now < placedAt || now - placedAt > 30.0001) return changed; if (!state.unlocked.unplanned_city_30) { const operationKey = detail.operationId == null ? "" : String(detail.operationId); if (operationKey && world._achievementLastQuickDeleteOperation === operationKey) return changed; if (operationKey) world._achievementLastQuickDeleteOperation = operationKey; const count = incrementProgressCounter("quickDeleteCount"); saveState(); renderIfDialogOpen(); if (count >= ADDITIVE_PROGRESS_CAPS.quickDeleteCount) changed = unlock("unplanned_city_30", { ...detail, world, count }) || changed; } return changed; } function recordHistoryAction(kind, detail = {}) { const action = String(kind || detail.kind || ""); if (action !== "undo" && action !== "redo") return false; const world = detailWorld(detail); if (world) recordIntervention({ ...detail, world, tool: action }); const key = action === "undo" ? "undoCount" : "redoCount"; const achievementId = action === "undo" ? "undo_20" : "redo_20"; const count = incrementProgressCounter(key); saveState(); renderIfDialogOpen(); let changed = false; if (action === "undo" && Math.max(0, Number(detail.deadRestored || 0) || 0) >= 10) { changed = unlock("undo_mass_revival", { ...detail, count: Math.max(0, Number(detail.deadRestored || 0) || 0) }) || changed; } if (count >= ADDITIVE_PROGRESS_CAPS[key]) changed = unlock(achievementId, { ...detail, count }) || changed; return changed; } function mechanizedIndustryHasAllLinks(world = global.world) { if (!world) return false; return ["rope", "rod", "spring"].every(required => { const bucket = world.itemsOfType?.(required); if (Array.isArray(bucket)) return bucket.some(item => item && !item.dead); return (world.items || []).some(item => item && !item.dead && item.type === required); }); } function evaluateMechanizedIndustry(detail = {}) { if (state.unlocked.mechanized_industry) return false; const world = detailWorld(detail); const source = detail.source || null; const detectorDriven = Boolean(detail.detectorDriven) || source?.type === "pressure_switch" || (Array.isArray(detail.detectorSources) && detail.detectorSources.some(candidate => candidate?.type === "pressure_switch")); const connectedTarget = Boolean(detail.connected) && detail.target && !detail.target.dead; if (world && detectorDriven && connectedTarget && mechanizedIndustryHasAllLinks(world)) { return unlock("mechanized_industry", { ...detail, world }); } return false; } function recordLinkPlaced(type, detail = {}) { const linkType = String(type || detail.type || detail.item?.type || ""); const placementLinkTypes = ["rope", "rod", "spring", "wire", "insulated_wire"]; if (!placementLinkTypes.includes(linkType)) return false; return recordPlayerPlacement(detail); } function recordSignalActivation(detail = {}) { return evaluateMechanizedIndustry(detail); } function recordAntPopulation(worldRef = global.world) { if (!worldRef || state.unlocked.ants_alive_25) return false; const aliveAnts = (worldRef.ants || []).reduce((count, ant) => count + (ant && !ant.dead ? 1 : 0), 0); if (aliveAnts >= 25) return unlock("ants_alive_25", { world: worldRef, count: aliveAnts }); return false; } function evaluateAntNestWithoutTarinai(worldRef = global.world, detail = {}) { if (!worldRef || state.unlocked.ant_nest_without_tarinai) return false; const hasNest = playstyleItemCount(worldRef, "ant_nest") > 0; if (!hasNest) return false; const aliveTarinai = (worldRef.tarinai || []).some(tarinai => tarinai && !tarinai.dead); if (aliveTarinai) return false; return unlock("ant_nest_without_tarinai", { ...detail, world: worldRef }); } function recordStickyBombPass(item = null, detail = {}) { if (!item || item.dead || item.type !== "sticky_bomb" || state.unlocked.sticky_bomb_15_passes) return false; item.stickyBombPassCount = Math.max(0, Math.floor(Number(item.stickyBombPassCount || 0) || 0)) + 1; if (item.stickyBombPassCount < 15) return false; return unlock("sticky_bomb_15_passes", { ...detail, item, count: item.stickyBombPassCount }); } function recordCircuitBoardSignalTransfer(sourceBoard = null, targetBoard = null, wire = null, detail = {}) { if (state.unlocked.information_industry) return false; if (!sourceBoard || !targetBoard || sourceBoard === targetBoard) return false; if (sourceBoard.type !== "circuit_board" || targetBoard.type !== "circuit_board") return false; if (!wire || !["wire", "insulated_wire"].includes(wire.type) || !wire.signalActive) return false; return unlock("information_industry", { ...detail, sourceBoard, targetBoard, wire }); } function recordWirePowerState(wire = null, active = false) { if (!wire || (typeof wire !== "object" && typeof wire !== "function")) return false; if (!active) return false; if (!wireShockTrackers.has(wire)) wireShockTrackers.set(wire, { targets: new WeakSet(), count: 0 }); return true; } function recordWireShock(wire = null, target = null, detail = {}) { if (state.unlocked.wire_shock_7_tarinai || !wire || !target || wire.type !== "wire" || !wire.signalActive) return false; const world = detail.world || wire.world || target.world || global.world || null; if (!world || !(world.tarinai || []).includes(target) || target.dead) return false; recordWirePowerState(wire, true); const tracker = wireShockTrackers.get(wire); if (!tracker || tracker.targets.has(target)) return false; tracker.targets.add(target); tracker.count += 1; if (tracker.count < WIRE_SHOCK_TARGET) return false; return unlock("wire_shock_7_tarinai", { ...detail, world, wire, target, count: tracker.count }); } function recordAntKilled(detail = {}) { let changed = false; const playerCaused = Boolean(detail.playerCaused || detail.ant?._achievementLastDamageSource?.danger); if (!state.unlocked.ants_killed_100 && playerCaused) { const count = incrementProgressCounter("antKills"); saveState(); renderIfDialogOpen(); if (count >= ADDITIVE_PROGRESS_CAPS.antKills) changed = unlock("ants_killed_100", { ...detail, count }) || changed; } const world = detailWorld(detail); const ant = detail.ant || null; const now = Math.max(0, Number(world?.time || 0) || 0); const aliveCount = (world?.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0); if (world && aliveCount < 30) world.achievementEnemyAntKills = 0; if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30) { world.achievementEnemyAntKills = Math.min(10, Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) + 1); renderIfDialogOpen(); if (world.achievementEnemyAntKills >= 10) changed = unlock("enemy_enemy_friend", { ...detail, world, count: world.achievementEnemyAntKills }) || changed; } return changed; } function dangerItemSource(source = null) { if (!source) return null; const configured = global.TarinaiItemToolMetadata?.TOOL_CATEGORIES ?.find?.(category => category?.id === "hazard")?.toolIds; const hazardTypes = new Set(Array.isArray(configured) ? configured : ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"]); if (source.type && hazardTypes.has(String(source.type))) return source; const inherited = source._achievementDangerSource || null; return inherited?.type && hazardTypes.has(String(inherited.type)) ? inherited : null; } function recordDamageSource(tarinai, source = null, detail = {}) { if (!tarinai) return false; const world = detail.world || tarinai.world || global.world || null; const now = Math.max(0, Number(world?.time || 0) || 0); const dangerSource = dangerItemSource(source); // "Unharmed" means no actual damage from any cause while the streak is active. if (world) { if (Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) > 0) { world.achievementEnemyAntKills = 0; renderIfDialogOpen(); } } tarinai._achievementLastDamageSource = dangerSource ? { danger: true, at: now, itemId: String(dangerSource.id || ""), itemType: String(dangerSource.type || ""), } : { danger: false, at: now, itemId: "", itemType: "" }; return Boolean(dangerSource); } function recordAntDamageSource(ant, source = null, detail = {}) { if (!ant) return false; const world = detail.world || ant.world || global.world || null; const now = Math.max(0, Number(world?.time || 0) || 0); const dangerSource = dangerItemSource(source); ant._achievementLastDamageSource = dangerSource ? { danger: true, at: now, itemId: String(dangerSource.id || ""), itemType: String(dangerSource.type || ""), } : { danger: false, at: now, itemId: "", itemType: "" }; return Boolean(dangerSource); } function fightPairTracker(world) { if (!world || (typeof world !== "object" && typeof world !== "function")) return null; let tracker = dangerFightDeathsByWorld.get(world); if (!tracker) { tracker = new Map(); dangerFightDeathsByWorld.set(world, tracker); } return tracker; } function recordFightPairDangerDeath(detail = {}) { if (state.unlocked.fight_pair_danger_kill) return false; const tarinai = detail.tarinai || null; const world = detail.world || tarinai?.world || null; if (!tarinai || !world) return false; const now = Math.max(0, Number(world.time || 0) || 0); const source = tarinai._achievementLastDamageSource || null; const lethalDanger = Boolean(detail.fromDamage || detail.achievementDangerKill); const session = tarinai._achievementFightSession || null; if (!lethalDanger || !source?.danger || Math.abs(now - Number(source.at || 0)) > 0.05) return false; if (!session?.id || !session.opponentId || Number(tarinai.fightTimer || 0) <= 0.04) return false; const tracker = fightPairTracker(world); if (!tracker) return false; const maxGap = 6; for (const [key, pending] of [...tracker.entries()]) { if (!pending || now - Number(pending.at || 0) > maxGap) { tracker.delete(key); continue; } if (pending.sessionId === session.id && pending.firstId !== tarinai.id && pending.otherId === tarinai.id && session.opponentId === pending.firstId) { tracker.delete(key); return unlock("fight_pair_danger_kill", { ...detail, pairKey: key, fightSessionId: session.id, first: pending, secondSource: source }); } } const opponent = (world.tarinai || []).find(candidate => candidate && candidate.id === session.opponentId) || null; if (!opponent || opponent === tarinai || opponent.dead) return false; const opponentSession = opponent._achievementFightSession || null; if (opponentSession?.id !== session.id || opponentSession.opponentId !== tarinai.id) return false; const key = String(session.pairKey || world.fightPairKey?.(tarinai, opponent) || ""); if (!key) return false; tracker.set(key, { firstId: tarinai.id, otherId: opponent.id, at: now, source, sessionId: session.id, }); return false; } function recordSaveSlotsFilled(detail = {}) { if (state.unlocked.secret_collection_9_slots) return false; const storage = global.TarinaiSaveStorage; const slotCount = Math.max(0, Math.floor(Number(storage?.SLOT_COUNT || 9) || 9)); let filled = Number(detail.filled); if (!Number.isFinite(filled)) { filled = 0; for (let slot = 1; slot <= slotCount; slot += 1) { try { if (storage?.readSlot?.(slot)?.hash) filled += 1; } catch (_) {} } } if (slotCount >= 9 && filled >= 9) return unlock("secret_collection_9_slots", { ...detail, filled, slotCount }); return false; } function recordEmptyClick(detail = {}) { const world = detailWorld(detail); if (world) recordIntervention({ ...detail, world, tool: "empty_click" }); if (state.unlocked.pause_spam_4_in_1_second) return false; const now = Number(detail.now); const timestamp = Number.isFinite(now) ? now : Date.now(); emptyClickTimes = emptyClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value); emptyClickTimes.push(timestamp); if (emptyClickTimes.length >= 4) { emptyClickTimes = []; return unlock("pause_spam_4_in_1_second", { ...detail, count: 4, windowMs: 1000 }); } return false; } function recordGroundChange(detail = {}) { const world = detailWorld(detail); const previous = String(detail.previous || ""); const next = String(detail.next || world?.groundType || ""); if (!world || detail.silent || !previous || !next || previous === next) return false; recordIntervention({ ...detail, world, tool: "ground_change" }); let changed = false; if (!state.unlocked.park_ground_changed && String(world.fieldType || "") === "park") { changed = unlock("park_ground_changed", { ...detail, world, previous, next }) || changed; } if (!state.unlocked.ground_change_4_in_1_second) { const now = Number(detail.now); const timestamp = Number.isFinite(now) ? now : Date.now(); groundChangeTimes = groundChangeTimes.filter(value => timestamp - value <= 1000 && timestamp >= value); groundChangeTimes.push(timestamp); if (groundChangeTimes.length >= 4) { groundChangeTimes = []; changed = unlock("ground_change_4_in_1_second", { ...detail, world, count: 4, windowMs: 1000 }) || changed; } } return changed; } function evaluateSandbox(world = global.world, detail = {}) { if (!world || state.unlocked.sandbox_five_toilets) return false; const count = playstyleItemCount(world, "toilet"); if (count < 5) return false; return unlock("sandbox_five_toilets", { ...detail, world, count }); } function recordLowFps(detail = {}) { if (state.unlocked.low_fps_single_digit) return false; const fps = Number.isFinite(Number(detail.fps)) ? Number(detail.fps) : Number(global.__tarinaiFps); if (!Number.isFinite(fps) || fps <= 0 || fps >= 10) return false; return unlock("low_fps_single_digit", { ...detail, fps }); } function localDayNumber(value = Date.now()) { const date = new Date(Number(value)); if (!Number.isFinite(date.getTime())) return 0; return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS); } function recordDailyPlay(detail = {}) { if (state.unlocked.daily_play_7_days) return false; const today = localDayNumber(Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now()); if (!today) return false; const progress = ensureProgress(); const previousDay = progress.dailyPlayLastDay; if (today < previousDay || today === previousDay) return false; progress.dailyPlayStreak = today === previousDay + 1 ? Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak + 1) : 1; progress.dailyPlayLastDay = today; markProgressMutation(); saveState(); renderIfDialogOpen(); if (progress.dailyPlayStreak >= DAILY_PLAY_TARGET) { return unlock("daily_play_7_days", { ...detail, day: today, streak: progress.dailyPlayStreak }); } return false; } function recordContinuousPlay(detail = {}) { if (state.unlocked.continuous_play_1_hour && state.unlocked.continuous_play_24_hours) return false; const explicitElapsed = Number(detail.elapsedMs); let elapsedMs; if (Number.isFinite(explicitElapsed)) { elapsedMs = Math.max(0, explicitElapsed); } else { const timestamp = Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now(); elapsedMs = continuousPlayElapsedMs(timestamp); } let changed = false; if (!state.unlocked.continuous_play_1_hour && elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) { changed = unlock("continuous_play_1_hour", { ...detail, elapsedMs }) || changed; } if (!state.unlocked.continuous_play_24_hours && elapsedMs >= CONTINUOUS_PLAY_24H_TARGET_MS) { changed = unlock("continuous_play_24_hours", { ...detail, elapsedMs }) || changed; } return changed; } function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) { if (state.unlocked.below_absolute_zero_item || !worldRef) return false; worldRef.ensureItemBuckets?.("achievement-absolute-zero"); const testItem = (item) => { if (!item || item.dead) return false; const temperature = Number(worldRef.temperatureAt?.(item.x, item.y, null)); if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) return false; return unlock("below_absolute_zero_item", { ...detail, world: worldRef, item, temperature }); }; // Evaluate the actual temperature field for every live item. This avoids // coupling the achievement to a hard-coded list of present-day heat sources. for (const item of worldRef.items || []) { if (testItem(item)) return true; } for (const residue of worldRef.residues || []) { if (testItem(residue)) return true; } return false; } function importWorldAchievementProgress(detail = {}) { const imported = Math.min(ROBOT_CLEAN_TARGET, Math.max(0, Math.floor(Number(detail.robotCleanCount || 0) || 0))); const current = progressCounter("robotCleanCount"); if (imported <= current) return false; raiseProgressCounter("robotCleanCount", imported); saveState(); renderIfDialogOpen(); return true; } function recordRobotClean(detail = {}) { if (state.unlocked.robot_cleaner_100) return false; const world = detailWorld(detail); const type = String(detail.type || detail.target?.type || ""); if (!world || !["zunchi", "ant_corpse", "trace", "splat"].includes(type)) return false; const count = incrementProgressCounter("robotCleanCount"); if (count >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count }); saveState(); renderIfDialogOpen(); return false; } function robotCleanerIsRunning(robot) { if (!robot || robot.dead || robot.type !== "robot_cleaner") return false; return !robot._signalDriven || Boolean(robot._signalOn); } function evaluateCleanFreak(world = global.world, detail = {}) { if (!world || state.unlocked.clean_freak_robot_only) return false; const activeObjects = Math.max(0, Number(world.activeObjectCount?.() ?? (world.items || []).reduce((count, item) => count + (item && !item.dead ? 1 : 0), 0)) || 0); const robotCount = Math.max(0, Number(world.itemCounts?.robot_cleaner || 0) || 0); if (!activeObjects || robotCount !== activeObjects) return false; const liveItems = world.itemsOfType?.("robot_cleaner") || []; if ((world.tarinai || []).some(tarinai => tarinai && !tarinai.dead)) return false; if ((world.ants || []).some(ant => ant && !ant.dead)) return false; if ((world.residues || []).some(residue => residue && !residue.dead && Math.max(0, Number(residue.amount ?? 1) || 0) > 0)) return false; const qualifies = liveItems.some(robot => { const mask = Number(global.TarinaiItemDynamicToolSystem?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? 0) | 0; return (mask & ROBOT_ALL_TARGET_MASK) === ROBOT_ALL_TARGET_MASK && Boolean(global.TarinaiItemDynamicToolSystem?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed) && robotCleanerIsRunning(robot); }); if (!qualifies) return false; return unlock("clean_freak_robot_only", { ...detail, world, robotCount: liveItems.length }); } function evaluateStatusEffectAchievements(tarinai, detail = {}) { if (!tarinai || tarinai.dead) return false; let changed = false; if (!state.unlocked.king_full_satisfaction && tarinai.isTarinaiChampion && tarinai.powerItemMode === "protein" && tarinai.sizeItemMode === "giant_drug") { changed = unlock("king_full_satisfaction", { ...detail, tarinai, world: detail.world || tarinai.world || null }) || changed; } if (!state.unlocked.slave_zero_satisfaction && tarinai.isZunchiSlave && tarinai.powerItemMode === "niteropu" && tarinai.sizeItemMode === "dwarf_drug") { changed = unlock("slave_zero_satisfaction", { ...detail, tarinai, world: detail.world || tarinai.world || null }) || changed; } return changed; } function recordFirstAidRecovery(detail = {}) { if (state.unlocked.town_doctor_50) return false; const recovered = Number(detail.recovered ?? detail.amount ?? 0); if (!Number.isFinite(recovered) || recovered <= 0.0001) return false; const count = incrementProgressCounter("firstAidHeals"); saveState(); renderIfDialogOpen(); if (count >= ADDITIVE_PROGRESS_CAPS.firstAidHeals) return unlock("town_doctor_50", { ...detail, count, recovered }); return false; } function recordPlushiePokeFling(detail = {}) { return unlock("poke_plushie_fling", detail); } function recordConsumableUse(detail = {}) { const type = String(detail.type || detail.item?.type || ""); 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); tarinai._achievementFightMochiWorld = world; } if (type === "mystery_drug" && tarinai && !state.unlocked.mad_scientist) { const progress = ensureProgress(); const identity = String(tarinai.familyKey || tarinai.id || ""); if (identity && !progress.mysteryDrugTarinaiIds.includes(identity)) { progress.mysteryDrugTarinaiIds = [...progress.mysteryDrugTarinaiIds, identity].slice(0, MAD_SCIENTIST_TARGET); markProgressMutation(); if (progress.mysteryDrugTarinaiIds.length >= MAD_SCIENTIST_TARGET) { changed = unlock("mad_scientist", { ...detail, world, tarinai, count: progress.mysteryDrugTarinaiIds.length }) || changed; } else { saveState(); renderIfDialogOpen(); } } } if (MEDICINE_LEDGER_TYPES.includes(type) && !state.unlocked.medicine_ledger_all) { const progress = ensureProgress(); if (!progress.medicineTypes.includes(type)) { progress.medicineTypes = [...progress.medicineTypes, type]; markProgressMutation(); saveState(); renderIfDialogOpen(); } if (MEDICINE_LEDGER_TYPES.every(required => progress.medicineTypes.includes(required))) { changed = unlock("medicine_ledger_all", { ...detail, count: progress.medicineTypes.length }) || changed; } } changed = evaluateStatusEffectAchievements(tarinai, { ...detail, world }) || changed; return changed; } function recordDirectCare(detail = {}) { const world = detailWorld(detail); if (world) recordIntervention({ ...detail, world, tool: "direct_care" }); const tarinai = detail.tarinai || null; const type = String(detail.type || detail.item?.type || ""); if (!world || !tarinai) return false; const foodGift = Boolean(detail.foodGift); const treatment = Boolean(detail.beneficialRecovery); if (foodGift) { tarinai._achievementDirectFeedCount = Math.max(0, Math.floor(Number(tarinai._achievementDirectFeedCount || 0) || 0)) + 1; world.achievementSelfSufficientStartAt = -1; } if (treatment) tarinai._achievementDirectTreatmentCount = Math.max(0, Math.floor(Number(tarinai._achievementDirectTreatmentCount || 0) || 0)) + 1; recordConsumableUse({ ...detail, world, tarinai, type }); if (tarinai._achievementDirectFeedCount >= 10 && tarinai._achievementDirectTreatmentCount >= 3) { return unlock("overprotective", { ...detail, world, tarinai, feedCount: tarinai._achievementDirectFeedCount, treatmentCount: tarinai._achievementDirectTreatmentCount }); } return false; } function recordFightStarted(detail = {}) { const world = detailWorld(detail); if (!world) return false; let changed = false; 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 count = incrementProgressCounter("fightMochiFightCount"); if (count >= CHAOS_FIGHT_TARGET) { changed = unlock("chaos_seeker_666_fights", { ...detail, world, count, fightMochiInfluenced: true }) || changed; } else { saveState(); renderIfDialogOpen(); } } const now = Math.max(0, Number(world.time || 0) || 0); const pairA = detail.a || null; const pairB = detail.b || null; if (pairA && pairB && pairA !== pairB) { world.achievementFightSessionSerial = Math.max(0, Math.floor(Number(world.achievementFightSessionSerial || 0) || 0)) + 1; const pairKey = world.fightPairKey?.(pairA, pairB) || [String(pairA.id || ""), String(pairB.id || "")].sort().join(":"); const sessionId = `${pairKey}@${world.achievementFightSessionSerial}`; pairA._achievementFightSession = { id: sessionId, pairKey, opponentId: pairB.id, startedAt: now }; pairB._achievementFightSession = { id: sessionId, pairKey, opponentId: pairA.id, startedAt: now }; } const candidates = [detail.a, detail.b].filter(Boolean); for (const tarinai of candidates) { const givenAtRaw = tarinai._achievementFightMochiAt; const givenAt = givenAtRaw == null ? NaN : Number(givenAtRaw); const validCurrentSessionMochi = tarinai._achievementFightMochiWorld === world; if (validCurrentSessionMochi && Number.isFinite(givenAt) && now >= givenAt && now - givenAt <= 30.0001) { changed = unlock("fuel_to_fire", { ...detail, world, tarinai, elapsed: now - givenAt }) || changed; break; } } return changed; } function recordShotFired(detail = {}) { if (state.unlocked.sniper_333_shots) return false; const count = incrementProgressCounter("shotCount"); if (count >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count }); saveState(); renderIfDialogOpen(); return false; } function recordLoveMochiBirth(detail = {}) { if (state.unlocked.fertility_seeker_721_love_births || !detail.loveMochiInfluenced) return false; const count = incrementProgressCounter("loveMochiBirthCount"); if (count >= FERTILITY_BIRTH_TARGET) { return unlock("fertility_seeker_721_love_births", { ...detail, count }); } saveState(); renderIfDialogOpen(); return false; } function evaluateMegalopolis(world = global.world, detail = {}) { if (!world || state.unlocked.megalopolis) return false; const aliveCount = playstylePopulationCount(world); if (aliveCount < 100) return false; const duplicatorCount = playstyleItemCount(world, "duplicator"); const shelterCount = playstyleItemCount(world, "grass_bed") + playstyleItemCount(world, "nest_box") + playstyleItemCount(world, "pipe"); if (duplicatorCount < 10 || shelterCount < 15) return false; return unlock("megalopolis", { ...detail, world, aliveCount, duplicatorCount, shelterCount }); } function resetWorldProgress(world = global.world) { if (!world) return false; runtimeByWorld.delete(world); dangerFightDeathsByWorld.delete(world); world.achievementEnemyAntKills = 0; world.achievementSelfSufficientStartAt = -1; world.achievementNoDeathStartAt = -1; world.achievementHappyStartAt = -1; world.achievementEliteFewStartAt = -1; for (const tarinai of world.tarinai || []) { if (!tarinai) continue; tarinai._achievementFightMochiAt = null; tarinai._achievementFightMochiWorld = null; tarinai._achievementDirectFeedCount = 0; tarinai._achievementDirectTreatmentCount = 0; tarinai._achievementSaunaHotAt = null; tarinai._achievementSaunaWasHot = false; tarinai._achievementHeldStartedAt = null; tarinai._achievementFightSession = null; } for (const item of world.items || []) { if (!item) continue; if (item.type === "sticky_bomb") item.stickyBombPassCount = 0; item._achievementPlacedAt = null; } // Mystery-drug targets are a lifetime achievement and intentionally survive world resets. return true; } function recordHeldTarinai(world = global.world, now = Date.now()) { if (!world || state.unlocked.held_30_seconds) return false; if (document.hidden) { for (const tarinai of world.tarinai || []) { if (tarinai) tarinai._achievementHeldStartedAt = null; } return false; } const timestamp = Number(now); if (!Number.isFinite(timestamp)) return false; for (const tarinai of world.tarinai || []) { if (!tarinai || tarinai.dead) continue; if (tarinai.playerHeld || tarinai._heldByPlayer) { if (tarinai._achievementHeldStartedAt == null || !Number.isFinite(Number(tarinai._achievementHeldStartedAt))) tarinai._achievementHeldStartedAt = timestamp; if (timestamp >= tarinai._achievementHeldStartedAt && timestamp - tarinai._achievementHeldStartedAt >= HELD_TARGET_MS) { return unlock("held_30_seconds", { world, tarinai, elapsedMs: timestamp - tarinai._achievementHeldStartedAt }); } } else { tarinai._achievementHeldStartedAt = null; } } return false; } function recordBirth(detail = {}) { unlock("first_birth", detail); const child = detail.child || null; 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 }); if (child?.isZunchiSlave) recordNaturalStatus("slave", { ...detail, world, tarinai: child, bornWithStatus: true }); if (child?.isTarinaiChampion) recordNaturalStatus("king", { ...detail, world, tarinai: child, bornWithStatus: true }); evaluateMegalopolis(world, detail); if (!state.unlocked.great_mother_1000_births) { const count = incrementProgressCounter("birthCount"); saveState(); renderIfDialogOpen(); if (count >= ADDITIVE_PROGRESS_CAPS.birthCount) unlock("great_mother_1000_births", { ...detail, count }); } if (state.unlocked.birth_50_in_60_seconds) return; const now = Number(world?.time || 0) || 0; const tracker = runtimeTracker(world, now); if (!tracker) return; tracker.birthTimes.push(now); tracker.birthTimes = tracker.birthTimes.filter(time => time >= now - 60.0001); if (tracker.birthTimes.length >= 30) unlock("birth_50_in_60_seconds", detail); } function recordDeath(detail = {}) { recordFightPairDangerDeath(detail); if (!state.unlocked.memento_mori) { const count = incrementProgressCounter("totalDeathCount"); if (count >= MEMENTO_MORI_TARGET) unlock("memento_mori", { ...detail, count }); else { scheduleProgressSave(); renderIfDialogOpen(); } } const reason = String(detail.reason || detail.tarinai?.deathReason || ""); const lifespanDeath = /\u5bff\u547d|\u5929\u5bff/.test(reason); const world = detail.world || detail.tarinai?.world || null; if (world) { evaluateMegalopolis(world, detail); const aliveAfterDeath = playstylePopulationCount(world); if (!state.unlocked.enemy_enemy_friend && aliveAfterDeath < 30) world.achievementEnemyAntKills = 0; if (!lifespanDeath) { world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1; } } const now = Number(world?.time || 0) || 0; const tracker = runtimeTracker(world, now); if (tracker) { tracker.deathTimes.push(now); tracker.deathTimes = tracker.deathTimes.filter(time => time >= now - 10.0001); if (tracker.deathTimes.length >= 50) unlock("death_50_in_10_seconds", detail); } const tarinai = detail.tarinai || null; if (reason.includes("\u5929\u5bff\u3092\u5168\u3046\u3057\u305f")) { unlock("lifespan_completed", detail); if (tarinai?.lifeItemMode === "mercury" || Number(tarinai?.mercuryLifeMultiplier || 0) > 1) unlock("mercury_lifespan", detail); } const attackerId = String(tarinai?._achievementLastFightAttackerId || ""); const attackerAt = Number(tarinai?._achievementLastFightDamageAt); const attacker = attackerId ? (world?.tarinai || []).find(candidate => candidate && String(candidate.id || "") === attackerId) : null; if (tarinai?.isTarinaiChampion && attacker?.isZunchiSlave && reason.includes("\u55a7\u5629") && Number.isFinite(attackerAt) && Math.abs(now - attackerAt) <= 0.1) { unlock("revolution", { ...detail, attacker }); } const laxativeActive = Boolean(tarinai?._achievementLaxativeActiveThisFrame || tarinai?.hasItemEffect?.("laxative") || Number(tarinai?.itemEffectTimers?.laxative || 0) > 0.04); if (laxativeActive && reason.includes("\u98e2\u9913")) unlock("laxative_starvation", detail); } function recordNaturalStatus(kind, detail = {}) { const tarinai = detail.tarinai || null; const world = detail.world || tarinai?.world || null; if (kind === "slave") { unlock("natural_zunchi_slave", detail); if (tarinai?.isAcquiredTarinai) unlock("ochibi_natural_slave", detail); if (recordNaturalGeneration(world, tarinai, "_achievementNaturalSlaveLineageDepth", 5)) { unlock("natural_zunchi_slave_5_generations", detail); } evaluateStatusEffectAchievements(tarinai, detail); return; } if (kind === "king") { unlock("natural_tarinai_king", detail); if (recordNaturalGeneration(world, tarinai, "_achievementNaturalKingLineageDepth", 3)) { unlock("natural_tarinai_king_3_generations", detail); } evaluateStatusEffectAchievements(tarinai, detail); } } function recordDirectFeed(detail = {}) { const world = detailWorld(detail); if (!world) return false; recordDirectCare({ ...detail, world, foodGift: true }); if (state.unlocked.direct_feed_33) return false; const count = incrementProgressCounter("directFeedCount"); saveState(); renderIfDialogOpen(); if (count >= ADDITIVE_PROGRESS_CAPS.directFeedCount) return unlock("direct_feed_33", { ...detail, world, count }); return false; } function recordIgnition(detail = {}) { return unlock("ignite_during_birth_ritual", detail); } function recordFavoriteTarinai(detail = {}) { if (state.unlocked.favorite_one) return false; const tarinai = detail.tarinai || detail.target || null; if (!tarinai || tarinai.dead || !tarinai.favorite) return false; return unlock("favorite_one", detail); } function recordStatsButtonPress(detail = {}) { if (state.unlocked.statistician) return false; const count = incrementProgressCounter("statsButtonPressCount"); if (count >= STATISTICIAN_TARGET) { return unlock("statistician", { ...detail, count }); } saveState(); renderIfDialogOpen(); return false; } function recordPushNotificationsEnabled(detail = {}) { if (state.unlocked.well_informed) return false; return unlock("well_informed", detail); } function recordManualTarinaiAdded(detail = {}) { // Adding a Tarinai is a direct player intervention and must invalidate // the no-operation window used by the surveillance-camera achievement. recordIntervention({ ...detail, tool: detail.tool || "new-tarinai" }); if (state.unlocked.lively_making) return false; const count = incrementProgressCounter("manualTarinaiAddedCount"); if (count >= LIVELY_MAKING_TARGET) { return unlock("lively_making", { ...detail, count }); } saveState(); renderIfDialogOpen(); return false; } function playstylePopulationCount(worldRef) { const cached = worldRef?.tarinaiCounts?.(); if (cached && Number.isFinite(Number(cached.alive))) return Math.max(0, Number(cached.alive) || 0); return (worldRef?.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0); } function playstyleItemCount(worldRef, type) { const counts = worldRef?.itemCounts; if (counts && typeof counts === "object") return Math.max(0, Number(counts[type] || 0) || 0); let count = 0; for (const item of worldRef?.items || []) if (item && !item.dead && item.type === type) count += 1; return count; } function playstyleBedCapacity(worldRef) { return playstyleItemCount(worldRef, "bed") + playstyleItemCount(worldRef, "grass_bed") + playstyleItemCount(worldRef, "nest_box") * 5 + playstyleItemCount(worldRef, "pipe") * 3; } function evaluateEvent(worldRef, trigger = "timer", detail = {}) { if (!worldRef) return false; const kind = String(trigger || "timer"); const populationEvent = kind === "population" || kind === "timer" || kind === "season" || kind === "restore"; const itemEvent = kind === "items" || populationEvent; const now = Math.max(0, Number(worldRef.time || 0) || 0); const aliveCount = populationEvent || itemEvent ? playstylePopulationCount(worldRef) : 0; let changed = false; if (populationEvent) { if (!state.unlocked.strength_in_numbers && aliveCount >= STRENGTH_IN_NUMBERS_TARGET) { changed = unlock("strength_in_numbers", { ...detail, world: worldRef, count: aliveCount }) || changed; } if (!state.unlocked.elite_few) { if (aliveCount > 0 && aliveCount <= ELITE_FEW_MAX_POPULATION) { let startedAt = Number(worldRef.achievementEliteFewStartAt); if (!Number.isFinite(startedAt) || startedAt < 0 || startedAt > now) { startedAt = now; worldRef.achievementEliteFewStartAt = now; } const dayLength = Math.max(1, Number(worldRef.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120); const targetDuration = dayLength * ELITE_FEW_TARGET_DAYS; if (now - startedAt >= targetDuration) { changed = unlock("elite_few", { ...detail, world: worldRef, count: aliveCount, elapsed: now - startedAt, days: ELITE_FEW_TARGET_DAYS }) || changed; } } else { worldRef.achievementEliteFewStartAt = -1; } } changed = evaluateMegalopolis(worldRef, detail) || changed; } if (itemEvent) { const zunchiCount = playstyleItemCount(worldRef, "zunchi"); const bedCapacity = playstyleBedCapacity(worldRef); if (!state.unlocked.zunchi_overflow && aliveCount >= 20 && zunchiCount > aliveCount) { changed = unlock("zunchi_overflow", { ...detail, world: worldRef, aliveCount, zunchiCount }) || changed; } if (!state.unlocked.comfortable_beds && aliveCount > 0 && bedCapacity >= aliveCount) { changed = unlock("comfortable_beds", { ...detail, world: worldRef, aliveCount, bedCapacity }) || changed; } if (!state.unlocked.stone_pillow && aliveCount >= 30 && bedCapacity === 0) { changed = unlock("stone_pillow", { ...detail, world: worldRef, aliveCount, bedCapacity }) || changed; } changed = evaluateMegalopolis(worldRef, detail) || changed; } if ((kind === "season" || kind === "timer" || kind === "restore") && !state.unlocked.across_seasons) { const elapsedDays = Number.isFinite(Number(worldRef.elapsedDays)) ? Math.max(0, Number(worldRef.elapsedDays) || 0) : Math.max(0, Math.floor(now / Math.max(1, Number(worldRef.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120))); if (elapsedDays >= FULL_SEASON_CYCLE_DAYS) changed = unlock("across_seasons", { ...detail, world: worldRef, elapsedDays }) || changed; } return changed; } function evaluateWorld(worldRef, mood = null) { if (!worldRef) return false; const maxGeneration = Math.max(1, Math.floor(Number(worldRef.maxGeneration || 1) || 1)); if (!state.unlocked.eternal_history_generation_10 && maxGeneration >= GENERATION_TARGET) { unlock("eternal_history_generation_10", { world: worldRef, generation: maxGeneration }); } evaluateEvent(worldRef, "timer", { mood }); const needsAliveScan = !state.unlocked.all_non_sleep_diseased_25 || !state.unlocked.colony_happy || !state.unlocked.minimalist_happy || !state.unlocked.self_sufficient || !state.unlocked.rain_shelter_all || !state.unlocked.sauna_cold_plunge || !state.unlocked.idle_observer_5_minutes || !state.unlocked.safe_colony_25_5_minutes || !state.unlocked.ant_nest_without_tarinai; const needsAntScan = !state.unlocked.ants_alive_25; const needsTemperatureItemScan = !state.unlocked.below_absolute_zero_item; const needsCleanFreakScan = !state.unlocked.clean_freak_robot_only; if (!needsAliveScan && !needsAntScan && !needsTemperatureItemScan && !needsCleanFreakScan) return true; const now = Math.max(0, Number(worldRef.time || 0) || 0); const alive = needsAliveScan ? (worldRef.tarinai || []).filter(t => t && !t.dead) : []; const aliveCount = alive.length; if (!state.unlocked.all_non_sleep_diseased_25 && alive.length >= 25 && alive.every(t => Boolean(t.zunchiDisease || t.explosionDisease || t.fightDisease))) { unlock("all_non_sleep_diseased_25", { world: worldRef, count: alive.length }); } const moodId = String(mood?.id || worldRef.colonyMood?.id || ""); const day = Math.max(1, Math.floor(Number(worldRef.day || 1) || 1)); if (alive.length > 0 && moodId === "happy" && day >= 6 && !state.unlocked.minimalist_happy) { const placedAlive = Math.max(0, Number(worldRef.achievementPlayerPlacedActiveCount || 0) || 0); if (placedAlive <= 10) unlock("minimalist_happy", { world: worldRef, mood: mood || worldRef.colonyMood, placedAlive }); } if (!state.unlocked.colony_happy) { if (alive.length > 0 && moodId === "happy" && day >= 6) { let happyStart = Number(worldRef.achievementHappyStartAt); if (!Number.isFinite(happyStart) || happyStart < 0 || happyStart > now) { happyStart = now; worldRef.achievementHappyStartAt = now; } const dayLength = Math.max(1, Number(global.CONFIG?.dayLength || 120) || 120); if (now - happyStart >= dayLength) unlock("colony_happy", { world: worldRef, mood: mood || worldRef.colonyMood, elapsed: now - happyStart, day }); } else { worldRef.achievementHappyStartAt = -1; } } if (!state.unlocked.self_sufficient) { if (alive.length >= 20 && playstyleItemCount(worldRef, "duplicator") === 0) { let selfStart = Number(worldRef.achievementSelfSufficientStartAt); if (!Number.isFinite(selfStart) || selfStart < 0 || selfStart > now) { selfStart = now; worldRef.achievementSelfSufficientStartAt = now; } if (now - selfStart >= 600) unlock("self_sufficient", { world: worldRef, count: alive.length, elapsed: now - selfStart }); } else { worldRef.achievementSelfSufficientStartAt = -1; } } if (!state.unlocked.rain_shelter_all && worldRef.weather === "light_rain" && alive.length >= 20 && alive.every(tarinai => Boolean(global.TarinaiRainShelterSystem?.isSheltered?.(worldRef, tarinai.x, tarinai.y, tarinai)))) { unlock("rain_shelter_all", { world: worldRef, count: alive.length }); } const needsSauna = !state.unlocked.sauna_cold_plunge; if (needsSauna) { for (const tarinai of alive) { const temperature = Number(worldRef.feltTemperatureFor?.(tarinai) ?? worldRef.temperatureAt?.(tarinai.x, tarinai.y, tarinai)); const status = Number.isFinite(temperature) ? worldRef.temperatureStatusFor?.(temperature, tarinai) : null; const direction = String(status?.direction || ""); const isHot = direction === "hot" && status?.comfortable !== true; const isCold = direction === "cold" && status?.comfortable !== true; let hotAt = tarinai._achievementSaunaHotAt == null ? NaN : Number(tarinai._achievementSaunaHotAt); let wasHot = tarinai._achievementSaunaWasHot; if (wasHot == null) wasHot = Boolean(isHot && Number.isFinite(hotAt)); if (isHot) { if (!wasHot || !Number.isFinite(hotAt) || hotAt > now) { hotAt = now; tarinai._achievementSaunaHotAt = now; } tarinai._achievementSaunaWasHot = true; } else { tarinai._achievementSaunaWasHot = false; if (isCold && Number.isFinite(hotAt) && now >= hotAt && now - hotAt <= 15.0001) { unlock("sauna_cold_plunge", { world: worldRef, tarinai, elapsed: now - hotAt, temperature }); } if (Number.isFinite(hotAt) && now - hotAt > 15.0001) tarinai._achievementSaunaHotAt = null; } } } if (!state.unlocked.idle_observer_5_minutes) { let lastInterventionAt = Number(worldRef.achievementLastInterventionAt); if (!Number.isFinite(lastInterventionAt) || lastInterventionAt < 0 || lastInterventionAt > now) { lastInterventionAt = now; worldRef.achievementLastInterventionAt = now; } const dayLength = Math.max(1, Number(worldRef.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120); const observerTarget = dayLength * OBSERVER_GAME_DAYS; if (now - lastInterventionAt >= observerTarget) unlock("idle_observer_5_minutes", { world: worldRef, elapsed: now - lastInterventionAt, gameDays: OBSERVER_GAME_DAYS }); } if (!state.unlocked.safe_colony_25_5_minutes) { if (alive.length >= 25) { let safeStart = Number(worldRef.achievementNoDeathStartAt); if (!Number.isFinite(safeStart) || safeStart < 0 || safeStart > now) { safeStart = now; worldRef.achievementNoDeathStartAt = now; } if (now - safeStart >= 300) unlock("safe_colony_25_5_minutes", { world: worldRef, count: alive.length, elapsed: now - safeStart }); } else { worldRef.achievementNoDeathStartAt = -1; } } if (!state.unlocked.sandbox_five_toilets) evaluateSandbox(worldRef, { source: "world-evaluation" }); if (!state.unlocked.ants_alive_25) recordAntPopulation(worldRef); if (!state.unlocked.ant_nest_without_tarinai) evaluateAntNestWithoutTarinai(worldRef, { source: "world-evaluation" }); if (!state.unlocked.below_absolute_zero_item) recordBelowAbsoluteZeroItem(worldRef); if (!state.unlocked.clean_freak_robot_only) evaluateCleanFreak(worldRef, { source: "world-evaluation" }); if (!state.unlocked.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" }); return true; } async function resetAchievements() { await persistenceReadyPromise; const confirmed = await global.TarinaiGameDialogs?.confirm?.({ title: "\u5b9f\u7e3e\u306e\u30ea\u30bb\u30c3\u30c8", message: "\u3059\u3079\u3066\u306e\u5b9f\u7e3e\u89e3\u9664\u72b6\u614b\u3068\u3001\u5171\u6709\u96c6\u8a08\u4e0a\u306e\u81ea\u5206\u306e\u89e3\u9664\u8a18\u9332\u3092\u524a\u9664\u3057\u307e\u3059\u3002", confirmText: "\u30ea\u30bb\u30c3\u30c8", cancelText: "\u30ad\u30e3\u30f3\u30bb\u30eb", danger: true, }); if (!confirmed) return false; const identity = currentServerIdentity(); persistenceGeneration += 1; persistenceRevision = 0; state = blankState(); if (identity) state.serverIdentity = { ...identity }; state.resetPending = true; progressMutationRevision = 0; progressSyncedRevision = 0; runtimeByWorld = new WeakMap(); dangerFightDeathsByWorld = new WeakMap(); wireShockTrackers = new WeakMap(); toastQueue = []; toastActive = false; emptyClickTimes = []; groundChangeTimes = []; continuousPlayAccumulatedMs = 0; continuousPlayLastHeartbeatAt = Date.now(); continuousPlayActive = !document.hidden; if (global.world) { resetWorldProgress(global.world); global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0); } saveState(); recordDailyPlay({ source: "reset" }); render(); clearTimeout(showUnlockToast.absorbTimer); clearTimeout(showUnlockToast.hideTimer); dom.toast?.classList.add("hidden"); resetUnlockToastVisual(); dom.button?.classList.remove("achievement-absorb-target"); global.showToast?.("\u5b9f\u7e3e\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002"); void syncPending(); return true; } function unlockAllAchievementsForDebug() { if (!DEBUG_MODE) return false; const now = Date.now(); let changed = 0; for (const definition of DEFINITIONS) { if (state.unlocked[definition.id]) continue; state.unlocked[definition.id] = now; if (!Array.isArray(state.debugUnlocked)) state.debugUnlocked = []; if (!state.debugUnlocked.includes(definition.id)) state.debugUnlocked.push(definition.id); changed += 1; } saveState(); render(); global.showToast?.(changed ? `\u5168${DEFINITIONS.length}\u5b9f\u7e3e\u3092\u7372\u5f97\u3057\u307e\u3057\u305f\u3002` : "\u3059\u3079\u3066\u306e\u5b9f\u7e3e\u306f\u7372\u5f97\u6e08\u307f\u3067\u3059\u3002"); return changed > 0; } function openDialog() { if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dom.dialog, dom.button); else { dom.dialog?.classList.remove("hidden"); dom.dialog?.setAttribute("aria-hidden", "false"); } render({ forceList: true }); if (!global.TarinaiManagedDialogs?.open) dom.closeIconButton?.focus?.(); void syncPending({ full: true }); } function closeDialog() { if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dom.dialog); else { dom.dialog?.classList.add("hidden"); dom.dialog?.setAttribute("aria-hidden", "true"); dom.button?.focus?.(); } } document.addEventListener("visibilitychange", () => { const now = Date.now(); if (document.hidden) { if (continuousPlayActive && now >= continuousPlayLastHeartbeatAt) { continuousPlayAccumulatedMs += now - continuousPlayLastHeartbeatAt; } continuousPlayActive = false; } else { continuousPlayLastHeartbeatAt = now; continuousPlayActive = true; } }); if (typeof global.setInterval === "function") { global.setInterval(() => { if (!document.hidden) recordContinuousPlay(); recordDailyPlay(); recordLowFps(); recordHeldTarinai(global.world, Date.now()); }, 1000); if (!DEBUG_MODE) global.setInterval(() => void syncPending({ full: true }), FULL_SYNC_INTERVAL_MS); } dom.button?.addEventListener("click", () => { global.TarinaiAudio?.uiClick?.(); openDialog(); }); dom.closeButton?.addEventListener("click", closeDialog); dom.closeIconButton?.addEventListener("click", closeDialog); dom.refreshButton?.addEventListener("click", () => void syncPending({ full: true })); if (dom.unlockAllButton && DEBUG_MODE) dom.unlockAllButton.hidden = false; dom.resetButton?.addEventListener("click", () => void resetAchievements()); dom.unlockAllButton?.addEventListener("click", unlockAllAchievementsForDebug); dom.dialog?.addEventListener("click", event => { if (event.target === dom.dialog) closeDialog(); }); dom.dialog?.addEventListener("tarinai:dialog-close-request", closeDialog); global.addEventListener?.("pagehide", () => { saveState(); }); document.addEventListener("visibilitychange", () => { if (document.hidden) saveState(); }); const api = Object.freeze({ isUnlocked(id) { return Boolean(state.unlocked[String(id || "")]); }, recordBirth, recordDeath, recordNaturalZunchiSlave(detail = {}) { recordNaturalStatus("slave", detail); }, recordNaturalTarinaiKing(detail = {}) { recordNaturalStatus("king", detail); }, recordDirectFeed, recordDirectCare, recordConsumableUse, recordFirstAidRecovery, recordPlushiePokeFling, recordIgnition, recordIntervention, recordPlayerPlacement, recordPlayerDeletion, recordHistoryAction, recordRobotClean, importWorldAchievementProgress, evaluateCleanFreak, recordFightStarted, recordShotFired, recordLoveMochiBirth, recordFavoriteTarinai, recordStatsButtonPress, recordPushNotificationsEnabled, recordManualTarinaiAdded, resetWorldProgress, recordLinkPlaced, recordSignalActivation, recordAntPopulation, recordAntKilled, recordStickyBombPass, recordCircuitBoardSignalTransfer, recordWirePowerState, recordWireShock, recordSaveSlotsFilled, recordEmptyClick, recordGroundChange, recordDamageSource, recordAntDamageSource, exportSpellState, importSpellState, evaluateWorld, evaluateEvent, recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); }, recordZunchiCollisionDeath(detail = {}) { return unlock("zunchi_collision_death", detail); }, recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); }, }); global.TarinaiAchievements = api; if (global.__TARINAI_TEST__) { global.TarinaiAchievementTestHooks = Object.freeze({ state() { return normalizeState(state); }, growOnlyStateDelta, mergeStateData, blankState, normalizeState, progressCounter, localProgressSnapshot, recordFightPairDangerDeath, recordNaturalGeneration, definitions: DEFINITIONS, categories: ACHIEVEMENT_CATEGORIES, definition(id) { return definitionById.get(String(id || "")) || null; }, refresh() { return syncPending({ full: true }); }, reset: resetAchievements, open: openDialog, render(options = {}) { return render(options); }, }); } setupPersistenceCrossTabSync(); if (dom.buttonCount) dom.buttonCount.textContent = "…"; if (dom.dialogCount) dom.dialogCount.textContent = `… / ${DEFINITIONS.length}`; persistenceReadyPromise = hydrateDurableState().catch(() => { persistenceHydrated = true; persistStateNow(); return true; }).then(() => { recordDailyPlay({ source: "load" }); if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) unlock(COMPLETIONIST_ID, { source: "load" }); render(); global.setTimeout(() => recordSaveSlotsFilled(), 0); global.setTimeout(() => void syncPending({ full: true }), 700); return true; }); })(window);