2026-07-15 14:44:29 +09:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
|
|
(function initAchievementSystem(global) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
// 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:";
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const PLAYER_KEY = "tarinai_achievement_player_v1";
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const PLAYER_SECRET_KEY = "tarinai_achievement_player_secret_v1";
|
|
|
|
|
|
const LEGACY_PLAYER_KEY = "tarinai_achievement_legacy_player_v4";
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php");
|
2026-08-08 15:31:31 +09:00
|
|
|
|
const GAME_VERSION = String(global.TARINAI_VERSION || "39.18.18");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug"));
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const ABSOLUTE_ZERO_C = -273.15;
|
|
|
|
|
|
const CONTINUOUS_PLAY_TARGET_MS = 60 * 60 * 1000;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
const CONTINUOUS_PLAY_24H_TARGET_MS = 24 * 60 * 60 * 1000;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const HELD_TARGET_MS = 30 * 1000;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const GENERATION_TARGET = 10;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const SYNC_DEBOUNCE_MS = 450;
|
|
|
|
|
|
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const OBSERVER_GAME_DAYS = 3;
|
|
|
|
|
|
const STRENGTH_IN_NUMBERS_TARGET = 100;
|
|
|
|
|
|
const ELITE_FEW_MAX_POPULATION = 25;
|
|
|
|
|
|
const ELITE_FEW_TARGET_DAYS = 5;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const FULL_SEASON_CYCLE_DAYS = 20;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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)),
|
|
|
|
|
|
})));
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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"),
|
2026-07-18 13:06:02 +09:00
|
|
|
|
toastCondition: document.getElementById("achievementToastCondition"),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
pauseButton: document.getElementById("pauseBtn"),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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" };
|
2026-07-15 14:44:29 +09:00
|
|
|
|
let requestSerial = 0;
|
|
|
|
|
|
let runtimeByWorld = new WeakMap();
|
|
|
|
|
|
let dangerFightDeathsByWorld = new WeakMap();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
let wireShockTrackers = new WeakMap();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
let syncQueue = Promise.resolve();
|
|
|
|
|
|
let toastQueue = [];
|
|
|
|
|
|
let toastActive = false;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
let emptyClickTimes = [];
|
|
|
|
|
|
let groundChangeTimes = [];
|
2026-07-29 20:52:11 +09:00
|
|
|
|
let continuousPlayAccumulatedMs = 0;
|
|
|
|
|
|
let continuousPlayLastHeartbeatAt = Date.now();
|
|
|
|
|
|
let continuousPlayActive = !document.hidden;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
let lastListSignature = "";
|
|
|
|
|
|
let scheduledSyncTimer = 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
let scheduledProgressSaveTimer = 0;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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, {}])),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
|
|
|
|
|
|
function blankState() {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
try {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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),
|
|
|
|
|
|
};
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
}
|
|
|
|
|
|
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),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
};
|
2026-07-29 20:52:11 +09:00
|
|
|
|
} 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);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
} 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"));
|
|
|
|
|
|
});
|
2026-07-15 14:44:29 +09:00
|
|
|
|
} catch (_) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
try { db?.close?.(); } catch (_) {}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveState() {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 13:06:02 +09:00
|
|
|
|
function scheduleProgressSave(delay = 350) {
|
|
|
|
|
|
if (scheduledProgressSaveTimer) return;
|
|
|
|
|
|
scheduledProgressSaveTimer = global.setTimeout(() => {
|
|
|
|
|
|
scheduledProgressSaveTimer = 0;
|
|
|
|
|
|
saveState();
|
|
|
|
|
|
}, Math.max(0, Number(delay) || 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
function markProgressMutation() {
|
|
|
|
|
|
progressMutationRevision += 1;
|
|
|
|
|
|
if (!DEBUG_MODE && global.location?.protocol !== "file:") schedulePendingSync();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
try {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return normalizeServerIdentity({
|
|
|
|
|
|
playerId: storage?.getItem?.(PLAYER_KEY),
|
|
|
|
|
|
playerSecret: storage?.getItem?.(PLAYER_SECRET_KEY),
|
|
|
|
|
|
});
|
2026-07-15 14:44:29 +09:00
|
|
|
|
} catch (_) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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 (_) {}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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) {
|
2026-08-08 15:31:31 +09:00
|
|
|
|
return `達成率 ${percentageText(id)}(達成人数 ${achieverCountText(id)})`;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function continuousPlayElapsedMs(now = Date.now()) {
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (state.unlocked.continuous_play_24_hours) return CONTINUOUS_PLAY_24H_TARGET_MS;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const timestamp = Number(now);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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}`;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return `現在 ${elapsedDays.toFixed(1)} / ${ELITE_FEW_TARGET_DAYS}日(個体数 ${aliveCount} / ${ELITE_FEW_MAX_POPULATION}以下)`;
|
2026-07-18 22:15:26 +09:00
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (id === "continuous_play_1_hour") {
|
|
|
|
|
|
const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs());
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return `現在の達成率 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%(${formatElapsed(elapsed)} / 60:00)`;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (id === "continuous_play_24_hours") {
|
|
|
|
|
|
const elapsed = Math.min(CONTINUOUS_PLAY_24H_TARGET_MS, continuousPlayElapsedMs());
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return `現在の達成率 ${(elapsed / CONTINUOUS_PLAY_24H_TARGET_MS * 100).toFixed(1)}%(${formatElapsed(elapsed)} / 1440:00)`;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
}
|
2026-07-15 14:44:29 +09: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)));
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return `現在の達成率 ${(count * 10).toFixed(1)}%(${count} / 10体)`;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
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 = [
|
2026-07-29 20:52:11 +09:00
|
|
|
|
progressCounter("birthCount"),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
Math.floor(continuousPlayElapsedMs() / 1000),
|
2026-07-16 22:12:03 +09:00
|
|
|
|
Math.max(0, Math.floor(Number(global.__tarinaiFps || 0) || 0)),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
Math.min(10, Math.max(0, Number(global.world?.achievementEnemyAntKills || 0) || 0)),
|
2026-07-29 20:52:11 +09:00
|
|
|
|
progressCounter("firstAidHeals"),
|
|
|
|
|
|
progressCounter("fightMochiFightCount"),
|
|
|
|
|
|
progressCounter("loveMochiBirthCount"),
|
2026-07-16 22:12:03 +09:00
|
|
|
|
Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)),
|
|
|
|
|
|
Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0),
|
2026-07-29 20:52:11 +09:00
|
|
|
|
progressCounter("statsButtonPressCount"),
|
|
|
|
|
|
progressCounter("totalDeathCount"),
|
|
|
|
|
|
progressCounter("manualTarinaiAddedCount"),
|
|
|
|
|
|
progressCounter("directFeedCount"),
|
|
|
|
|
|
progressCounter("robotCleanCount"),
|
2026-07-18 22:15:26 +09:00
|
|
|
|
Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0),
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
].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");
|
2026-07-15 16:27:56 +09:00
|
|
|
|
status.textContent = unlockedAt ? `\u9054\u6210\u6e08\u307f ${formatDate(unlockedAt)}` : "";
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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";
|
2026-07-29 20:52:11 +09:00
|
|
|
|
else if (shared.connected) dom.sharedStatus.textContent = `共有集計(端末報告値): ${shared.totalPlayers}人のプレイヤー`;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 22:12:03 +09:00
|
|
|
|
function renderIfDialogOpen() {
|
|
|
|
|
|
if (!dom.dialog?.classList.contains("hidden")) render();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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();
|
2026-07-18 22:15:26 +09:00
|
|
|
|
global.TarinaiAudio?.achievementUnlock?.();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!dom.toast || !dom.toastTitle) {
|
2026-07-18 13:06:02 +09:00
|
|
|
|
global.showToast?.(`\u5b9f\u7e3e\u89e3\u9664: ${definition.title} \u2014 ${definition.description || ""}`);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (dom.toastCondition) dom.toastCondition.textContent = definition.description || "";
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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" },
|
2026-07-29 20:52:11 +09:00
|
|
|
|
body: JSON.stringify({ ...payload, playerSecret: playerSecret() }),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
signal: controller?.signal,
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
} : { signal: controller?.signal, cache: "no-store" };
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const url = payload ? API_URL : `${API_URL}?action=summary`;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const response = await fetch(url, options);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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 : {},
|
2026-07-29 20:52:11 +09:00
|
|
|
|
statisticsTrust: String(data?.statisticsTrust || "client-reported"),
|
2026-07-15 14:44:29 +09:00
|
|
|
|
};
|
|
|
|
|
|
render();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshShared(options = {}) {
|
|
|
|
|
|
const serial = ++requestSerial;
|
|
|
|
|
|
shared.loading = true;
|
|
|
|
|
|
render();
|
|
|
|
|
|
try {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const data = options.session ? await establishSession() : await request(null);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
async function performSyncPending(options = {}) {
|
|
|
|
|
|
let latest = null;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-15 14:44:29 +09:00
|
|
|
|
try {
|
|
|
|
|
|
if (state.resetPending) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
latest = await writeWithIdentityRecovery({
|
2026-07-15 14:44:29 +09:00
|
|
|
|
action: "reset",
|
|
|
|
|
|
playerId: playerId(),
|
|
|
|
|
|
gameVersion: GAME_VERSION,
|
2026-07-29 20:52:11 +09:00
|
|
|
|
confirmReset: "RESET_ACHIEVEMENTS",
|
|
|
|
|
|
catalogRevision: ACHIEVEMENT_CATALOG_REVISION,
|
|
|
|
|
|
generation: persistenceGeneration,
|
|
|
|
|
|
syncProtocol: SYNC_PROTOCOL_VERSION,
|
|
|
|
|
|
}, { mergeSession: false });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
state.resetPending = false;
|
|
|
|
|
|
saveState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
// 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.
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const snapshot = localUnlockSnapshot();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const progressSnapshot = localProgressSnapshot();
|
|
|
|
|
|
const progressRevisionAtStart = progressMutationRevision;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const hasProgressChanges = progressRevisionAtStart > progressSyncedRevision;
|
|
|
|
|
|
if (options.full || syncedIds.length || hasProgressChanges) {
|
|
|
|
|
|
latest = await writeWithIdentityRecovery({
|
2026-07-15 14:44:29 +09:00
|
|
|
|
action: "sync",
|
|
|
|
|
|
playerId: playerId(),
|
|
|
|
|
|
gameVersion: GAME_VERSION,
|
|
|
|
|
|
unlocked,
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
|
|
|
|
|
} else if (!latest) {
|
|
|
|
|
|
return refreshShared({ session: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (latest) applyShared(latest);
|
|
|
|
|
|
return true;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error?.recoveredData) {
|
|
|
|
|
|
applyShared(error.recoveredData);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
shared = { ...shared, connected: false, loading: false };
|
|
|
|
|
|
render();
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncPending(options = {}) {
|
|
|
|
|
|
const normalized = { full: Boolean(options.full) };
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const run = async () => {
|
|
|
|
|
|
await persistenceReadyPromise;
|
|
|
|
|
|
return performSyncPending(normalized);
|
|
|
|
|
|
};
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
function recordNaturalGeneration(world, tarinai, depthKey, required) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!world || !tarinai) return 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const parentIds = Array.isArray(tarinai.parents) ? tarinai.parents.filter(Boolean) : [];
|
|
|
|
|
|
let parentDepth = 0;
|
|
|
|
|
|
for (const parentId of parentIds) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const parent = (world.tarinai || []).find(candidate => candidate
|
|
|
|
|
|
&& String(candidate.familyKey || candidate.id || "") === String(parentId));
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 = [];
|
2026-07-29 20:52:11 +09:00
|
|
|
|
state.progress.linkTypes = [...new Set(state.progress.linkTypes.filter(type => LINK_PROGRESS_TYPES.includes(type)))];
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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)))];
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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));
|
2026-07-18 22:15:26 +09:00
|
|
|
|
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);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
return state.progress;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 13:06:02 +09:00
|
|
|
|
function unlockedIndexesFromMaskWords(lowWord = 0, highWord = 0, extraWord = 0) {
|
2026-07-15 16:27:56 +09:00
|
|
|
|
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(lowWord) || 0)));
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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)));
|
2026-07-15 16:27:56 +09:00
|
|
|
|
const indexes = [];
|
|
|
|
|
|
for (let index = 0; index < DEFINITIONS.length; index += 1) {
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const word = index < 32 ? low : (index < 64 ? high : extra);
|
|
|
|
|
|
const bit = index < 32 ? index : (index < 64 ? index - 32 : index - 64);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (Math.floor(word / (2 ** bit)) % 2) indexes.push(index);
|
|
|
|
|
|
}
|
|
|
|
|
|
return indexes;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function exportSpellState() {
|
|
|
|
|
|
let lowWord = 0;
|
|
|
|
|
|
let highWord = 0;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
let extraWord = 0;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
else if (index < 64) highWord += 2 ** (index - 32);
|
|
|
|
|
|
else extraWord += 2 ** (index - 64);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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,
|
2026-07-18 13:06:02 +09:00
|
|
|
|
extraWord >>> 0,
|
2026-07-15 16:27:56 +09:00
|
|
|
|
baseTimestamp,
|
|
|
|
|
|
deltas,
|
|
|
|
|
|
[
|
2026-07-29 20:52:11 +09:00
|
|
|
|
state.unlocked.placed_objects_100 ? 0 : Math.min(100, progressCounter("placementCount")),
|
2026-07-15 16:27:56 +09:00
|
|
|
|
state.unlocked.mechanized_industry ? 0 : linkFlags,
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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")),
|
2026-07-15 16:27:56 +09:00
|
|
|
|
state.unlocked.medicine_ledger_all ? 0 : medicineMask,
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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")),
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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),
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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")),
|
2026-07-18 22:15:26 +09:00
|
|
|
|
state.unlocked.mad_scientist ? 0 : Math.min(MAD_SCIENTIST_TARGET, progress.mysteryDrugTarinaiIds.length),
|
2026-07-29 20:52:11 +09:00
|
|
|
|
state.unlocked.direct_feed_33 ? 0 : Math.min(33, progressCounter("directFeedCount")),
|
|
|
|
|
|
state.unlocked.robot_cleaner_100 ? 0 : Math.min(ROBOT_CLEAN_TARGET, progressCounter("robotCleanCount")),
|
2026-07-15 16:27:56 +09:00
|
|
|
|
],
|
2026-07-29 20:52:11 +09:00
|
|
|
|
state.unlocked.mad_scientist ? [] : progress.mysteryDrugTarinaiIds.slice(0, MAD_SCIENTIST_TARGET),
|
2026-07-15 16:27:56 +09:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseSpellState(payload = null) {
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (!Array.isArray(payload)) return null;
|
|
|
|
|
|
const version = Number(payload[0]);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (![9, 10, SPELL_STATE_VERSION].includes(version)) return null;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
const lowWord = Number(payload[1]);
|
|
|
|
|
|
const highWord = Number(payload[2]);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const extraWord = Number(payload[3]);
|
|
|
|
|
|
const extraMax = (2 ** Math.max(0, DEFINITIONS.length - 64)) - 1;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (!Number.isSafeInteger(lowWord) || lowWord < 0 || lowWord > 0xffffffff ||
|
2026-07-18 13:06:02 +09:00
|
|
|
|
!Number.isSafeInteger(highWord) || highWord < 0 || highWord > 0xffffffff ||
|
|
|
|
|
|
!Number.isSafeInteger(extraWord) || extraWord < 0 || extraWord > extraMax) {
|
2026-07-15 16:27:56 +09:00
|
|
|
|
throw new Error("invalid achievement spell mask");
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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] : [],
|
2026-07-29 20:52:11 +09:00
|
|
|
|
mysteryDrugIds: version >= 10 && Array.isArray(payload[7])
|
|
|
|
|
|
? [...new Set(payload[7].map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET)
|
|
|
|
|
|
: [],
|
|
|
|
|
|
version,
|
2026-07-15 16:27:56 +09:00
|
|
|
|
timestampUnitMs: 60000,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function importSpellState(payload = null) {
|
|
|
|
|
|
const parsed = parseSpellState(payload);
|
|
|
|
|
|
if (!parsed) return { included: false, unlockedAdded: 0, progressChanged: false };
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const { unlockedIndexes, baseTimestamp, deltas, packed, mysteryDrugIds, version, timestampUnitMs } = parsed;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const previousProgress = JSON.stringify({ progress, ledger: ensureProgressLedger() });
|
|
|
|
|
|
raiseProgressCounter("placementCount", packed[0]);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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));
|
2026-07-29 20:52:11 +09:00
|
|
|
|
raiseProgressCounter("antKills", packed[2]);
|
|
|
|
|
|
raiseProgressCounter("birthCount", packed[3]);
|
|
|
|
|
|
raiseProgressCounter("quickDeleteCount", packed[4]);
|
|
|
|
|
|
raiseProgressCounter("undoCount", packed[5]);
|
|
|
|
|
|
raiseProgressCounter("redoCount", packed[6]);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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])];
|
2026-07-29 20:52:11 +09:00
|
|
|
|
raiseProgressCounter("firstAidHeals", packed[8]);
|
|
|
|
|
|
raiseProgressCounter("fightMochiFightCount", packed[9]);
|
|
|
|
|
|
raiseProgressCounter("shotCount", packed[10]);
|
|
|
|
|
|
raiseProgressCounter("loveMochiBirthCount", packed[11]);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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);
|
2026-07-18 22:15:26 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
raiseProgressCounter("directFeedCount", packed[18]);
|
|
|
|
|
|
raiseProgressCounter("robotCleanCount", packed[19]);
|
|
|
|
|
|
const progressChanged = previousProgress !== JSON.stringify({ progress, ledger: ensureProgressLedger() });
|
|
|
|
|
|
if (progressChanged) markProgressMutation();
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
function recordPlayerPlacement(detail = {}) {
|
|
|
|
|
|
const world = detailWorld(detail);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (world) recordIntervention({ ...detail, world, tool: detail.tool || "placement" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!world) return false;
|
|
|
|
|
|
recordIntervention({ ...detail, world });
|
|
|
|
|
|
const item = detail.item || null;
|
|
|
|
|
|
if (item) {
|
2026-07-20 14:36:59 +09:00
|
|
|
|
if (!item._achievementPlayerPlaced || item._achievementPlayerPlacedCounted === false) world.achievementPlayerPlacedActiveCount = Math.max(0, Number(world.achievementPlayerPlacedActiveCount || 0) || 0) + 1;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
item._achievementPlayerPlaced = true;
|
2026-07-20 14:36:59 +09:00
|
|
|
|
item._achievementPlayerPlacedCounted = true;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
item._achievementPlacedAt = Math.max(0, Number(world.time || 0) || 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
let changed = evaluateCleanFreak(world, detail);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
changed = evaluateMegalopolis(world, detail) || changed;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
changed = evaluateSandbox(world, detail) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!state.unlocked.placed_objects_100) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("placementCount");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS.placementCount) changed = unlock("placed_objects_100", { ...detail, world, count }) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
return changed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordPlayerDeletion(detail = {}) {
|
|
|
|
|
|
const world = detailWorld(detail);
|
|
|
|
|
|
const item = detail.item || null;
|
|
|
|
|
|
if (!world || !item) return false;
|
|
|
|
|
|
let changed = evaluateCleanFreak(world, detail);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
changed = evaluateMegalopolis(world, detail) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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) {
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const operationKey = detail.operationId == null ? "" : String(detail.operationId);
|
|
|
|
|
|
if (operationKey && world._achievementLastQuickDeleteOperation === operationKey) return changed;
|
|
|
|
|
|
if (operationKey) world._achievementLastQuickDeleteOperation = operationKey;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("quickDeleteCount");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS.quickDeleteCount) changed = unlock("unplanned_city_30", { ...detail, world, count }) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
return changed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordHistoryAction(kind, detail = {}) {
|
|
|
|
|
|
const action = String(kind || detail.kind || "");
|
|
|
|
|
|
if (action !== "undo" && action !== "redo") return false;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const world = detailWorld(detail);
|
|
|
|
|
|
if (world) recordIntervention({ ...detail, world, tool: action });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const key = action === "undo" ? "undoCount" : "redoCount";
|
|
|
|
|
|
const achievementId = action === "undo" ? "undo_20" : "redo_20";
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter(key);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS[key]) changed = unlock(achievementId, { ...detail, count }) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
return changed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
function evaluateMechanizedIndustry(detail = {}) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return recordPlayerPlacement(detail);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 22:12:03 +09:00
|
|
|
|
function evaluateAntNestWithoutTarinai(worldRef = global.world, detail = {}) {
|
|
|
|
|
|
if (!worldRef || state.unlocked.ant_nest_without_tarinai) return false;
|
2026-07-20 14:36:59 +09:00
|
|
|
|
const hasNest = playstyleItemCount(worldRef, "ant_nest") > 0;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
function recordAntKilled(detail = {}) {
|
|
|
|
|
|
let changed = false;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const playerCaused = Boolean(detail.playerCaused || detail.ant?._achievementLastDamageSource?.danger);
|
|
|
|
|
|
if (!state.unlocked.ants_killed_100 && playerCaused) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("antKills");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS.antKills) changed = unlock("ants_killed_100", { ...detail, count }) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (world && aliveCount < 30) world.achievementEnemyAntKills = 0;
|
|
|
|
|
|
if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
world.achievementEnemyAntKills = Math.min(10, Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) + 1);
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
// "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();
|
|
|
|
|
|
}
|
2026-07-16 22:12:03 +09:00
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const session = tarinai._achievementFightSession || null;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!lethalDanger || !source?.danger || Math.abs(now - Number(source.at || 0)) > 0.05) return false;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (!session?.id || !session.opponentId || Number(tarinai.fightTimer || 0) <= 0.04) return false;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const tracker = fightPairTracker(world);
|
|
|
|
|
|
if (!tracker) return false;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const maxGap = 6;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
for (const [key, pending] of [...tracker.entries()]) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (!pending || now - Number(pending.at || 0) > maxGap) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
tracker.delete(key);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (pending.sessionId === session.id
|
|
|
|
|
|
&& pending.firstId !== tarinai.id
|
|
|
|
|
|
&& pending.otherId === tarinai.id
|
|
|
|
|
|
&& session.opponentId === pending.firstId) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
tracker.delete(key);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
return unlock("fight_pair_danger_kill", { ...detail, pairKey: key, fightSessionId: session.id, first: pending, secondSource: source });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 22:12:03 +09:00
|
|
|
|
function recordEmptyClick(detail = {}) {
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const world = detailWorld(detail);
|
|
|
|
|
|
if (world) recordIntervention({ ...detail, world, tool: "empty_click" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (state.unlocked.pause_spam_4_in_1_second) return false;
|
|
|
|
|
|
const now = Number(detail.now);
|
|
|
|
|
|
const timestamp = Number.isFinite(now) ? now : Date.now();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
emptyClickTimes = emptyClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value);
|
|
|
|
|
|
emptyClickTimes.push(timestamp);
|
|
|
|
|
|
if (emptyClickTimes.length >= 4) {
|
|
|
|
|
|
emptyClickTimes = [];
|
2026-07-15 14:44:29 +09:00
|
|
|
|
return unlock("pause_spam_4_in_1_second", { ...detail, count: 4, windowMs: 1000 });
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
recordIntervention({ ...detail, world, tool: "ground_change" });
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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;
|
2026-07-20 14:36:59 +09:00
|
|
|
|
const count = playstyleItemCount(world, "toilet");
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
markProgressMutation();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
saveState();
|
|
|
|
|
|
renderIfDialogOpen();
|
|
|
|
|
|
if (progress.dailyPlayStreak >= DAILY_PLAY_TARGET) {
|
|
|
|
|
|
return unlock("daily_play_7_days", { ...detail, day: today, streak: progress.dailyPlayStreak });
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
function recordContinuousPlay(detail = {}) {
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (state.unlocked.continuous_play_1_hour && state.unlocked.continuous_play_24_hours) return false;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
elapsedMs = continuousPlayElapsedMs(timestamp);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
2026-07-16 22:12:03 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) {
|
|
|
|
|
|
if (state.unlocked.below_absolute_zero_item || !worldRef) return false;
|
2026-07-20 14:36:59 +09:00
|
|
|
|
worldRef.ensureItemBuckets?.("achievement-absolute-zero");
|
|
|
|
|
|
const testItem = (item) => {
|
|
|
|
|
|
if (!item || item.dead) return false;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const temperature = Number(worldRef.temperatureAt?.(item.x, item.y, null));
|
2026-07-20 14:36:59 +09:00
|
|
|
|
if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) return false;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
return unlock("below_absolute_zero_item", { ...detail, world: worldRef, item, temperature });
|
2026-07-20 14:36:59 +09:00
|
|
|
|
};
|
2026-07-29 20:52:11 +09:00
|
|
|
|
// 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;
|
2026-07-20 14:36:59 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
for (const residue of worldRef.residues || []) {
|
|
|
|
|
|
if (testItem(residue)) return true;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("robotCleanCount");
|
|
|
|
|
|
if (count >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count });
|
|
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-20 14:36:59 +09:00
|
|
|
|
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") || [];
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if ((world.tarinai || []).some(tarinai => tarinai && !tarinai.dead)) return false;
|
|
|
|
|
|
if ((world.ants || []).some(ant => ant && !ant.dead)) return false;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if ((world.residues || []).some(residue => residue && !residue.dead && Math.max(0, Number(residue.amount ?? 1) || 0) > 0)) return false;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("firstAidHeals");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS.firstAidHeals) return unlock("town_doctor_50", { ...detail, count, recovered });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (type === "fight_mochi" && tarinai && world) {
|
|
|
|
|
|
tarinai._achievementFightMochiAt = Math.max(0, Number(world.time || 0) || 0);
|
|
|
|
|
|
tarinai._achievementFightMochiWorld = world;
|
|
|
|
|
|
}
|
2026-07-18 22:15:26 +09:00
|
|
|
|
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);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
markProgressMutation();
|
2026-07-18 22:15:26 +09:00
|
|
|
|
if (progress.mysteryDrugTarinaiIds.length >= MAD_SCIENTIST_TARGET) {
|
|
|
|
|
|
changed = unlock("mad_scientist", { ...detail, world, tarinai, count: progress.mysteryDrugTarinaiIds.length }) || changed;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
saveState();
|
|
|
|
|
|
renderIfDialogOpen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (MEDICINE_LEDGER_TYPES.includes(type) && !state.unlocked.medicine_ledger_all) {
|
|
|
|
|
|
const progress = ensureProgress();
|
|
|
|
|
|
if (!progress.medicineTypes.includes(type)) {
|
|
|
|
|
|
progress.medicineTypes = [...progress.medicineTypes, type];
|
2026-07-29 20:52:11 +09:00
|
|
|
|
markProgressMutation();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (world) recordIntervention({ ...detail, world, tool: "direct_care" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const tarinai = detail.tarinai || null;
|
|
|
|
|
|
const type = String(detail.type || detail.item?.type || "");
|
|
|
|
|
|
if (!world || !tarinai) return false;
|
|
|
|
|
|
const foodGift = Boolean(detail.foodGift);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const treatment = Boolean(detail.beneficialRecovery);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
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) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("fightMochiFightCount");
|
|
|
|
|
|
if (count >= CHAOS_FIGHT_TARGET) {
|
|
|
|
|
|
changed = unlock("chaos_seeker_666_fights", { ...detail, world, count, fightMochiInfluenced: true }) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const now = Math.max(0, Number(world.time || 0) || 0);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const candidates = [detail.a, detail.b].filter(Boolean);
|
|
|
|
|
|
for (const tarinai of candidates) {
|
|
|
|
|
|
const givenAtRaw = tarinai._achievementFightMochiAt;
|
|
|
|
|
|
const givenAt = givenAtRaw == null ? NaN : Number(givenAtRaw);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const validCurrentSessionMochi = tarinai._achievementFightMochiWorld === world;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (validCurrentSessionMochi && Number.isFinite(givenAt) && now >= givenAt && now - givenAt <= 30.0001) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
changed = unlock("fuel_to_fire", { ...detail, world, tarinai, elapsed: now - givenAt }) || changed;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return changed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 16:27:56 +09:00
|
|
|
|
function recordShotFired(detail = {}) {
|
|
|
|
|
|
if (state.unlocked.sniper_333_shots) return false;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("shotCount");
|
|
|
|
|
|
if (count >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count });
|
2026-07-15 16:27:56 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 16:27:56 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordLoveMochiBirth(detail = {}) {
|
|
|
|
|
|
if (state.unlocked.fertility_seeker_721_love_births || !detail.loveMochiInfluenced) return false;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("loveMochiBirthCount");
|
|
|
|
|
|
if (count >= FERTILITY_BIRTH_TARGET) {
|
|
|
|
|
|
return unlock("fertility_seeker_721_love_births", { ...detail, count });
|
2026-07-15 16:27:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-15 16:27:56 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function evaluateMegalopolis(world = global.world, detail = {}) {
|
|
|
|
|
|
if (!world || state.unlocked.megalopolis) return false;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const aliveCount = playstylePopulationCount(world);
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (aliveCount < 100) return false;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const duplicatorCount = playstyleItemCount(world, "duplicator");
|
|
|
|
|
|
const shelterCount = playstyleItemCount(world, "grass_bed")
|
|
|
|
|
|
+ playstyleItemCount(world, "nest_box")
|
|
|
|
|
|
+ playstyleItemCount(world, "pipe");
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (duplicatorCount < 10 || shelterCount < 15) return false;
|
|
|
|
|
|
return unlock("megalopolis", { ...detail, world, aliveCount, duplicatorCount, shelterCount });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
function resetWorldProgress(world = global.world) {
|
|
|
|
|
|
if (!world) return false;
|
2026-07-17 12:25:23 +09:00
|
|
|
|
runtimeByWorld.delete(world);
|
|
|
|
|
|
dangerFightDeathsByWorld.delete(world);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
world.achievementEnemyAntKills = 0;
|
|
|
|
|
|
world.achievementSelfSufficientStartAt = -1;
|
|
|
|
|
|
world.achievementNoDeathStartAt = -1;
|
|
|
|
|
|
world.achievementHappyStartAt = -1;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
world.achievementEliteFewStartAt = -1;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
for (const tarinai of world.tarinai || []) {
|
|
|
|
|
|
if (!tarinai) continue;
|
|
|
|
|
|
tarinai._achievementFightMochiAt = null;
|
|
|
|
|
|
tarinai._achievementFightMochiWorld = null;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
tarinai._achievementDirectFeedCount = 0;
|
|
|
|
|
|
tarinai._achievementDirectTreatmentCount = 0;
|
|
|
|
|
|
tarinai._achievementSaunaHotAt = null;
|
|
|
|
|
|
tarinai._achievementSaunaWasHot = false;
|
|
|
|
|
|
tarinai._achievementHeldStartedAt = null;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
tarinai._achievementFightSession = null;
|
2026-07-18 13:06:02 +09:00
|
|
|
|
}
|
|
|
|
|
|
for (const item of world.items || []) {
|
|
|
|
|
|
if (!item) continue;
|
|
|
|
|
|
if (item.type === "sticky_bomb") item.stickyBombPassCount = 0;
|
|
|
|
|
|
item._achievementPlacedAt = null;
|
2026-07-15 16:27:56 +09:00
|
|
|
|
}
|
2026-07-29 20:52:11 +09:00
|
|
|
|
// Mystery-drug targets are a lifetime achievement and intentionally survive world resets.
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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 });
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (child?.isZunchiSlave) recordNaturalStatus("slave", { ...detail, world, tarinai: child, bornWithStatus: true });
|
|
|
|
|
|
if (child?.isTarinaiChampion) recordNaturalStatus("king", { ...detail, world, tarinai: child, bornWithStatus: true });
|
2026-07-15 16:27:56 +09:00
|
|
|
|
evaluateMegalopolis(world, detail);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!state.unlocked.great_mother_1000_births) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("birthCount");
|
2026-07-15 14:44:29 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS.birthCount) unlock("great_mother_1000_births", { ...detail, count });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (!state.unlocked.memento_mori) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("totalDeathCount");
|
|
|
|
|
|
if (count >= MEMENTO_MORI_TARGET) unlock("memento_mori", { ...detail, count });
|
2026-07-18 13:06:02 +09:00
|
|
|
|
else { scheduleProgressSave(); renderIfDialogOpen(); }
|
|
|
|
|
|
}
|
2026-07-16 22:12:03 +09:00
|
|
|
|
const reason = String(detail.reason || detail.tarinai?.deathReason || "");
|
|
|
|
|
|
const lifespanDeath = /\u5bff\u547d|\u5929\u5bff/.test(reason);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
const world = detail.world || detail.tarinai?.world || null;
|
|
|
|
|
|
if (world) {
|
2026-07-15 16:27:56 +09:00
|
|
|
|
evaluateMegalopolis(world, detail);
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const aliveAfterDeath = playstylePopulationCount(world);
|
2026-07-17 12:25:23 +09:00
|
|
|
|
if (!state.unlocked.enemy_enemy_friend && aliveAfterDeath < 30) world.achievementEnemyAntKills = 0;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (!lifespanDeath) {
|
|
|
|
|
|
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-08-08 15:31:31 +09:00
|
|
|
|
if (tarinai?.isAcquiredTarinai) unlock("ochibi_natural_slave", detail);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (recordNaturalGeneration(world, tarinai, "_achievementNaturalSlaveLineageDepth", 5)) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
unlock("natural_zunchi_slave_5_generations", detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
evaluateStatusEffectAchievements(tarinai, detail);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (kind === "king") {
|
|
|
|
|
|
unlock("natural_tarinai_king", detail);
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (recordNaturalGeneration(world, tarinai, "_achievementNaturalKingLineageDepth", 3)) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("directFeedCount");
|
2026-07-20 14:36:59 +09:00
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
renderIfDialogOpen();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (count >= ADDITIVE_PROGRESS_CAPS.directFeedCount) return unlock("direct_feed_33", { ...detail, world, count });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordIgnition(detail = {}) {
|
|
|
|
|
|
return unlock("ignite_during_birth_ritual", detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("statsButtonPressCount");
|
|
|
|
|
|
if (count >= STATISTICIAN_TARGET) {
|
|
|
|
|
|
return unlock("statistician", { ...detail, count });
|
2026-07-18 13:06:02 +09:00
|
|
|
|
}
|
|
|
|
|
|
saveState();
|
|
|
|
|
|
renderIfDialogOpen();
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordPushNotificationsEnabled(detail = {}) {
|
|
|
|
|
|
if (state.unlocked.well_informed) return false;
|
|
|
|
|
|
return unlock("well_informed", detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordManualTarinaiAdded(detail = {}) {
|
2026-07-18 22:15:26 +09:00
|
|
|
|
// 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" });
|
2026-07-18 13:06:02 +09:00
|
|
|
|
if (state.unlocked.lively_making) return false;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const count = incrementProgressCounter("manualTarinaiAddedCount");
|
|
|
|
|
|
if (count >= LIVELY_MAKING_TARGET) {
|
|
|
|
|
|
return unlock("lively_making", { ...detail, count });
|
2026-07-18 13:06:02 +09:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
|
evaluateEvent(worldRef, "timer", { mood });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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
|
2026-07-16 22:12:03 +09:00
|
|
|
|
|| !state.unlocked.safe_colony_25_5_minutes
|
|
|
|
|
|
|| !state.unlocked.ant_nest_without_tarinai;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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) : [];
|
2026-07-18 13:06:02 +09:00
|
|
|
|
const aliveCount = alive.length;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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) {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const placedAlive = Math.max(0, Number(worldRef.achievementPlayerPlacedActiveCount || 0) || 0);
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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) {
|
2026-07-20 14:36:59 +09:00
|
|
|
|
if (alive.length >= 20 && playstyleItemCount(worldRef, "duplicator") === 0) {
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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 || "");
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
|
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 });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (!state.unlocked.sandbox_five_toilets) evaluateSandbox(worldRef, { source: "world-evaluation" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!state.unlocked.ants_alive_25) recordAntPopulation(worldRef);
|
2026-07-16 22:12:03 +09:00
|
|
|
|
if (!state.unlocked.ant_nest_without_tarinai) evaluateAntNestWithoutTarinai(worldRef, { source: "world-evaluation" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (!state.unlocked.below_absolute_zero_item) recordBelowAbsoluteZeroItem(worldRef);
|
|
|
|
|
|
if (!state.unlocked.clean_freak_robot_only) evaluateCleanFreak(worldRef, { source: "world-evaluation" });
|
2026-07-15 16:27:56 +09:00
|
|
|
|
if (!state.unlocked.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resetAchievements() {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
await persistenceReadyPromise;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const identity = currentServerIdentity();
|
|
|
|
|
|
persistenceGeneration += 1;
|
|
|
|
|
|
persistenceRevision = 0;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
state = blankState();
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (identity) state.serverIdentity = { ...identity };
|
2026-07-15 14:44:29 +09:00
|
|
|
|
state.resetPending = true;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
progressMutationRevision = 0;
|
|
|
|
|
|
progressSyncedRevision = 0;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
runtimeByWorld = new WeakMap();
|
|
|
|
|
|
dangerFightDeathsByWorld = new WeakMap();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
wireShockTrackers = new WeakMap();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
toastQueue = [];
|
|
|
|
|
|
toastActive = false;
|
2026-07-16 22:12:03 +09:00
|
|
|
|
emptyClickTimes = [];
|
|
|
|
|
|
groundChangeTimes = [];
|
2026-07-29 20:52:11 +09:00
|
|
|
|
continuousPlayAccumulatedMs = 0;
|
|
|
|
|
|
continuousPlayLastHeartbeatAt = Date.now();
|
|
|
|
|
|
continuousPlayActive = !document.hidden;
|
2026-07-15 14:44:29 +09:00
|
|
|
|
if (global.world) {
|
|
|
|
|
|
resetWorldProgress(global.world);
|
|
|
|
|
|
global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
saveState();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
recordDailyPlay({ source: "reset" });
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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() {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (global.TarinaiManagedDialogs?.open) global.TarinaiManagedDialogs.open(dom.dialog, dom.button);
|
|
|
|
|
|
else {
|
|
|
|
|
|
dom.dialog?.classList.remove("hidden");
|
|
|
|
|
|
dom.dialog?.setAttribute("aria-hidden", "false");
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
render({ forceList: true });
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (!global.TarinaiManagedDialogs?.open) dom.closeIconButton?.focus?.();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
void syncPending({ full: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeDialog() {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
if (global.TarinaiManagedDialogs?.close) global.TarinaiManagedDialogs.close(dom.dialog);
|
|
|
|
|
|
else {
|
|
|
|
|
|
dom.dialog?.classList.add("hidden");
|
|
|
|
|
|
dom.dialog?.setAttribute("aria-hidden", "true");
|
|
|
|
|
|
dom.button?.focus?.();
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (document.hidden) {
|
|
|
|
|
|
if (continuousPlayActive && now >= continuousPlayLastHeartbeatAt) {
|
|
|
|
|
|
continuousPlayAccumulatedMs += now - continuousPlayLastHeartbeatAt;
|
|
|
|
|
|
}
|
|
|
|
|
|
continuousPlayActive = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
continuousPlayLastHeartbeatAt = now;
|
|
|
|
|
|
continuousPlayActive = true;
|
|
|
|
|
|
}
|
2026-07-15 14:44:29 +09:00
|
|
|
|
});
|
|
|
|
|
|
if (typeof global.setInterval === "function") {
|
|
|
|
|
|
global.setInterval(() => {
|
|
|
|
|
|
if (!document.hidden) recordContinuousPlay();
|
2026-07-16 22:12:03 +09:00
|
|
|
|
recordDailyPlay();
|
|
|
|
|
|
recordLowFps();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
recordHeldTarinai(global.world, Date.now());
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
if (!DEBUG_MODE) global.setInterval(() => void syncPending({ full: true }), FULL_SYNC_INTERVAL_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dom.button?.addEventListener("click", () => {
|
2026-07-29 20:52:11 +09:00
|
|
|
|
global.TarinaiAudio?.uiClick?.();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-07-29 20:52:11 +09:00
|
|
|
|
dom.dialog?.addEventListener("tarinai:dialog-close-request", closeDialog);
|
|
|
|
|
|
global.addEventListener?.("pagehide", () => {
|
|
|
|
|
|
saveState();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
|
|
|
|
if (document.hidden) saveState();
|
2026-07-15 14:44:29 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-29 20:52:11 +09:00
|
|
|
|
importWorldAchievementProgress,
|
2026-07-15 14:44:29 +09:00
|
|
|
|
evaluateCleanFreak,
|
|
|
|
|
|
recordFightStarted,
|
2026-07-15 16:27:56 +09:00
|
|
|
|
recordShotFired,
|
|
|
|
|
|
recordLoveMochiBirth,
|
2026-07-18 13:06:02 +09:00
|
|
|
|
recordFavoriteTarinai,
|
|
|
|
|
|
recordStatsButtonPress,
|
|
|
|
|
|
recordPushNotificationsEnabled,
|
|
|
|
|
|
recordManualTarinaiAdded,
|
2026-07-15 14:44:29 +09:00
|
|
|
|
resetWorldProgress,
|
|
|
|
|
|
recordLinkPlaced,
|
|
|
|
|
|
recordSignalActivation,
|
|
|
|
|
|
recordAntPopulation,
|
|
|
|
|
|
recordAntKilled,
|
2026-07-16 22:12:03 +09:00
|
|
|
|
recordStickyBombPass,
|
|
|
|
|
|
recordCircuitBoardSignalTransfer,
|
|
|
|
|
|
recordWirePowerState,
|
|
|
|
|
|
recordWireShock,
|
2026-07-15 14:44:29 +09:00
|
|
|
|
recordSaveSlotsFilled,
|
2026-07-16 22:12:03 +09:00
|
|
|
|
recordEmptyClick,
|
|
|
|
|
|
recordGroundChange,
|
2026-07-15 14:44:29 +09:00
|
|
|
|
recordDamageSource,
|
|
|
|
|
|
recordAntDamageSource,
|
2026-07-15 16:27:56 +09:00
|
|
|
|
exportSpellState,
|
|
|
|
|
|
importSpellState,
|
2026-07-15 14:44:29 +09:00
|
|
|
|
evaluateWorld,
|
2026-07-18 13:06:02 +09:00
|
|
|
|
evaluateEvent,
|
2026-07-15 14:44:29 +09:00
|
|
|
|
recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); },
|
2026-07-20 14:36:59 +09:00
|
|
|
|
recordZunchiCollisionDeath(detail = {}) { return unlock("zunchi_collision_death", detail); },
|
2026-07-15 14:44:29 +09:00
|
|
|
|
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },
|
|
|
|
|
|
});
|
|
|
|
|
|
global.TarinaiAchievements = api;
|
2026-07-29 20:52:11 +09:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-07-15 14:44:29 +09:00
|
|
|
|
})(window);
|