p
This commit is contained in:
parent
d14550dad6
commit
63e10af865
86 changed files with 5400 additions and 1209 deletions
285
achievement_api.php
Normal file
285
achievement_api.php
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<?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',
|
||||
];
|
||||
const DATA_DIR_NAME = '.achievement_data';
|
||||
const DATA_FILE_NAME = 'state.json';
|
||||
|
||||
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 normalize_state(mixed $value): array {
|
||||
if (!is_array($value)) return empty_state();
|
||||
$players = [];
|
||||
$unlocks = [];
|
||||
|
||||
if (($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 = $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) || !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 {
|
||||
$players = isset($value['players']) && is_array($value['players']) ? $value['players'] : [];
|
||||
$unlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : [];
|
||||
}
|
||||
|
||||
$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 = [];
|
||||
$players = [];
|
||||
foreach ($playerIds as $index => $id) {
|
||||
$playerIndex[$id] = $index;
|
||||
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
||||
$players[] = [
|
||||
$id,
|
||||
max(1, (int)($player['firstSeen'] ?? 1)),
|
||||
max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))),
|
||||
substr((string)($player['gameVersion'] ?? ''), 0, 32),
|
||||
];
|
||||
}
|
||||
$unlocks = [];
|
||||
foreach ($state['unlocks'] as $achievementId => $records) {
|
||||
if (!is_string($achievementId) || !is_array($records) || !$records) continue;
|
||||
$flat = [];
|
||||
foreach ($records as $id => $timestamp) {
|
||||
if (!isset($playerIndex[$id])) continue;
|
||||
$flat[] = $playerIndex[$id];
|
||||
$flat[] = max(1, (int)$timestamp);
|
||||
}
|
||||
if ($flat) $unlocks[$achievementId] = $flat;
|
||||
}
|
||||
ksort($unlocks, SORT_STRING);
|
||||
return ['v' => 2, 'p' => $players, 'u' => $unlocks];
|
||||
}
|
||||
|
||||
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');
|
||||
$payload = [];
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
||||
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 (!valid_player_id($playerId)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
||||
}
|
||||
$syncUnlocks = [];
|
||||
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);
|
||||
}
|
||||
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 || $numericTimestamp <= 0) {
|
||||
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||
}
|
||||
$syncUnlocks[$achievementId] = (float)$numericTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
$handle = fopen($dataPath, 'c+');
|
||||
if ($handle === false || !flock($handle, LOCK_EX)) {
|
||||
if (is_resource($handle)) fclose($handle);
|
||||
respond(['ok' => false, 'error' => 'storage_locked'], 503);
|
||||
}
|
||||
|
||||
try {
|
||||
rewind($handle);
|
||||
$contents = stream_get_contents($handle);
|
||||
$state = normalize_state(json_decode(is_string($contents) ? $contents : '', true));
|
||||
$now = time();
|
||||
$player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId])
|
||||
? $state['players'][$playerId]
|
||||
: ['firstSeen' => $now];
|
||||
$player['lastSeen'] = $now;
|
||||
$player['gameVersion'] = substr((string)($payload['gameVersion'] ?? ''), 0, 32);
|
||||
$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') {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
ftruncate($handle, 0);
|
||||
fwrite($handle, json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
fflush($handle);
|
||||
$summary = build_summary($state);
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
respond($summary);
|
||||
Loading…
Add table
Add a link
Reference in a new issue