1227 lines
60 KiB
PHP
1227 lines
60 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');
|
|
header('Referrer-Policy: no-referrer');
|
|
header('X-Frame-Options: DENY');
|
|
|
|
function load_achievement_catalog(): array {
|
|
$path = __DIR__ . DIRECTORY_SEPARATOR . 'js' . DIRECTORY_SEPARATOR . 'achievement_catalog.js';
|
|
$raw = @file_get_contents($path);
|
|
if (!is_string($raw)) throw new RuntimeException('achievement_catalog_unavailable');
|
|
$prefix = 'window.TARINAI_ACHIEVEMENT_CATALOG = ';
|
|
$trimmed = trim($raw);
|
|
if (!str_starts_with($trimmed, $prefix) || !str_ends_with($trimmed, ';')) {
|
|
throw new RuntimeException('achievement_catalog_invalid');
|
|
}
|
|
$json = substr($trimmed, strlen($prefix), -1);
|
|
try { $catalog = json_decode($json, true, 512, JSON_THROW_ON_ERROR); }
|
|
catch (JsonException) { throw new RuntimeException('achievement_catalog_invalid'); }
|
|
if (!is_array($catalog) || !is_array($catalog['definitions'] ?? null)) {
|
|
throw new RuntimeException('achievement_catalog_invalid');
|
|
}
|
|
return $catalog;
|
|
}
|
|
|
|
$achievementCatalog = load_achievement_catalog();
|
|
$achievementDefinitions = [];
|
|
$achievementIds = [];
|
|
$progressRules = [];
|
|
foreach ($achievementCatalog['definitions'] as $definition) {
|
|
if (!is_array($definition)) continue;
|
|
$id = (string)($definition['id'] ?? '');
|
|
if ($id === '' || isset($achievementDefinitions[$id])) throw new RuntimeException('achievement_catalog_invalid');
|
|
$achievementDefinitions[$id] = $definition;
|
|
$achievementIds[] = $id;
|
|
if (is_array($definition['progress'] ?? null)) $progressRules[$id] = $definition['progress'];
|
|
}
|
|
define('ACHIEVEMENT_IDS', $achievementIds);
|
|
define('ACHIEVEMENT_DEFINITIONS', $achievementDefinitions);
|
|
define('PROGRESS_RULES', $progressRules);
|
|
define('COMPLETIONIST_ID', (string)($achievementCatalog['completionistId'] ?? 'true_tarinai_observer'));
|
|
define('CATALOG_REVISION', (string)($achievementCatalog['revision'] ?? 'unknown'));
|
|
define('ADDITIVE_PROGRESS_CAPS', is_array($achievementCatalog['additiveProgressCaps'] ?? null) ? $achievementCatalog['additiveProgressCaps'] : []);
|
|
define('MEDICINE_LEDGER_TYPES', is_array($achievementCatalog['medicineLedgerTypes'] ?? null) ? array_values($achievementCatalog['medicineLedgerTypes']) : []);
|
|
define('LINK_PROGRESS_TYPES', is_array($achievementCatalog['linkProgressTypes'] ?? null) ? array_values($achievementCatalog['linkProgressTypes']) : []);
|
|
define('MAD_SCIENTIST_TARGET', max(1, (int)($progressRules['mad_scientist']['target'] ?? 10)));
|
|
|
|
const MAX_REQUEST_BYTES = 131072;
|
|
const RATE_WINDOW_SECONDS = 60;
|
|
const RATE_READ_LIMIT = 240;
|
|
const RATE_SESSION_IP_LIMIT = 120;
|
|
const RATE_WRITE_IP_LIMIT = 600;
|
|
const RATE_PLAYER_WRITE_LIMIT = 120;
|
|
const QUALIFY_MIN_ACTIVITY = 2;
|
|
const QUALIFY_MIN_AGE_SECONDS = 30;
|
|
const SESSION_COOKIE_NAME = 'tarinai_achievement_session_v2';
|
|
const SESSION_COOKIE_MAX_AGE = 31536000;
|
|
const IDENTITY_ISSUE_WINDOW_SECONDS = 86400;
|
|
const IDENTITY_ISSUE_LIMIT_PER_IP = 512;
|
|
const SYNC_PROTOCOL_VERSION = 5;
|
|
|
|
|
|
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}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) === 1;
|
|
}
|
|
|
|
function valid_player_secret(string $value): bool {
|
|
return preg_match('/^[0-9a-f]{64}$/i', $value) === 1;
|
|
}
|
|
|
|
function client_ip(): string {
|
|
return substr((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'), 0, 128);
|
|
}
|
|
|
|
function directory_has_achievement_data(string $path): bool {
|
|
return is_file($path . DIRECTORY_SEPARATOR . 'state.json')
|
|
|| is_file($path . DIRECTORY_SEPARATOR . 'summary.json')
|
|
|| is_file($path . DIRECTORY_SEPARATOR . 'session-key.json')
|
|
|| is_dir($path . DIRECTORY_SEPARATOR . 'players');
|
|
}
|
|
|
|
function copy_directory_tree(string $source, string $target): void {
|
|
ensure_directory($target);
|
|
$items = @scandir($source);
|
|
if (!is_array($items)) throw new RuntimeException('storage_unavailable');
|
|
foreach ($items as $name) {
|
|
if ($name === '.' || $name === '..') continue;
|
|
$from = $source . DIRECTORY_SEPARATOR . $name;
|
|
$to = $target . DIRECTORY_SEPARATOR . $name;
|
|
if (is_dir($from)) copy_directory_tree($from, $to);
|
|
elseif (is_file($from) && !is_file($to) && !@copy($from, $to)) throw new RuntimeException('storage_write_failed');
|
|
}
|
|
}
|
|
|
|
function legacy_state_snapshot(string $path): ?array {
|
|
if (!is_file($path)) return null;
|
|
$bytes = @file_get_contents($path);
|
|
if (!is_string($bytes) || trim($bytes) === '') throw new RuntimeException('legacy_state_unreadable');
|
|
return [
|
|
'path' => $path,
|
|
'bytes' => $bytes,
|
|
'hash' => hash('sha256', $bytes),
|
|
'size' => strlen($bytes),
|
|
'mtime' => max(0, (int)@filemtime($path)),
|
|
];
|
|
}
|
|
|
|
function remember_legacy_state_source(string $target, array $source): void {
|
|
atomic_write_json($target . DIRECTORY_SEPARATOR . 'legacy-v4-source.json', [
|
|
'schema' => 1,
|
|
'sourcePath' => (string)$source['path'],
|
|
'sourceHash' => (string)$source['hash'],
|
|
'sourceSize' => (int)$source['size'],
|
|
'sourceMtime' => (int)$source['mtime'],
|
|
'copiedAt' => time(),
|
|
]);
|
|
}
|
|
|
|
function copy_legacy_state_snapshot(string $target, array $source): void {
|
|
atomic_write_bytes($target . DIRECTORY_SEPARATOR . 'state.json', (string)$source['bytes']);
|
|
remember_legacy_state_source($target, $source);
|
|
}
|
|
|
|
function migrate_legacy_data_root(string $target, array $legacyRoots): void {
|
|
$targetCurrent = is_file($target . DIRECTORY_SEPARATOR . 'summary.json')
|
|
|| is_file($target . DIRECTORY_SEPARATOR . 'session-key.json')
|
|
|| is_dir($target . DIRECTORY_SEPARATOR . 'players');
|
|
$targetStatePath = $target . DIRECTORY_SEPARATOR . 'state.json';
|
|
$targetState = legacy_state_snapshot($targetStatePath);
|
|
|
|
$candidates = [];
|
|
$stateCandidates = [];
|
|
foreach ($legacyRoots as $legacy) {
|
|
$path = rtrim((string)$legacy, DIRECTORY_SEPARATOR);
|
|
if ($path === '' || $path === $target || !is_dir($path) || !directory_has_achievement_data($path)) continue;
|
|
$directoryMtime = max(
|
|
(int)@filemtime($path),
|
|
(int)@filemtime($path . DIRECTORY_SEPARATOR . 'state.json'),
|
|
(int)@filemtime($path . DIRECTORY_SEPARATOR . 'summary.json')
|
|
);
|
|
$candidates[$path] = $directoryMtime;
|
|
$snapshot = legacy_state_snapshot($path . DIRECTORY_SEPARATOR . 'state.json');
|
|
if ($snapshot !== null) $stateCandidates[$path] = $snapshot;
|
|
}
|
|
arsort($candidates, SORT_NUMERIC);
|
|
uasort($stateCandidates, static function (array $a, array $b): int {
|
|
$mtime = ((int)$b['mtime']) <=> ((int)$a['mtime']);
|
|
if ($mtime !== 0) return $mtime;
|
|
return ((int)$b['size']) <=> ((int)$a['size']);
|
|
});
|
|
|
|
// When a compact source was copied into a private current root, keep
|
|
// comparing the original source on later requests. This captures final
|
|
// writes made by the legacy server during cut-over instead of freezing the
|
|
// first copied snapshot forever.
|
|
if ($targetState !== null && $stateCandidates) {
|
|
$sourceMarker = read_json_file($target . DIRECTORY_SEPARATOR . 'legacy-v4-source.json', []);
|
|
$migrationMarker = read_json_file($target . DIRECTORY_SEPARATOR . 'legacy-v4-migration.json', []);
|
|
$preferredPath = (string)($sourceMarker['sourcePath'] ?? '');
|
|
$source = $preferredPath !== '' && isset($stateCandidates[dirname($preferredPath)])
|
|
? $stateCandidates[dirname($preferredPath)]
|
|
: reset($stateCandidates);
|
|
if (is_array($source) && !hash_equals((string)$targetState['hash'], (string)$source['hash'])) {
|
|
$trackedSource = $preferredPath !== '' && (string)$source['path'] === $preferredPath;
|
|
$targetIsLastImportedSnapshot = hash_equals(
|
|
strtolower((string)($migrationMarker['sourceHash'] ?? '')),
|
|
strtolower((string)$targetState['hash'])
|
|
);
|
|
$sourceIsNotOlder = (int)$source['mtime'] >= (int)$targetState['mtime'];
|
|
if ($trackedSource || $targetIsLastImportedSnapshot || $sourceIsNotOlder) {
|
|
copy_legacy_state_snapshot($target, $source);
|
|
$targetState = $source;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!$candidates) return;
|
|
|
|
// A prior current build may already have created an empty summary/player
|
|
// tree. Copy the newest compact source into it so the importer can merge it
|
|
// without discarding current records.
|
|
if ($targetCurrent) {
|
|
$source = $stateCandidates ? reset($stateCandidates) : null;
|
|
if (is_array($source)) copy_legacy_state_snapshot($target, $source);
|
|
return;
|
|
}
|
|
|
|
$sourcePath = (string)array_key_first($candidates);
|
|
ensure_directory(dirname($target));
|
|
if (isset($stateCandidates[$sourcePath])) {
|
|
copy_directory_tree($sourcePath, $target);
|
|
remember_legacy_state_source($target, $stateCandidates[$sourcePath]);
|
|
return;
|
|
}
|
|
if (!file_exists($target) && @rename($sourcePath, $target)) {
|
|
// A separate compact source may coexist with a current-format legacy
|
|
// directory. Merge the compact snapshot as well.
|
|
$stateSource = $stateCandidates ? reset($stateCandidates) : null;
|
|
if (is_array($stateSource)) copy_legacy_state_snapshot($target, $stateSource);
|
|
return;
|
|
}
|
|
copy_directory_tree($sourcePath, $target);
|
|
$stateSource = $stateCandidates ? reset($stateCandidates) : null;
|
|
if (is_array($stateSource) && !is_file($targetStatePath)) copy_legacy_state_snapshot($target, $stateSource);
|
|
}
|
|
|
|
function data_root(): string {
|
|
$configured = trim((string)(getenv('TARINAI_ACHIEVEMENT_DATA_DIR') ?: ''));
|
|
if ($configured !== '') {
|
|
$target = rtrim($configured, DIRECTORY_SEPARATOR);
|
|
migrate_legacy_data_root($target, [
|
|
__DIR__ . DIRECTORY_SEPARATOR . '.achievement_data',
|
|
dirname(__DIR__) . DIRECTORY_SEPARATOR . '.achievement_data',
|
|
]);
|
|
return $target;
|
|
}
|
|
|
|
$documentRoot = realpath((string)($_SERVER['DOCUMENT_ROOT'] ?? ''));
|
|
$publicRoot = is_string($documentRoot) && $documentRoot !== '' ? $documentRoot : __DIR__;
|
|
$installPath = realpath(__DIR__) ?: __DIR__;
|
|
$installKey = substr(hash('sha256', $installPath), 0, 16);
|
|
$legacyParent = dirname($publicRoot);
|
|
$legacyRoots = [
|
|
$legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements-' . $installKey,
|
|
__DIR__ . DIRECTORY_SEPARATOR . '.achievement_data',
|
|
$publicRoot . DIRECTORY_SEPARATOR . '.achievement_data',
|
|
];
|
|
$globbed = glob($legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements-*', GLOB_ONLYDIR);
|
|
if (is_array($globbed)) $legacyRoots = array_merge($legacyRoots, $globbed);
|
|
|
|
// Keep local-server data independent of the ZIP extraction directory and
|
|
// listening port. This makes upgrades and side-by-side extracted versions
|
|
// share one persistent achievement identity and server record.
|
|
$xdg = trim((string)(getenv('XDG_DATA_HOME') ?: ''));
|
|
$home = trim((string)(getenv('HOME') ?: getenv('USERPROFILE') ?: ''));
|
|
if ($xdg !== '') $target = rtrim($xdg, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'tarinai-observer' . DIRECTORY_SEPARATOR . 'achievements';
|
|
elseif ($home !== '') $target = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.tarinai-observer' . DIRECTORY_SEPARATOR . 'achievements';
|
|
else $target = $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements';
|
|
|
|
try {
|
|
migrate_legacy_data_root($target, $legacyRoots);
|
|
return $target;
|
|
} catch (RuntimeException) {
|
|
// Hosted environments may not permit writes under HOME. The fallback
|
|
// remains outside the document root and is stable across sibling builds.
|
|
$fallback = $legacyParent . DIRECTORY_SEPARATOR . '.tarinai-achievements';
|
|
migrate_legacy_data_root($fallback, $legacyRoots);
|
|
return $fallback;
|
|
}
|
|
}
|
|
|
|
function ensure_directory(string $path): void {
|
|
if (!is_dir($path) && !mkdir($path, 0770, true) && !is_dir($path)) throw new RuntimeException('storage_unavailable');
|
|
}
|
|
|
|
|
|
function protect_data_root(string $path): void {
|
|
ensure_directory($path);
|
|
$rules = "Options -Indexes\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Deny from all\n</IfModule>\n";
|
|
$htaccess = $path . DIRECTORY_SEPARATOR . '.htaccess';
|
|
if (!is_file($htaccess)) @file_put_contents($htaccess, $rules, LOCK_EX);
|
|
$index = $path . DIRECTORY_SEPARATOR . 'index.html';
|
|
if (!is_file($index)) @file_put_contents($index, '', LOCK_EX);
|
|
}
|
|
|
|
function read_json_file(string $path, array $fallback): array {
|
|
if (!is_file($path)) return $fallback;
|
|
$raw = file_get_contents($path);
|
|
if (!is_string($raw)) throw new RuntimeException('storage_unavailable');
|
|
if (trim($raw) === '') return $fallback;
|
|
try {
|
|
$value = json_decode($raw, true, 128, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $error) {
|
|
throw new RuntimeException('storage_corrupt', 0, $error);
|
|
}
|
|
return is_array($value) ? $value : $fallback;
|
|
}
|
|
|
|
function atomic_write_json(string $path, array $value): void {
|
|
ensure_directory(dirname($path));
|
|
try {
|
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $error) {
|
|
throw new RuntimeException('storage_encode_failed', 0, $error);
|
|
}
|
|
$temp = tempnam(dirname($path), basename($path) . '.tmp.');
|
|
if ($temp === false) throw new RuntimeException('storage_unavailable');
|
|
$handle = @fopen($temp, 'wb');
|
|
if ($handle === false) { @unlink($temp); throw new RuntimeException('storage_unavailable'); }
|
|
try {
|
|
$offset = 0;
|
|
$length = strlen($encoded);
|
|
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);
|
|
} catch (Throwable $error) {
|
|
fclose($handle); @unlink($temp); throw $error;
|
|
}
|
|
fclose($handle);
|
|
@chmod($temp, 0660);
|
|
if (!@rename($temp, $path)) { @unlink($temp); throw new RuntimeException('storage_write_failed'); }
|
|
}
|
|
|
|
|
|
function atomic_write_bytes(string $path, string $bytes): void {
|
|
ensure_directory(dirname($path));
|
|
$temp = tempnam(dirname($path), basename($path) . '.tmp.');
|
|
if ($temp === false) throw new RuntimeException('storage_unavailable');
|
|
$handle = @fopen($temp, 'wb');
|
|
if ($handle === false) { @unlink($temp); throw new RuntimeException('storage_unavailable'); }
|
|
try {
|
|
$offset = 0;
|
|
$length = strlen($bytes);
|
|
while ($offset < $length) {
|
|
$written = fwrite($handle, substr($bytes, $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);
|
|
} catch (Throwable $error) {
|
|
fclose($handle); @unlink($temp); throw $error;
|
|
}
|
|
fclose($handle);
|
|
@chmod($temp, 0660);
|
|
if (!@rename($temp, $path)) { @unlink($temp); throw new RuntimeException('storage_write_failed'); }
|
|
}
|
|
|
|
function valid_legacy_player_id(string $value): bool {
|
|
return preg_match('/^[A-Za-z0-9_-]{22}$/', $value) === 1;
|
|
}
|
|
|
|
function legacy_player_uuid(string $legacyId): string {
|
|
if (!valid_legacy_player_id($legacyId)) throw new RuntimeException('legacy_state_invalid');
|
|
$decoded = base64_decode(strtr($legacyId, '-_', '+/') . '==', true);
|
|
if (!is_string($decoded) || strlen($decoded) !== 16) throw new RuntimeException('legacy_state_invalid');
|
|
$bytes = $decoded;
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
$hex = bin2hex($bytes);
|
|
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12);
|
|
}
|
|
|
|
/**
|
|
* Imports the compact schema-4 state.json used by the pre-credential server.
|
|
* The source remains untouched and is copied byte-for-byte into legacy-backup.
|
|
* Player writes are deterministic and idempotent, so a process interruption is
|
|
* safely resumed on the next request before the completion marker is written.
|
|
*/
|
|
function migrate_legacy_compact_state_v4(string $root, string $summaryPath): ?array {
|
|
$sourcePath = $root . DIRECTORY_SEPARATOR . 'state.json';
|
|
if (!is_file($sourcePath)) return null;
|
|
$markerPath = $root . DIRECTORY_SEPARATOR . 'legacy-v4-migration.json';
|
|
$marker = read_json_file($markerPath, []);
|
|
$sourceSize = max(0, (int)@filesize($sourcePath));
|
|
$sourceMtime = max(0, (int)@filemtime($sourcePath));
|
|
$sourceRaw = @file_get_contents($sourcePath);
|
|
if (!is_string($sourceRaw) || trim($sourceRaw) === '') throw new RuntimeException('legacy_state_unreadable');
|
|
$sourceHash = hash('sha256', $sourceRaw);
|
|
if (($marker['status'] ?? '') === 'complete'
|
|
&& hash_equals((string)($marker['sourceHash'] ?? ''), $sourceHash)) {
|
|
$marker['sourceSize'] = $sourceSize;
|
|
$marker['sourceMtime'] = $sourceMtime;
|
|
atomic_write_json($markerPath, $marker);
|
|
return $marker;
|
|
}
|
|
// If the legacy server wrote a final update during cut-over, import the
|
|
// changed snapshot again. The merge is grow-only and deterministic.
|
|
|
|
try {
|
|
$data = json_decode($sourceRaw, true, 256, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $error) {
|
|
throw new RuntimeException('legacy_state_invalid', 0, $error);
|
|
}
|
|
if (!is_array($data) || count($data) !== 6 || ($data[0] ?? null) !== 4) throw new RuntimeException('legacy_state_invalid');
|
|
$baseTimestamp = $data[1] ?? null;
|
|
$versions = $data[2] ?? null;
|
|
$catalogIds = $data[3] ?? null;
|
|
$playerRows = $data[4] ?? null;
|
|
$unlockRows = $data[5] ?? null;
|
|
if (!is_int($baseTimestamp) || $baseTimestamp < 946684800 || !is_array($versions) || !$versions
|
|
|| !is_array($catalogIds) || !is_array($playerRows) || !is_array($unlockRows)) {
|
|
throw new RuntimeException('legacy_state_invalid');
|
|
}
|
|
foreach ($versions as $version) {
|
|
if (!is_string($version) || $version === '' || strlen($version) > 32) throw new RuntimeException('legacy_state_invalid');
|
|
}
|
|
if (array_values($catalogIds) !== array_values(ACHIEVEMENT_IDS)) throw new RuntimeException('legacy_catalog_mismatch');
|
|
|
|
$perPlayerUnlocks = array_fill(0, count($playerRows), []);
|
|
$legacyCounts = array_fill_keys(ACHIEVEMENT_IDS, 0);
|
|
$seenAchievements = [];
|
|
$unlockAssignments = 0;
|
|
foreach ($unlockRows as $unlockRow) {
|
|
if (!is_array($unlockRow) || count($unlockRow) < 3 || count($unlockRow) % 2 !== 1) throw new RuntimeException('legacy_state_invalid');
|
|
$achievementIndex = $unlockRow[0] ?? null;
|
|
if (!is_int($achievementIndex) || $achievementIndex < 0 || $achievementIndex >= count(ACHIEVEMENT_IDS)
|
|
|| isset($seenAchievements[$achievementIndex])) throw new RuntimeException('legacy_state_invalid');
|
|
$seenAchievements[$achievementIndex] = true;
|
|
$achievementId = ACHIEVEMENT_IDS[$achievementIndex];
|
|
$seenPlayers = [];
|
|
for ($offset = 1; $offset < count($unlockRow); $offset += 2) {
|
|
$playerIndex = $unlockRow[$offset] ?? null;
|
|
$timeOffset = $unlockRow[$offset + 1] ?? null;
|
|
if (!is_int($playerIndex) || $playerIndex < 0 || $playerIndex >= count($playerRows)
|
|
|| isset($seenPlayers[$playerIndex]) || !is_int($timeOffset) || $timeOffset < 0) {
|
|
throw new RuntimeException('legacy_state_invalid');
|
|
}
|
|
$timestamp = $baseTimestamp + $timeOffset;
|
|
if ($timestamp < 946684800) throw new RuntimeException('legacy_state_invalid');
|
|
$seenPlayers[$playerIndex] = true;
|
|
$perPlayerUnlocks[$playerIndex][$achievementId] = $timestamp;
|
|
$legacyCounts[$achievementId]++;
|
|
$unlockAssignments++;
|
|
}
|
|
}
|
|
|
|
$backupPath = $root . DIRECTORY_SEPARATOR . 'legacy-backup' . DIRECTORY_SEPARATOR . 'state-v4-' . $sourceHash . '.json';
|
|
if (!is_file($backupPath)) atomic_write_bytes($backupPath, $sourceRaw);
|
|
$backupRaw = @file_get_contents($backupPath);
|
|
if (!is_string($backupRaw) || !hash_equals($sourceHash, hash('sha256', $backupRaw))) throw new RuntimeException('legacy_backup_failed');
|
|
|
|
atomic_write_json($markerPath, [
|
|
'schema' => 1,
|
|
'status' => 'in_progress',
|
|
'sourceSchema' => 4,
|
|
'sourceHash' => $sourceHash,
|
|
'sourceSize' => $sourceSize,
|
|
'sourceMtime' => $sourceMtime,
|
|
'startedAt' => time(),
|
|
'expectedPlayers' => count($playerRows),
|
|
'expectedUnlockAssignments' => $unlockAssignments,
|
|
'backup' => basename(dirname($backupPath)) . '/' . basename($backupPath),
|
|
]);
|
|
|
|
$legacyIdsSeen = [];
|
|
$migratedPlayerIds = [];
|
|
foreach ($playerRows as $index => $row) {
|
|
if (!is_array($row) || count($row) < 3 || count($row) > 4) throw new RuntimeException('legacy_state_invalid');
|
|
$legacyId = $row[0] ?? null;
|
|
$firstOffset = $row[1] ?? null;
|
|
$duration = $row[2] ?? null;
|
|
$versionIndex = $row[3] ?? 0;
|
|
if (!is_string($legacyId) || !valid_legacy_player_id($legacyId) || isset($legacyIdsSeen[$legacyId])
|
|
|| !is_int($firstOffset) || $firstOffset < 0 || !is_int($duration) || $duration < 0
|
|
|| !is_int($versionIndex) || $versionIndex < 0 || $versionIndex >= count($versions)) {
|
|
throw new RuntimeException('legacy_state_invalid');
|
|
}
|
|
$legacyIdsSeen[$legacyId] = true;
|
|
$playerId = legacy_player_uuid($legacyId);
|
|
if (isset($migratedPlayerIds[$playerId])) throw new RuntimeException('legacy_player_collision');
|
|
$migratedPlayerIds[$playerId] = true;
|
|
$claimHash = hash('sha256', $legacyId);
|
|
$path = player_path($root, $playerId);
|
|
if (is_file($path)) {
|
|
$existingRaw = read_json_file($path, []);
|
|
$existingClaimHash = strtolower((string)($existingRaw['legacyClaimHash'] ?? ''));
|
|
if ($existingClaimHash !== '' && !hash_equals($existingClaimHash, $claimHash)) throw new RuntimeException('legacy_player_collision');
|
|
if ($existingClaimHash === '' && (string)($existingRaw['ownerHash'] ?? '') !== '') throw new RuntimeException('legacy_player_collision');
|
|
$player = normalize_player($existingRaw, $playerId);
|
|
} else {
|
|
$player = default_player($playerId, '', $baseTimestamp + $firstOffset);
|
|
}
|
|
$player['schema'] = 5;
|
|
$player['legacyClaimHash'] = $claimHash;
|
|
$player['legacyClaimedAt'] = max(0, (int)($player['legacyClaimedAt'] ?? 0));
|
|
$player['firstSeen'] = min((int)$player['firstSeen'], $baseTimestamp + $firstOffset);
|
|
$player['lastSeen'] = max((int)$player['lastSeen'], $baseTimestamp + $firstOffset + $duration);
|
|
$player['activityCount'] = max(2, (int)$player['activityCount']);
|
|
$player['qualified'] = true;
|
|
if ((string)$player['gameVersion'] === '') $player['gameVersion'] = $versions[$versionIndex];
|
|
// A later explicit reset must remain authoritative if migration is
|
|
// resumed after the player has already advanced to a newer generation.
|
|
if ((int)$player['generation'] === 0) {
|
|
foreach ($perPlayerUnlocks[$index] as $achievementId => $timestamp) {
|
|
if (!isset($player['unlocked'][$achievementId])) $player['unlocked'][$achievementId] = $timestamp;
|
|
else $player['unlocked'][$achievementId] = min((int)$player['unlocked'][$achievementId], $timestamp);
|
|
}
|
|
}
|
|
reconcile_completionist($player['unlocked'], $player['lastSeen']);
|
|
atomic_write_json($path, $player);
|
|
}
|
|
|
|
$verifiedAssignments = 0;
|
|
foreach ($playerRows as $index => $row) {
|
|
$legacyId = (string)$row[0];
|
|
$playerId = legacy_player_uuid($legacyId);
|
|
$player = normalize_player(read_json_file(player_path($root, $playerId), []), $playerId);
|
|
if (!hash_equals(hash('sha256', $legacyId), (string)$player['legacyClaimHash'])) throw new RuntimeException('legacy_migration_verify_failed');
|
|
if ((int)$player['generation'] === 0) {
|
|
foreach ($perPlayerUnlocks[$index] as $achievementId => $timestamp) {
|
|
if (!isset($player['unlocked'][$achievementId]) || (int)$player['unlocked'][$achievementId] > $timestamp) {
|
|
throw new RuntimeException('legacy_migration_verify_failed');
|
|
}
|
|
$verifiedAssignments++;
|
|
}
|
|
}
|
|
}
|
|
if ($verifiedAssignments !== $unlockAssignments) throw new RuntimeException('legacy_migration_verify_failed');
|
|
|
|
$summary = repair_summary_cache($root, $summaryPath);
|
|
if ((int)$summary['totalPlayers'] < count($playerRows)) throw new RuntimeException('legacy_migration_verify_failed');
|
|
foreach ($legacyCounts as $achievementId => $count) {
|
|
if ((int)($summary['counts'][$achievementId] ?? 0) < $count) throw new RuntimeException('legacy_migration_verify_failed');
|
|
}
|
|
$complete = [
|
|
'schema' => 1,
|
|
'status' => 'complete',
|
|
'sourceSchema' => 4,
|
|
'sourceHash' => $sourceHash,
|
|
'sourceSize' => $sourceSize,
|
|
'sourceMtime' => $sourceMtime,
|
|
'completedAt' => time(),
|
|
'migratedPlayers' => count($playerRows),
|
|
'migratedUnlockAssignments' => $unlockAssignments,
|
|
'summaryPlayers' => (int)$summary['totalPlayers'],
|
|
'backup' => basename(dirname($backupPath)) . '/' . basename($backupPath),
|
|
];
|
|
atomic_write_json($markerPath, $complete);
|
|
return $complete;
|
|
}
|
|
|
|
function with_lock(string $path, int $mode, callable $callback): mixed {
|
|
ensure_directory(dirname($path));
|
|
$handle = @fopen($path, 'c');
|
|
if ($handle === false || !flock($handle, $mode)) {
|
|
if (is_resource($handle)) fclose($handle);
|
|
throw new RuntimeException('storage_locked');
|
|
}
|
|
try { return $callback(); } finally { flock($handle, LOCK_UN); fclose($handle); }
|
|
}
|
|
|
|
function player_path(string $root, string $playerId): string {
|
|
$bucket = substr(hash('sha256', $playerId), 0, 2);
|
|
return $root . DIRECTORY_SEPARATOR . 'players' . DIRECTORY_SEPARATOR . $bucket . DIRECTORY_SEPARATOR . hash('sha256', $playerId) . '.json';
|
|
}
|
|
|
|
function default_summary(): array {
|
|
return [
|
|
'schema' => 1,
|
|
'totalPlayers' => 0,
|
|
'counts' => array_fill_keys(ACHIEVEMENT_IDS, 0),
|
|
];
|
|
}
|
|
|
|
function normalize_summary(array $summary): array {
|
|
$out = default_summary();
|
|
$out['totalPlayers'] = max(0, (int)($summary['totalPlayers'] ?? 0));
|
|
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
|
foreach (ACHIEVEMENT_IDS as $id) $out['counts'][$id] = max(0, (int)($counts[$id] ?? 0));
|
|
return $out;
|
|
}
|
|
|
|
function default_progress_state(): array {
|
|
return [
|
|
'progress' => [
|
|
'linkTypes' => [],
|
|
'signalActivated' => false,
|
|
'medicineTypes' => [],
|
|
'dailyPlayStreak' => 0,
|
|
'dailyPlayLastDay' => 0,
|
|
'mysteryDrugTarinaiIds' => [],
|
|
],
|
|
'ledger' => [
|
|
'base' => array_fill_keys(array_keys(ADDITIVE_PROGRESS_CAPS), 0),
|
|
'shards' => array_fill_keys(array_keys(ADDITIVE_PROGRESS_CAPS), []),
|
|
],
|
|
];
|
|
}
|
|
|
|
function progress_counter_value(array $progressState, string $key): int {
|
|
$cap = (int)(ADDITIVE_PROGRESS_CAPS[$key] ?? 0);
|
|
if ($cap <= 0) return 0;
|
|
$base = bounded_int($progressState['ledger']['base'][$key] ?? 0, 0, $cap);
|
|
$shards = is_array($progressState['ledger']['shards'][$key] ?? null) ? $progressState['ledger']['shards'][$key] : [];
|
|
$sum = $base;
|
|
foreach ($shards as $value) $sum += bounded_int($value, 0, $cap);
|
|
return min($cap, $sum);
|
|
}
|
|
|
|
function bounded_int(mixed $value, int $minimum, int $maximum): int {
|
|
if (!is_int($value) && !is_float($value) && !is_string($value)) return $minimum;
|
|
if (!is_numeric($value)) return $minimum;
|
|
$numeric = (float)$value;
|
|
if (!is_finite($numeric)) return $minimum;
|
|
return max($minimum, min($maximum, (int)floor($numeric)));
|
|
}
|
|
|
|
function unique_allowed_strings(mixed $input, array $allowed): array {
|
|
if (!is_array($input)) return [];
|
|
$lookup = array_fill_keys($allowed, true);
|
|
$out = [];
|
|
foreach ($input as $value) {
|
|
$text = (string)$value;
|
|
if (isset($lookup[$text])) $out[$text] = true;
|
|
}
|
|
return array_keys($out);
|
|
}
|
|
|
|
function normalize_progress_state(mixed $input): array {
|
|
$out = default_progress_state();
|
|
if (!is_array($input)) return $out;
|
|
$progressSource = is_array($input['progress'] ?? null) ? $input['progress'] : [];
|
|
$ledgerSource = is_array($input['ledger'] ?? null) ? $input['ledger'] : [];
|
|
$baseSource = is_array($ledgerSource['base'] ?? null) ? $ledgerSource['base'] : [];
|
|
$shardsSource = is_array($ledgerSource['shards'] ?? null) ? $ledgerSource['shards'] : [];
|
|
|
|
foreach (ADDITIVE_PROGRESS_CAPS as $key => $rawCap) {
|
|
$cap = max(0, (int)$rawCap);
|
|
$base = bounded_int($baseSource[$key] ?? 0, 0, $cap);
|
|
$shards = [];
|
|
$rawShards = is_array($shardsSource[$key] ?? null) ? $shardsSource[$key] : [];
|
|
foreach ($rawShards as $actorId => $rawValue) {
|
|
$id = substr((string)$actorId, 0, 160);
|
|
if ($id === '' || preg_match('/[\x00-\x1F\x7F]/', $id) === 1) continue;
|
|
$value = bounded_int($rawValue, 0, $cap);
|
|
if ($value > 0) $shards[$id] = max((int)($shards[$id] ?? 0), $value);
|
|
}
|
|
$total = min($cap, $base + array_sum($shards));
|
|
// Protocol v4 and older duplicated additive counters in progress. Read
|
|
// them as a one-time migration floor, but never emit them again.
|
|
$legacyFloor = bounded_int($progressSource[$key] ?? 0, 0, $cap);
|
|
if ($legacyFloor > $total) {
|
|
$base = min($cap, $base + ($legacyFloor - $total));
|
|
$total = $legacyFloor;
|
|
}
|
|
if ($total >= $cap) {
|
|
$base = $cap;
|
|
$shards = [];
|
|
}
|
|
$out['ledger']['base'][$key] = $base;
|
|
$out['ledger']['shards'][$key] = $shards;
|
|
}
|
|
|
|
$out['progress']['linkTypes'] = unique_allowed_strings($progressSource['linkTypes'] ?? [], LINK_PROGRESS_TYPES);
|
|
$out['progress']['signalActivated'] = (bool)($progressSource['signalActivated'] ?? false);
|
|
$out['progress']['medicineTypes'] = unique_allowed_strings($progressSource['medicineTypes'] ?? [], MEDICINE_LEDGER_TYPES);
|
|
$out['progress']['dailyPlayStreak'] = bounded_int($progressSource['dailyPlayStreak'] ?? 0, 0, 7);
|
|
$out['progress']['dailyPlayLastDay'] = bounded_int($progressSource['dailyPlayLastDay'] ?? 0, 0, 1000000);
|
|
$ids = [];
|
|
foreach ((array)($progressSource['mysteryDrugTarinaiIds'] ?? []) as $rawId) {
|
|
$id = substr(trim((string)$rawId), 0, 160);
|
|
if ($id !== '' && preg_match('/[\x00-\x1F\x7F]/', $id) !== 1) $ids[$id] = true;
|
|
if (count($ids) >= MAD_SCIENTIST_TARGET) break;
|
|
}
|
|
$out['progress']['mysteryDrugTarinaiIds'] = array_keys($ids);
|
|
return $out;
|
|
}
|
|
|
|
function merge_progress_state(mixed $targetInput, mixed $sourceInput): array {
|
|
$target = normalize_progress_state($targetInput);
|
|
$source = normalize_progress_state($sourceInput);
|
|
foreach (ADDITIVE_PROGRESS_CAPS as $key => $cap) {
|
|
$target['ledger']['base'][$key] = max((int)$target['ledger']['base'][$key], (int)$source['ledger']['base'][$key]);
|
|
foreach ($source['ledger']['shards'][$key] as $actorId => $value) {
|
|
$target['ledger']['shards'][$key][$actorId] = max((int)($target['ledger']['shards'][$key][$actorId] ?? 0), (int)$value);
|
|
}
|
|
}
|
|
$targetDay = (int)$target['progress']['dailyPlayLastDay'];
|
|
$sourceDay = (int)$source['progress']['dailyPlayLastDay'];
|
|
if ($sourceDay > $targetDay) {
|
|
$target['progress']['dailyPlayLastDay'] = $sourceDay;
|
|
$target['progress']['dailyPlayStreak'] = (int)$source['progress']['dailyPlayStreak'];
|
|
} elseif ($sourceDay === $targetDay) {
|
|
$target['progress']['dailyPlayStreak'] = max((int)$target['progress']['dailyPlayStreak'], (int)$source['progress']['dailyPlayStreak']);
|
|
}
|
|
$target['progress']['signalActivated'] = (bool)$target['progress']['signalActivated'] || (bool)$source['progress']['signalActivated'];
|
|
$target['progress']['linkTypes'] = array_values(array_unique(array_merge($target['progress']['linkTypes'], $source['progress']['linkTypes'])));
|
|
$target['progress']['medicineTypes'] = array_values(array_unique(array_merge($target['progress']['medicineTypes'], $source['progress']['medicineTypes'])));
|
|
$target['progress']['mysteryDrugTarinaiIds'] = array_slice(array_values(array_unique(array_merge(
|
|
$target['progress']['mysteryDrugTarinaiIds'],
|
|
$source['progress']['mysteryDrugTarinaiIds']
|
|
))), 0, MAD_SCIENTIST_TARGET);
|
|
return normalize_progress_state($target);
|
|
}
|
|
|
|
function progress_unlock_satisfied(string $id, array $progressState): ?bool {
|
|
$rule = PROGRESS_RULES[$id] ?? null;
|
|
if (!is_array($rule)) return null;
|
|
$type = (string)($rule['type'] ?? '');
|
|
$key = (string)($rule['key'] ?? '');
|
|
$target = max(0, (int)($rule['target'] ?? 0));
|
|
$progress = $progressState['progress'];
|
|
if ($type === 'counter') return progress_counter_value($progressState, $key) >= $target;
|
|
if ($type === 'value') return bounded_int($progress[$key] ?? 0, 0, max($target, 1000000)) >= $target;
|
|
if ($type === 'set') {
|
|
$required = (string)($rule['required'] ?? '');
|
|
$requiredValues = $required === 'medicineLedgerTypes' ? MEDICINE_LEDGER_TYPES : [];
|
|
return count(array_intersect($requiredValues, (array)($progress[$key] ?? []))) === count($requiredValues);
|
|
}
|
|
if ($type === 'unique') return count((array)($progress[$key] ?? [])) >= $target;
|
|
if ($type === 'mechanized') {
|
|
return (bool)($progress['signalActivated'] ?? false)
|
|
&& count(array_intersect(LINK_PROGRESS_TYPES, (array)($progress['linkTypes'] ?? []))) === count(LINK_PROGRESS_TYPES);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function reconcile_progress_unlocks(array &$unlocked, array $progressState, int $now): void {
|
|
foreach (ACHIEVEMENT_IDS as $id) {
|
|
if ($id === COMPLETIONIST_ID || isset($unlocked[$id])) continue;
|
|
if (progress_unlock_satisfied($id, $progressState) === true) $unlocked[$id] = $now;
|
|
}
|
|
}
|
|
|
|
function default_player(string $playerId, string $ownerHash, int $now): array {
|
|
return [
|
|
'schema' => 5,
|
|
'playerId' => $playerId,
|
|
'ownerHash' => $ownerHash,
|
|
'firstSeen' => $now,
|
|
'lastSeen' => $now,
|
|
'activityCount' => 0,
|
|
'qualified' => false,
|
|
'gameVersion' => '',
|
|
'generation' => 0,
|
|
'unlocked' => [],
|
|
'progressState' => default_progress_state(),
|
|
];
|
|
}
|
|
|
|
function normalize_player(array $player, string $playerId): array {
|
|
$now = time();
|
|
$unlocked = [];
|
|
foreach ((array)($player['unlocked'] ?? []) as $id => $timestamp) {
|
|
if (is_string($id) && in_array($id, ACHIEVEMENT_IDS, true)) $unlocked[$id] = max(1, (int)$timestamp);
|
|
}
|
|
return [
|
|
'schema' => 5,
|
|
'playerId' => $playerId,
|
|
'ownerHash' => (string)($player['ownerHash'] ?? ''),
|
|
'firstSeen' => max(1, (int)($player['firstSeen'] ?? $now)),
|
|
'lastSeen' => max(1, (int)($player['lastSeen'] ?? $now)),
|
|
'activityCount' => max(0, (int)($player['activityCount'] ?? 0)),
|
|
'qualified' => (bool)($player['qualified'] ?? false),
|
|
'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32),
|
|
'generation' => max(0, (int)($player['generation'] ?? 0)),
|
|
'unlocked' => $unlocked,
|
|
'progressState' => normalize_progress_state($player['progressState'] ?? null),
|
|
'legacyClaimHash' => preg_match('/^[0-9a-f]{64}$/', strtolower((string)($player['legacyClaimHash'] ?? ''))) === 1
|
|
? strtolower((string)$player['legacyClaimHash']) : '',
|
|
'legacyClaimedAt' => max(0, (int)($player['legacyClaimedAt'] ?? 0)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The summary is a derived cache, never the source of truth. Rebuilding it from
|
|
* player records makes a corrupt/missing summary self-healing and repairs a
|
|
* process interruption between the player and summary renames.
|
|
*/
|
|
function rebuild_summary_from_players(string $root): array {
|
|
$summary = default_summary();
|
|
$playersRoot = $root . DIRECTORY_SEPARATOR . 'players';
|
|
if (!is_dir($playersRoot)) return $summary;
|
|
try {
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($playersRoot, FilesystemIterator::SKIP_DOTS)
|
|
);
|
|
foreach ($iterator as $file) {
|
|
if (!$file->isFile() || strtolower($file->getExtension()) !== 'json') continue;
|
|
try {
|
|
$raw = read_json_file($file->getPathname(), []);
|
|
$playerId = strtolower((string)($raw['playerId'] ?? ''));
|
|
if (!valid_player_id($playerId)) continue;
|
|
$player = normalize_player($raw, $playerId);
|
|
} catch (Throwable) {
|
|
// One damaged player record must not take down every player.
|
|
continue;
|
|
}
|
|
if (!(bool)$player['qualified']) continue;
|
|
$summary['totalPlayers']++;
|
|
foreach (array_keys($player['unlocked']) as $id) {
|
|
if (isset($summary['counts'][$id])) $summary['counts'][$id]++;
|
|
}
|
|
}
|
|
} catch (UnexpectedValueException) {
|
|
throw new RuntimeException('storage_unavailable');
|
|
}
|
|
return normalize_summary($summary);
|
|
}
|
|
|
|
function repair_summary_cache(string $root, string $summaryPath): array {
|
|
$summary = rebuild_summary_from_players($root);
|
|
atomic_write_json($summaryPath, $summary);
|
|
return $summary;
|
|
}
|
|
|
|
function verify_owner(array &$player, string $secret): void {
|
|
$submitted = hash('sha256', strtolower($secret));
|
|
$stored = (string)($player['ownerHash'] ?? '');
|
|
if ($stored === '' || !hash_equals($stored, $submitted)) respond(['ok' => false, 'error' => 'invalid_owner'], 403);
|
|
}
|
|
|
|
function reconcile_completionist(array &$unlocked, int $now): void {
|
|
$latestPrerequisite = 0;
|
|
foreach (ACHIEVEMENT_IDS as $id) {
|
|
if ($id === COMPLETIONIST_ID) continue;
|
|
if (!isset($unlocked[$id])) { unset($unlocked[COMPLETIONIST_ID]); return; }
|
|
$latestPrerequisite = max($latestPrerequisite, max(1, (int)$unlocked[$id]));
|
|
}
|
|
$completionTime = max(1, $latestPrerequisite ?: $now);
|
|
if (!isset($unlocked[COMPLETIONIST_ID])) $unlocked[COMPLETIONIST_ID] = $completionTime;
|
|
else $unlocked[COMPLETIONIST_ID] = max((int)$unlocked[COMPLETIONIST_ID], $completionTime);
|
|
}
|
|
|
|
function normalize_client_unlock_time(mixed $value, int $now): int {
|
|
if (!is_int($value) && !is_float($value) && !is_string($value)) return $now;
|
|
if (!is_numeric($value)) return $now;
|
|
$numeric = (float)$value;
|
|
if (!is_finite($numeric) || $numeric <= 0) return $now;
|
|
// Browser timestamps are milliseconds; legacy/server timestamps are seconds.
|
|
if ($numeric >= 100000000000.0) $numeric /= 1000.0;
|
|
$seconds = (int)floor($numeric);
|
|
// Reject implausible/future values, but preserve valid offline unlock times.
|
|
if ($seconds < 946684800 || $seconds > $now + 300) return $now;
|
|
return $seconds;
|
|
}
|
|
|
|
function apply_summary_delta(array &$summary, array $before, array $after): void {
|
|
foreach (ACHIEVEMENT_IDS as $id) {
|
|
$had = isset($before[$id]);
|
|
$has = isset($after[$id]);
|
|
if ($had === $has) continue;
|
|
$summary['counts'][$id] = max(0, (int)$summary['counts'][$id] + ($has ? 1 : -1));
|
|
}
|
|
}
|
|
|
|
function maybe_qualify(array &$player, array &$summary, int $now): bool {
|
|
if ($player['qualified']) return false;
|
|
if ((int)$player['activityCount'] < QUALIFY_MIN_ACTIVITY) return false;
|
|
if ($now - (int)$player['firstSeen'] < QUALIFY_MIN_AGE_SECONDS) return false;
|
|
$player['qualified'] = true;
|
|
$summary['totalPlayers'] = max(0, (int)$summary['totalPlayers'] + 1);
|
|
foreach (array_keys($player['unlocked']) as $id) $summary['counts'][$id] = max(0, (int)$summary['counts'][$id] + 1);
|
|
return true;
|
|
}
|
|
|
|
function public_summary(array $summary, ?array $player = null): array {
|
|
$total = max(0, (int)$summary['totalPlayers']);
|
|
$achievements = [];
|
|
foreach (ACHIEVEMENT_IDS as $id) {
|
|
$count = max(0, min($total, (int)($summary['counts'][$id] ?? 0)));
|
|
$achievements[$id] = [
|
|
'unlockedPlayers' => $count,
|
|
'percentage' => $total > 0 ? round($count * 100 / $total, 1) : 0.0,
|
|
];
|
|
}
|
|
$out = ['ok' => true, 'totalPlayers' => $total, 'achievements' => $achievements, 'statisticsTrust' => 'client-reported'];
|
|
if ($player !== null) {
|
|
$out['qualified'] = (bool)($player['qualified'] ?? false);
|
|
$out['playerGeneration'] = max(0, (int)($player['generation'] ?? 0));
|
|
// Return the authenticated player's grow-only set. The client uploads
|
|
// its offline snapshot first, then merges this atomic union response.
|
|
$out['playerUnlocked'] = (object)(is_array($player['unlocked'] ?? null) ? $player['unlocked'] : []);
|
|
$out['playerProgress'] = normalize_progress_state($player['progressState'] ?? null);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
function enforce_rate_limit(string $root, string $scope, int $limit): void {
|
|
$now = time();
|
|
$hash = hash('sha256', $scope);
|
|
$path = $root . DIRECTORY_SEPARATOR . 'rate' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash . '.json';
|
|
ensure_directory(dirname($path));
|
|
$lockPath = $path . '.lock';
|
|
with_lock($lockPath, LOCK_EX, function () use ($path, $now, $limit): void {
|
|
$state = read_json_file($path, ['window' => $now, 'count' => 0]);
|
|
$window = (int)($state['window'] ?? $now);
|
|
$count = (int)($state['count'] ?? 0);
|
|
if ($now - $window >= RATE_WINDOW_SECONDS) { $window = $now; $count = 0; }
|
|
$count++;
|
|
if ($count > $limit) respond(['ok' => false, 'error' => 'rate_limited', 'retryAfter' => max(1, RATE_WINDOW_SECONDS - ($now - $window))], 429);
|
|
atomic_write_json($path, ['window' => $window, 'count' => $count]);
|
|
});
|
|
}
|
|
|
|
function claim_identity_issue_slot(string $root, string $ip): void {
|
|
$now = time();
|
|
$hash = hash('sha256', 'identity:' . $ip);
|
|
$path = $root . DIRECTORY_SEPARATOR . 'identity-rate' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash . '.json';
|
|
with_lock($path . '.lock', LOCK_EX, function () use ($path, $now): void {
|
|
$state = read_json_file($path, ['window' => $now, 'count' => 0]);
|
|
$window = max(1, (int)($state['window'] ?? $now));
|
|
$count = max(0, (int)($state['count'] ?? 0));
|
|
if ($now - $window >= IDENTITY_ISSUE_WINDOW_SECONDS) { $window = $now; $count = 0; }
|
|
if ($count >= IDENTITY_ISSUE_LIMIT_PER_IP) {
|
|
respond([
|
|
'ok' => false,
|
|
'error' => 'identity_creation_limited',
|
|
'retryAfter' => max(1, IDENTITY_ISSUE_WINDOW_SECONDS - ($now - $window)),
|
|
], 429);
|
|
}
|
|
atomic_write_json($path, ['window' => $window, 'count' => $count + 1]);
|
|
});
|
|
}
|
|
|
|
function random_uuid_v4(): string {
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
$hex = bin2hex($bytes);
|
|
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12);
|
|
}
|
|
|
|
function session_signing_key(string $root): string {
|
|
$path = $root . DIRECTORY_SEPARATOR . 'session-key.json';
|
|
return with_lock($path . '.lock', LOCK_EX, function () use ($path): string {
|
|
$state = read_json_file($path, []);
|
|
$hex = strtolower((string)($state['key'] ?? ''));
|
|
if (preg_match('/^[0-9a-f]{64}$/', $hex) !== 1) {
|
|
$hex = bin2hex(random_bytes(32));
|
|
atomic_write_json($path, ['key' => $hex]);
|
|
}
|
|
$key = hex2bin($hex);
|
|
if (!is_string($key) || strlen($key) !== 32) throw new RuntimeException('storage_corrupt');
|
|
return $key;
|
|
});
|
|
}
|
|
|
|
function make_session_cookie(string $root, string $playerId, string $secret): string {
|
|
$payload = strtolower($playerId) . '.' . strtolower($secret);
|
|
return $payload . '.' . hash_hmac('sha256', $payload, session_signing_key($root));
|
|
}
|
|
|
|
function read_session_cookie(string $root): ?array {
|
|
$token = strtolower((string)($_COOKIE[SESSION_COOKIE_NAME] ?? ''));
|
|
$parts = explode('.', $token);
|
|
if (count($parts) !== 3) return null;
|
|
[$playerId, $secret, $mac] = $parts;
|
|
if (!valid_player_id($playerId) || !valid_player_secret($secret) || preg_match('/^[0-9a-f]{64}$/', $mac) !== 1) return null;
|
|
$payload = $playerId . '.' . $secret;
|
|
$expected = hash_hmac('sha256', $payload, session_signing_key($root));
|
|
return hash_equals($expected, $mac) ? ['playerId' => $playerId, 'playerSecret' => $secret] : null;
|
|
}
|
|
|
|
function set_session_cookie(string $root, string $playerId, string $secret): void {
|
|
$secure = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
|
|
setcookie(SESSION_COOKIE_NAME, make_session_cookie($root, $playerId, $secret), [
|
|
'expires' => time() + SESSION_COOKIE_MAX_AGE,
|
|
'path' => '/',
|
|
'secure' => $secure,
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
]);
|
|
}
|
|
|
|
function stored_owner_matches(string $root, string $playerId, string $secret): bool {
|
|
$path = player_path($root, $playerId);
|
|
if (!is_file($path)) return false;
|
|
try {
|
|
$player = normalize_player(read_json_file($path, []), $playerId);
|
|
} catch (RuntimeException) {
|
|
return false;
|
|
}
|
|
$stored = (string)($player['ownerHash'] ?? '');
|
|
return $stored !== '' && hash_equals($stored, hash('sha256', strtolower($secret)));
|
|
}
|
|
|
|
function resolve_session_credentials(string $root, string $submittedPlayerId, string $submittedSecret, string $legacyPlayerId): array {
|
|
if (valid_legacy_player_id($legacyPlayerId) && valid_player_secret($submittedSecret)) {
|
|
$migratedPlayerId = legacy_player_uuid($legacyPlayerId);
|
|
$path = player_path($root, $migratedPlayerId);
|
|
if (is_file($path)) {
|
|
$raw = read_json_file($path, []);
|
|
$claimHash = strtolower((string)($raw['legacyClaimHash'] ?? ''));
|
|
if ($claimHash !== '' && hash_equals($claimHash, hash('sha256', $legacyPlayerId))) {
|
|
$ownerHash = strtolower((string)($raw['ownerHash'] ?? ''));
|
|
$submittedOwnerHash = hash('sha256', strtolower($submittedSecret));
|
|
if ($ownerHash !== '' && !hash_equals($ownerHash, $submittedOwnerHash)) {
|
|
// A valid durable identity or HttpOnly cookie must remain a
|
|
// recovery path even when stale legacy-ID storage survived
|
|
// but its generated secret did not. Otherwise the stale
|
|
// legacy value permanently blocks an already-authenticated
|
|
// browser before cookie recovery is considered.
|
|
if (valid_player_id($submittedPlayerId) && valid_player_secret($submittedSecret)
|
|
&& stored_owner_matches($root, strtolower($submittedPlayerId), strtolower($submittedSecret))) {
|
|
return [
|
|
'playerId' => strtolower($submittedPlayerId),
|
|
'playerSecret' => strtolower($submittedSecret),
|
|
'created' => false,
|
|
];
|
|
}
|
|
$cookie = read_session_cookie($root);
|
|
if ($cookie !== null && stored_owner_matches($root, $cookie['playerId'], $cookie['playerSecret'])) {
|
|
return [
|
|
'playerId' => $cookie['playerId'],
|
|
'playerSecret' => $cookie['playerSecret'],
|
|
'created' => false,
|
|
];
|
|
}
|
|
respond(['ok' => false, 'error' => 'legacy_player_already_claimed'], 403);
|
|
}
|
|
return [
|
|
'playerId' => $migratedPlayerId,
|
|
'playerSecret' => strtolower($submittedSecret),
|
|
'created' => false,
|
|
'legacyClaimId' => $legacyPlayerId,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
if (valid_player_id($submittedPlayerId) && valid_player_secret($submittedSecret)
|
|
&& stored_owner_matches($root, strtolower($submittedPlayerId), strtolower($submittedSecret))) {
|
|
return ['playerId' => strtolower($submittedPlayerId), 'playerSecret' => strtolower($submittedSecret), 'created' => false];
|
|
}
|
|
$cookie = read_session_cookie($root);
|
|
if ($cookie !== null && stored_owner_matches($root, $cookie['playerId'], $cookie['playerSecret'])) {
|
|
return ['playerId' => $cookie['playerId'], 'playerSecret' => $cookie['playerSecret'], 'created' => false];
|
|
}
|
|
claim_identity_issue_slot($root, client_ip());
|
|
return ['playerId' => random_uuid_v4(), 'playerSecret' => bin2hex(random_bytes(32)), 'created' => true];
|
|
}
|
|
|
|
$method = strtoupper((string)($_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');
|
|
if (!in_array($action, ['session', 'sync', 'reset', 'summary'], true)) respond(['ok' => false, 'error' => 'invalid_action'], 400);
|
|
if ($method === 'GET' && $action !== 'summary') respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
|
|
|
try {
|
|
$root = data_root();
|
|
protect_data_root($root);
|
|
$summaryPath = $root . DIRECTORY_SEPARATOR . 'summary.json';
|
|
$globalLock = $root . DIRECTORY_SEPARATOR . 'summary.lock';
|
|
with_lock($globalLock, LOCK_EX, fn() => migrate_legacy_compact_state_v4($root, $summaryPath));
|
|
} catch (RuntimeException $error) {
|
|
$known = [
|
|
'storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed', 'storage_locked',
|
|
'legacy_state_unreadable', 'legacy_state_invalid', 'legacy_catalog_mismatch', 'legacy_backup_failed',
|
|
'legacy_player_collision', 'legacy_migration_verify_failed',
|
|
];
|
|
$message = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable';
|
|
respond(['ok' => false, 'error' => $message], 503);
|
|
}
|
|
if ($action === 'summary') {
|
|
enforce_rate_limit($root, 'ip:' . client_ip() . ':read', RATE_READ_LIMIT);
|
|
} elseif ($action === 'session') {
|
|
enforce_rate_limit($root, 'ip:' . client_ip() . ':session', RATE_SESSION_IP_LIMIT);
|
|
} else {
|
|
// Authenticated writes are primarily player-scoped. The high IP ceiling is
|
|
// only an abuse fuse and does not make a classroom share a 30-request pool.
|
|
enforce_rate_limit($root, 'ip:' . client_ip() . ':write-abuse', RATE_WRITE_IP_LIMIT);
|
|
}
|
|
if ($action === 'summary') {
|
|
try {
|
|
$result = with_lock($globalLock, LOCK_EX, fn() => public_summary(repair_summary_cache($root, $summaryPath)));
|
|
respond($result);
|
|
} catch (RuntimeException $error) { respond(['ok' => false, 'error' => $error->getMessage()], 503); }
|
|
}
|
|
|
|
$submittedPlayerId = strtolower((string)($payload['playerId'] ?? ''));
|
|
$submittedSecret = strtolower((string)($payload['playerSecret'] ?? ''));
|
|
$submittedLegacyPlayerId = (string)($payload['legacyPlayerId'] ?? '');
|
|
$sessionCredentials = null;
|
|
if ($action === 'session') {
|
|
try { $sessionCredentials = resolve_session_credentials($root, $submittedPlayerId, $submittedSecret, $submittedLegacyPlayerId); }
|
|
catch (RuntimeException $error) { respond(['ok' => false, 'error' => $error->getMessage()], 503); }
|
|
$playerId = $sessionCredentials['playerId'];
|
|
$secret = $sessionCredentials['playerSecret'];
|
|
} else {
|
|
$playerId = $submittedPlayerId;
|
|
$secret = $submittedSecret;
|
|
if (!valid_player_id($playerId)) respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
|
if (!valid_player_secret($secret)) respond(['ok' => false, 'error' => 'invalid_owner'], 403);
|
|
if (!is_file(player_path($root, $playerId))) respond(['ok' => false, 'error' => 'unknown_player'], 403);
|
|
}
|
|
enforce_rate_limit($root, 'player:' . $playerId, RATE_PLAYER_WRITE_LIMIT);
|
|
$clientGeneration = max(0, (int)($payload['generation'] ?? 0));
|
|
$generationRecovery = (bool)($payload['generationRecovery'] ?? false);
|
|
$submittedUnlocks = [];
|
|
$submittedProgress = default_progress_state();
|
|
|
|
if ($action === 'sync') {
|
|
if ((int)($payload['syncProtocol'] ?? 0) !== SYNC_PROTOCOL_VERSION) {
|
|
respond(['ok' => false, 'error' => 'sync_protocol_upgrade_required', 'requiredProtocol' => SYNC_PROTOCOL_VERSION], 426);
|
|
}
|
|
if ((string)($payload['mergeMode'] ?? '') !== 'grow-only-union-v5') {
|
|
respond(['ok' => false, 'error' => 'invalid_merge_mode'], 400);
|
|
}
|
|
$rawUnlocks = $payload['unlocked'] ?? null;
|
|
if (!is_array($rawUnlocks) || count($rawUnlocks) > count(ACHIEVEMENT_IDS)) respond(['ok' => false, 'error' => 'invalid_unlocks'], 400);
|
|
if ((bool)($payload['replace'] ?? false)) respond(['ok' => false, 'error' => 'destructive_sync_disabled'], 409);
|
|
$rawProgress = $payload['progressState'] ?? null;
|
|
if (!is_array($rawProgress)) respond(['ok' => false, 'error' => 'invalid_progress'], 400);
|
|
$submittedProgress = normalize_progress_state($rawProgress);
|
|
$receivedAt = time();
|
|
foreach ($rawUnlocks as $id => $timestamp) {
|
|
if (!is_string($id) || !in_array($id, ACHIEVEMENT_IDS, true)) respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
|
if ($id === COMPLETIONIST_ID) continue; // completionist is always server-derived.
|
|
$submittedUnlocks[$id] = normalize_client_unlock_time($timestamp, $receivedAt);
|
|
}
|
|
}
|
|
if ($action === 'reset') {
|
|
if ((int)($payload['syncProtocol'] ?? 0) !== SYNC_PROTOCOL_VERSION) {
|
|
respond(['ok' => false, 'error' => 'sync_protocol_upgrade_required', 'requiredProtocol' => SYNC_PROTOCOL_VERSION], 426);
|
|
}
|
|
if ((string)($payload['confirmReset'] ?? '') !== 'RESET_ACHIEVEMENTS') respond(['ok' => false, 'error' => 'reset_confirmation_required'], 409);
|
|
if ((string)($payload['catalogRevision'] ?? '') !== CATALOG_REVISION) respond(['ok' => false, 'error' => 'catalog_mismatch'], 409);
|
|
}
|
|
|
|
try {
|
|
$result = with_lock($globalLock, LOCK_EX, function () use ($root, $summaryPath, $playerId, $secret, $payload, $action, $submittedUnlocks, $submittedProgress, $sessionCredentials, $clientGeneration, $generationRecovery): array {
|
|
$now = time();
|
|
$summary = default_summary();
|
|
$path = player_path($root, $playerId);
|
|
$exists = is_file($path);
|
|
if (!$exists && $action !== 'session') respond(['ok' => false, 'error' => 'unknown_player'], 403);
|
|
$ownerHash = hash('sha256', $secret);
|
|
$player = $exists ? normalize_player(read_json_file($path, []), $playerId) : default_player($playerId, $ownerHash, $now);
|
|
$legacyClaimId = (string)($sessionCredentials['legacyClaimId'] ?? '');
|
|
if ($action === 'session' && $legacyClaimId !== '') {
|
|
$expectedClaimHash = hash('sha256', $legacyClaimId);
|
|
if (!hash_equals((string)($player['legacyClaimHash'] ?? ''), $expectedClaimHash)) {
|
|
respond(['ok' => false, 'error' => 'legacy_claim_invalid'], 403);
|
|
}
|
|
$storedOwnerHash = (string)($player['ownerHash'] ?? '');
|
|
if ($storedOwnerHash !== '' && !hash_equals($storedOwnerHash, $ownerHash)) {
|
|
respond(['ok' => false, 'error' => 'legacy_player_already_claimed'], 403);
|
|
}
|
|
$player['ownerHash'] = $ownerHash;
|
|
if ((int)($player['legacyClaimedAt'] ?? 0) <= 0) $player['legacyClaimedAt'] = $now;
|
|
}
|
|
verify_owner($player, $secret);
|
|
|
|
$beforeQualified = (bool)$player['qualified'];
|
|
$beforeUnlocks = $player['unlocked'];
|
|
$serverGeneration = max(0, (int)($player['generation'] ?? 0));
|
|
|
|
if ($action === 'sync') {
|
|
if ($clientGeneration < $serverGeneration) {
|
|
$summary = repair_summary_cache($root, $summaryPath);
|
|
$error = public_summary($summary, $player);
|
|
$error['ok'] = false;
|
|
$error['error'] = 'stale_generation';
|
|
respond($error, 409);
|
|
}
|
|
if ($clientGeneration > $serverGeneration) {
|
|
if (!$generationRecovery) {
|
|
$summary = repair_summary_cache($root, $summaryPath);
|
|
$error = public_summary($summary, $player);
|
|
$error['ok'] = false;
|
|
$error['error'] = 'generation_ahead';
|
|
respond($error, 409);
|
|
}
|
|
// A locally durable newer generation is authoritative after a
|
|
// server backup restore or a newly-issued replacement identity.
|
|
$player['generation'] = $clientGeneration;
|
|
$player['unlocked'] = [];
|
|
$player['progressState'] = default_progress_state();
|
|
$serverGeneration = $clientGeneration;
|
|
}
|
|
}
|
|
|
|
$player['lastSeen'] = $now;
|
|
$player['activityCount'] = max(0, (int)$player['activityCount']) + 1;
|
|
$version = substr((string)($payload['gameVersion'] ?? ''), 0, 32);
|
|
if ($version !== '') $player['gameVersion'] = $version;
|
|
|
|
if ($action === 'sync') {
|
|
$player['progressState'] = merge_progress_state($player['progressState'] ?? null, $submittedProgress);
|
|
foreach ($submittedUnlocks as $id => $submittedAt) {
|
|
if (!isset($player['unlocked'][$id])) $player['unlocked'][$id] = $submittedAt;
|
|
else $player['unlocked'][$id] = min((int)$player['unlocked'][$id], $submittedAt);
|
|
}
|
|
// Cumulative achievements are independently reconstructed from the
|
|
// merged progress CRDT, so 99 actions on one device plus 1 on
|
|
// another unlocks exactly once without trusting request ordering.
|
|
reconcile_progress_unlocks($player['unlocked'], $player['progressState'], $now);
|
|
} elseif ($action === 'reset') {
|
|
// The client generation is the target generation. Equal or older
|
|
// reset requests are already-processed retries and must be no-ops.
|
|
if ($clientGeneration > $serverGeneration) {
|
|
$player['generation'] = $clientGeneration;
|
|
$player['unlocked'] = [];
|
|
$player['progressState'] = default_progress_state();
|
|
}
|
|
}
|
|
reconcile_completionist($player['unlocked'], $now);
|
|
|
|
if ($beforeQualified) apply_summary_delta($summary, $beforeUnlocks, $player['unlocked']);
|
|
maybe_qualify($player, $summary, $now);
|
|
|
|
atomic_write_json($path, $player);
|
|
$summary = repair_summary_cache($root, $summaryPath);
|
|
$public = public_summary($summary, $player);
|
|
if ($action === 'session') {
|
|
$public['playerId'] = $playerId;
|
|
$public['playerSecret'] = $secret;
|
|
$public['serverIssued'] = (bool)($sessionCredentials['created'] ?? false);
|
|
$public['legacyClaimed'] = (string)($sessionCredentials['legacyClaimId'] ?? '') !== '';
|
|
}
|
|
return $public;
|
|
});
|
|
if ($action === 'session') set_session_cookie($root, $playerId, $secret);
|
|
respond($result);
|
|
|
|
} catch (RuntimeException $error) {
|
|
$known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed', 'storage_locked'];
|
|
$message = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable';
|
|
respond(['ok' => false, 'error' => $message], 503);
|
|
}
|