502 lines
21 KiB
PHP
502 lines
21 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
const ACHIEVEMENT_IDS = [
|
|
'first_birth',
|
|
'natural_zunchi_slave',
|
|
'natural_tarinai_king',
|
|
'self_zunchi_death',
|
|
'natural_zunchi_slave_5_generations',
|
|
'natural_tarinai_king_3_generations',
|
|
'death_50_in_10_seconds',
|
|
'birth_50_in_60_seconds',
|
|
'great_mother_1000_births',
|
|
'lifespan_completed',
|
|
'soccer_ball_death',
|
|
'fight_pair_danger_kill',
|
|
'all_non_sleep_diseased_25',
|
|
'colony_happy',
|
|
'direct_feed_33',
|
|
'ignite_during_birth_ritual',
|
|
'laxative_starvation',
|
|
'idle_observer_5_minutes',
|
|
'placed_objects_100',
|
|
'mechanized_industry',
|
|
'safe_colony_25_5_minutes',
|
|
'ants_alive_25',
|
|
'ants_killed_100',
|
|
'secret_collection_9_slots',
|
|
'pause_spam_4_in_1_second',
|
|
'continuous_play_1_hour',
|
|
'below_absolute_zero_item',
|
|
'undo_mass_revival',
|
|
'robot_cleaner_100',
|
|
'held_30_seconds',
|
|
'minimalist_happy',
|
|
'overprotective',
|
|
'self_sufficient',
|
|
'unplanned_city_30',
|
|
'sauna_cold_plunge',
|
|
'rain_shelter_all',
|
|
'medicine_ledger_all',
|
|
'mercury_lifespan',
|
|
'enemy_enemy_friend',
|
|
'revolution',
|
|
'fuel_to_fire',
|
|
'undo_20',
|
|
'redo_20',
|
|
'eternal_history_generation_10',
|
|
'king_full_satisfaction',
|
|
'slave_zero_satisfaction',
|
|
'town_doctor_50',
|
|
'poke_plushie_fling',
|
|
'chaos_seeker_666_fights',
|
|
'clean_freak_robot_only',
|
|
'true_tarinai_observer',
|
|
'sniper_333_shots',
|
|
'megalopolis',
|
|
'fertility_seeker_721_love_births',
|
|
'low_fps_single_digit',
|
|
'continuous_play_24_hours',
|
|
'sandbox_five_toilets',
|
|
'park_ground_changed',
|
|
'ground_change_4_in_1_second',
|
|
'ant_nest_without_tarinai',
|
|
'sticky_bomb_15_passes',
|
|
'daily_play_7_days',
|
|
'wire_shock_7_tarinai',
|
|
'information_industry',
|
|
];
|
|
const DATA_DIR_NAME = '.achievement_data';
|
|
const DATA_FILE_NAME = 'state.json';
|
|
const LOCK_FILE_NAME = 'state.lock';
|
|
const STATE_SCHEMA_VERSION = 3;
|
|
const MAX_REQUEST_BYTES = 131072;
|
|
|
|
function respond(array $payload, int $status = 200): never {
|
|
http_response_code($status);
|
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
|
exit;
|
|
}
|
|
|
|
function valid_player_id(string $value): bool {
|
|
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value) === 1;
|
|
}
|
|
|
|
function empty_state(): array {
|
|
return ['players' => [], 'unlocks' => []];
|
|
}
|
|
|
|
function compact_player_id(string $value): string {
|
|
$hex = str_replace('-', '', strtolower($value));
|
|
if (strlen($hex) !== 32 || !ctype_xdigit($hex)) return '';
|
|
$binary = hex2bin($hex);
|
|
if ($binary === false) return '';
|
|
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
|
}
|
|
|
|
function expand_player_id(string $value): string {
|
|
if (strlen($value) !== 22 || preg_match('/^[A-Za-z0-9_-]{22}$/', $value) !== 1) return '';
|
|
$binary = base64_decode(strtr($value, '-_', '+/') . '==', true);
|
|
if ($binary === false || strlen($binary) !== 16) return '';
|
|
$hex = bin2hex($binary);
|
|
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12);
|
|
}
|
|
|
|
function detect_state_format(mixed $value): string {
|
|
if (!is_array($value)) return 'invalid';
|
|
if ($value === []) return 'empty';
|
|
if (array_is_list($value)) {
|
|
return (($value[0] ?? null) === STATE_SCHEMA_VERSION
|
|
&& isset($value[1], $value[2], $value[3], $value[4])
|
|
&& is_array($value[2]) && is_array($value[3]) && is_array($value[4]))
|
|
? 'v3' : 'invalid';
|
|
}
|
|
if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) {
|
|
return 'v2';
|
|
}
|
|
if (array_key_exists('players', $value) || array_key_exists('unlocks', $value)) return 'verbose';
|
|
return 'invalid';
|
|
}
|
|
|
|
function normalize_state(mixed $value): array {
|
|
if (!is_array($value)) return empty_state();
|
|
$players = [];
|
|
$unlocks = [];
|
|
|
|
// v3 positional form: [version, baseTime, versions, players, unlocks].
|
|
if (($value[0] ?? null) === STATE_SCHEMA_VERSION && isset($value[1], $value[2], $value[3], $value[4])
|
|
&& is_array($value[2]) && is_array($value[3]) && is_array($value[4])) {
|
|
$baseTime = max(1, (int)$value[1]);
|
|
$versions = array_map(static fn($entry): string => substr((string)$entry, 0, 32), $value[2]);
|
|
$playerIds = [];
|
|
foreach ($value[3] as $row) {
|
|
if (!is_array($row) || !isset($row[0]) || !is_string($row[0])) continue;
|
|
$id = expand_player_id($row[0]);
|
|
if (!valid_player_id($id)) continue;
|
|
$firstSeen = $baseTime + max(0, (int)($row[1] ?? 0));
|
|
$lastSeen = $firstSeen + max(0, (int)($row[2] ?? 0));
|
|
$versionIndex = max(0, (int)($row[3] ?? 0));
|
|
$playerIds[] = $id;
|
|
$players[$id] = [
|
|
'firstSeen' => max(1, $firstSeen),
|
|
'lastSeen' => max(1, $lastSeen),
|
|
'gameVersion' => $versions[$versionIndex] ?? '',
|
|
];
|
|
}
|
|
foreach ($value[4] as $row) {
|
|
if (!is_array($row) || count($row) < 3) continue;
|
|
$achievementIndex = (int)$row[0];
|
|
$achievementId = ACHIEVEMENT_IDS[$achievementIndex] ?? '';
|
|
if ($achievementId === '') continue;
|
|
$records = [];
|
|
$count = count($row);
|
|
for ($i = 1; $i + 1 < $count; $i += 2) {
|
|
$playerIndex = (int)$row[$i];
|
|
if (!isset($playerIds[$playerIndex])) continue;
|
|
$records[$playerIds[$playerIndex]] = $baseTime + max(0, (int)$row[$i + 1]);
|
|
}
|
|
if ($records) $unlocks[$achievementId] = $records;
|
|
}
|
|
// v2 is an on-read migration source and is always rewritten as v3.
|
|
} elseif (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) {
|
|
$playerIds = [];
|
|
foreach ($value['p'] as $row) {
|
|
if (!is_array($row) || !isset($row[0]) || !is_string($row[0]) || !valid_player_id($row[0])) continue;
|
|
$id = strtolower($row[0]);
|
|
$playerIds[] = $id;
|
|
$players[$id] = [
|
|
'firstSeen' => max(1, (int)($row[1] ?? 1)),
|
|
'lastSeen' => max(1, (int)($row[2] ?? ($row[1] ?? 1))),
|
|
'gameVersion' => substr((string)($row[3] ?? ''), 0, 32),
|
|
];
|
|
}
|
|
foreach ($value['u'] as $achievementId => $flat) {
|
|
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue;
|
|
$records = [];
|
|
$count = count($flat);
|
|
for ($i = 0; $i + 1 < $count; $i += 2) {
|
|
$playerIndex = (int)$flat[$i];
|
|
if (!isset($playerIds[$playerIndex])) continue;
|
|
$records[$playerIds[$playerIndex]] = max(1, (int)$flat[$i + 1]);
|
|
}
|
|
if ($records) $unlocks[$achievementId] = $records;
|
|
}
|
|
} else {
|
|
$rawPlayers = isset($value['players']) && is_array($value['players']) ? $value['players'] : [];
|
|
foreach ($rawPlayers as $id => $player) {
|
|
if (!is_string($id) || !valid_player_id($id) || !is_array($player)) continue;
|
|
$players[strtolower($id)] = [
|
|
'firstSeen' => max(1, (int)($player['firstSeen'] ?? 1)),
|
|
'lastSeen' => max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))),
|
|
'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32),
|
|
];
|
|
}
|
|
$rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : [];
|
|
foreach ($rawUnlocks as $achievementId => $records) {
|
|
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($records)) continue;
|
|
foreach ($records as $id => $timestamp) {
|
|
$normalizedId = strtolower((string)$id);
|
|
if (!isset($players[$normalizedId])) continue;
|
|
$unlocks[$achievementId][$normalizedId] = max(1, (int)$timestamp);
|
|
}
|
|
}
|
|
}
|
|
|
|
$legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first'])
|
|
? $unlocks['signal_automation_first'] : [];
|
|
$legacyLinks = isset($unlocks['link_craftsman']) && is_array($unlocks['link_craftsman'])
|
|
? $unlocks['link_craftsman'] : [];
|
|
if ($legacySignal && $legacyLinks) {
|
|
if (!isset($unlocks['mechanized_industry']) || !is_array($unlocks['mechanized_industry'])) {
|
|
$unlocks['mechanized_industry'] = [];
|
|
}
|
|
foreach ($legacySignal as $playerId => $signalTime) {
|
|
if (!isset($legacyLinks[$playerId])) continue;
|
|
$unlocks['mechanized_industry'][$playerId] = max((int)$signalTime, (int)$legacyLinks[$playerId]);
|
|
}
|
|
}
|
|
unset($unlocks['signal_automation_first'], $unlocks['link_craftsman']);
|
|
|
|
return ['players' => $players, 'unlocks' => $unlocks];
|
|
}
|
|
|
|
function compact_state(array $state): array {
|
|
$playerIds = array_keys($state['players']);
|
|
sort($playerIds, SORT_STRING);
|
|
$playerIndex = [];
|
|
$timestamps = [];
|
|
$versionCounts = [];
|
|
foreach ($playerIds as $id) {
|
|
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
|
$timestamps[] = max(1, (int)($player['firstSeen'] ?? 1));
|
|
$timestamps[] = max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1)));
|
|
$version = substr((string)($player['gameVersion'] ?? ''), 0, 32);
|
|
$versionCounts[$version] = ($versionCounts[$version] ?? 0) + 1;
|
|
}
|
|
foreach ($state['unlocks'] as $achievementId => $records) {
|
|
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue;
|
|
foreach ($records as $timestamp) $timestamps[] = max(1, (int)$timestamp);
|
|
}
|
|
$baseTime = $timestamps ? min($timestamps) : 1;
|
|
uksort($versionCounts, static function (string $a, string $b) use ($versionCounts): int {
|
|
$countOrder = ($versionCounts[$b] ?? 0) <=> ($versionCounts[$a] ?? 0);
|
|
return $countOrder !== 0 ? $countOrder : strcmp($a, $b);
|
|
});
|
|
$versions = array_keys($versionCounts);
|
|
if (!$versions) $versions = [''];
|
|
$versionIndex = array_flip($versions);
|
|
|
|
$players = [];
|
|
foreach ($playerIds as $index => $id) {
|
|
$playerIndex[$id] = $index;
|
|
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
|
$firstSeen = max(1, (int)($player['firstSeen'] ?? 1));
|
|
$lastSeen = max($firstSeen, (int)($player['lastSeen'] ?? $firstSeen));
|
|
$version = substr((string)($player['gameVersion'] ?? ''), 0, 32);
|
|
$row = [compact_player_id($id), $firstSeen - $baseTime, $lastSeen - $firstSeen];
|
|
$indexValue = (int)($versionIndex[$version] ?? 0);
|
|
if ($indexValue !== 0) $row[] = $indexValue;
|
|
$players[] = $row;
|
|
}
|
|
|
|
$unlocks = [];
|
|
foreach (ACHIEVEMENT_IDS as $achievementIndex => $achievementId) {
|
|
$records = $state['unlocks'][$achievementId] ?? null;
|
|
if (!is_array($records) || !$records) continue;
|
|
$pairs = [];
|
|
foreach ($records as $id => $timestamp) {
|
|
if (!isset($playerIndex[$id])) continue;
|
|
$pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime];
|
|
}
|
|
usort($pairs, static fn(array $a, array $b): int => $a[0] <=> $b[0]);
|
|
if (!$pairs) continue;
|
|
$row = [$achievementIndex];
|
|
foreach ($pairs as [$indexValue, $timestampDelta]) {
|
|
$row[] = $indexValue;
|
|
$row[] = max(0, $timestampDelta);
|
|
}
|
|
$unlocks[] = $row;
|
|
}
|
|
return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];
|
|
}
|
|
|
|
function encode_compact_state(array $state): string {
|
|
try {
|
|
return json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $error) {
|
|
throw new RuntimeException('storage_encode_failed', 0, $error);
|
|
}
|
|
}
|
|
|
|
function decode_stored_state(string $contents): array {
|
|
if (trim($contents) === '') return [empty_state(), 'empty'];
|
|
try {
|
|
$decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $error) {
|
|
throw new RuntimeException('storage_corrupt', 0, $error);
|
|
}
|
|
$format = detect_state_format($decoded);
|
|
if ($format === 'invalid') throw new RuntimeException('storage_corrupt');
|
|
return [normalize_state($decoded), $format];
|
|
}
|
|
|
|
function atomic_write_state(string $dataPath, string $encoded): void {
|
|
$dataDir = dirname($dataPath);
|
|
$tempPath = tempnam($dataDir, DATA_FILE_NAME . '.tmp.');
|
|
if ($tempPath === false) throw new RuntimeException('storage_unavailable');
|
|
$handle = @fopen($tempPath, 'wb');
|
|
if ($handle === false) {
|
|
@unlink($tempPath);
|
|
throw new RuntimeException('storage_unavailable');
|
|
}
|
|
try {
|
|
$length = strlen($encoded);
|
|
$offset = 0;
|
|
while ($offset < $length) {
|
|
$written = fwrite($handle, substr($encoded, $offset));
|
|
if ($written === false || $written === 0) throw new RuntimeException('storage_write_failed');
|
|
$offset += $written;
|
|
}
|
|
if (!fflush($handle)) throw new RuntimeException('storage_write_failed');
|
|
if (function_exists('fsync') && !fsync($handle)) throw new RuntimeException('storage_write_failed');
|
|
} catch (Throwable $error) {
|
|
fclose($handle);
|
|
@unlink($tempPath);
|
|
throw $error;
|
|
}
|
|
fclose($handle);
|
|
@chmod($tempPath, 0664);
|
|
if (!@rename($tempPath, $dataPath)) {
|
|
@unlink($tempPath);
|
|
throw new RuntimeException('storage_write_failed');
|
|
}
|
|
}
|
|
|
|
function build_summary(array $state): array {
|
|
$total = count($state['players']);
|
|
$achievements = [];
|
|
foreach (ACHIEVEMENT_IDS as $id) {
|
|
$unlocked = isset($state['unlocks'][$id]) && is_array($state['unlocks'][$id])
|
|
? count($state['unlocks'][$id])
|
|
: 0;
|
|
$percentage = $total > 0 ? round(($unlocked / $total) * 100, 1) : 0.0;
|
|
$achievements[$id] = [
|
|
'unlockedPlayers' => $unlocked,
|
|
'percentage' => $percentage,
|
|
];
|
|
}
|
|
return [
|
|
'ok' => true,
|
|
'totalPlayers' => $total,
|
|
'achievements' => $achievements,
|
|
];
|
|
}
|
|
|
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
|
if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
|
|
|
$payload = [];
|
|
if ($method === 'POST') {
|
|
$declaredLength = max(0, (int)($_SERVER['CONTENT_LENGTH'] ?? 0));
|
|
if ($declaredLength > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413);
|
|
$raw = file_get_contents('php://input', false, null, 0, MAX_REQUEST_BYTES + 1);
|
|
if (!is_string($raw) || strlen($raw) > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413);
|
|
try {
|
|
$decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException) {
|
|
respond(['ok' => false, 'error' => 'invalid_json'], 400);
|
|
}
|
|
if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400);
|
|
$payload = $decoded;
|
|
}
|
|
|
|
$action = $method === 'POST'
|
|
? (string)($payload['action'] ?? '')
|
|
: (string)($_GET['action'] ?? 'summary');
|
|
$playerId = $method === 'POST'
|
|
? (string)($payload['playerId'] ?? '')
|
|
: (string)($_GET['playerId'] ?? '');
|
|
|
|
if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) {
|
|
respond(['ok' => false, 'error' => 'invalid_action'], 400);
|
|
}
|
|
if ($method === 'GET' && $action !== 'summary') {
|
|
respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
|
}
|
|
if (!valid_player_id($playerId)) {
|
|
respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
|
}
|
|
$playerId = strtolower($playerId);
|
|
$syncUnlocks = [];
|
|
$completionistEligible = null;
|
|
if ($action === 'unlock') {
|
|
$achievementId = (string)($payload['achievementId'] ?? '');
|
|
if (!in_array($achievementId, ACHIEVEMENT_IDS, true)) {
|
|
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
|
}
|
|
} elseif ($action === 'sync') {
|
|
$submittedUnlocks = $payload['unlocked'] ?? null;
|
|
if (!is_array($submittedUnlocks) || count($submittedUnlocks) > count(ACHIEVEMENT_IDS)) {
|
|
respond(['ok' => false, 'error' => 'invalid_unlocks'], 400);
|
|
}
|
|
if (array_key_exists('completionistEligible', $payload)) {
|
|
if (!is_bool($payload['completionistEligible'])) {
|
|
respond(['ok' => false, 'error' => 'invalid_completionist_state'], 400);
|
|
}
|
|
$completionistEligible = $payload['completionistEligible'];
|
|
}
|
|
foreach ($submittedUnlocks as $achievementId => $timestamp) {
|
|
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true)) {
|
|
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
|
}
|
|
if (!is_int($timestamp) && !is_float($timestamp) && !is_string($timestamp)) {
|
|
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
|
}
|
|
$numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT);
|
|
if ($numericTimestamp === false || !is_finite((float)$numericTimestamp) || $numericTimestamp <= 0) {
|
|
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
|
}
|
|
$syncUnlocks[$achievementId] = (float)$numericTimestamp;
|
|
}
|
|
if ($completionistEligible === false) unset($syncUnlocks['true_tarinai_observer']);
|
|
}
|
|
|
|
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME;
|
|
if (!is_dir($dataDir) && !mkdir($dataDir, 0775, true) && !is_dir($dataDir)) {
|
|
respond(['ok' => false, 'error' => 'storage_unavailable'], 503);
|
|
}
|
|
$dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME;
|
|
$lockPath = $dataDir . DIRECTORY_SEPARATOR . LOCK_FILE_NAME;
|
|
$lockHandle = @fopen($lockPath, 'c');
|
|
if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) {
|
|
if (is_resource($lockHandle)) fclose($lockHandle);
|
|
respond(['ok' => false, 'error' => 'storage_locked'], 503);
|
|
}
|
|
|
|
$summary = null;
|
|
$storageError = null;
|
|
try {
|
|
$contents = is_file($dataPath) ? file_get_contents($dataPath) : '';
|
|
if ($contents === false) throw new RuntimeException('storage_unavailable');
|
|
[$state, $sourceFormat] = decode_stored_state($contents);
|
|
$now = time();
|
|
$mutatesPlayer = $action !== 'summary';
|
|
|
|
if ($mutatesPlayer) {
|
|
$player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId])
|
|
? $state['players'][$playerId]
|
|
: ['firstSeen' => $now];
|
|
$player['lastSeen'] = $now;
|
|
$submittedGameVersion = substr((string)($payload['gameVersion'] ?? ''), 0, 32);
|
|
if ($submittedGameVersion !== '' || !isset($player['gameVersion'])) $player['gameVersion'] = $submittedGameVersion;
|
|
$state['players'][$playerId] = $player;
|
|
}
|
|
|
|
if ($action === 'unlock') {
|
|
$achievementId = (string)$payload['achievementId'];
|
|
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
|
|
$state['unlocks'][$achievementId] = [];
|
|
}
|
|
if (!isset($state['unlocks'][$achievementId][$playerId])) {
|
|
$state['unlocks'][$achievementId][$playerId] = $now;
|
|
}
|
|
} elseif ($action === 'sync') {
|
|
if ($completionistEligible === false && isset($state['unlocks']['true_tarinai_observer'][$playerId])) {
|
|
unset($state['unlocks']['true_tarinai_observer'][$playerId]);
|
|
}
|
|
foreach ($syncUnlocks as $achievementId => $submittedTimestamp) {
|
|
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
|
|
$state['unlocks'][$achievementId] = [];
|
|
}
|
|
if (isset($state['unlocks'][$achievementId][$playerId])) continue;
|
|
$timestampSeconds = $submittedTimestamp >= 100000000000
|
|
? (int)floor($submittedTimestamp / 1000)
|
|
: (int)floor($submittedTimestamp);
|
|
$state['unlocks'][$achievementId][$playerId] = max(1, min($now, $timestampSeconds));
|
|
}
|
|
} elseif ($action === 'reset') {
|
|
foreach ($state['unlocks'] as $achievementId => $records) {
|
|
if (is_array($records)) unset($state['unlocks'][$achievementId][$playerId]);
|
|
}
|
|
}
|
|
|
|
$encoded = encode_compact_state($state);
|
|
if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) {
|
|
atomic_write_state($dataPath, $encoded);
|
|
}
|
|
$summary = build_summary($state);
|
|
} catch (RuntimeException $error) {
|
|
$known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed'];
|
|
$storageError = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable';
|
|
} finally {
|
|
flock($lockHandle, LOCK_UN);
|
|
fclose($lockHandle);
|
|
}
|
|
|
|
if ($storageError !== null) respond(['ok' => false, 'error' => $storageError], 503);
|
|
respond(is_array($summary) ? $summary : ['ok' => false, 'error' => 'storage_unavailable'], is_array($summary) ? 200 : 503);
|