tarinai/js/achievements.js
2026-08-08 15:31:31 +09:00

3157 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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);