ff14-mitigator/scripts/update_action_json.php
2026-05-24 11:45:46 +02:00

417 lines
12 KiB
PHP

<?php
declare(strict_types=1);
ini_set('memory_limit', '1024M');
const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/Action.json';
$rootDir = dirname(__DIR__);
$mitigationSource = $rootDir . '/api/analysis.php';
$plannerDataSource = $rootDir . '/js/ffxiv-data.js';
$outputFile = $rootDir . '/assets/jsons/Action.json';
function fail(string $message, int $code = 1): void
{
fwrite(STDERR, $message . PHP_EOL);
exit($code);
}
function extract_constant_array_literal(string $php, string $constantName): string
{
$needle = 'const ' . $constantName . ' =';
$start = strpos($php, $needle);
if ($start === false) {
fail('Could not find const ' . $constantName . ' in api/analysis.php');
}
$arrayStart = strpos($php, '[', $start);
if ($arrayStart === false) {
fail('Could not find array literal for ' . $constantName);
}
$depth = 0;
$length = strlen($php);
$inString = false;
$stringQuote = '';
$escaped = false;
for ($i = $arrayStart; $i < $length; $i++) {
$char = $php[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $stringQuote) {
$inString = false;
$stringQuote = '';
}
continue;
}
if ($char === '\'' || $char === '"') {
$inString = true;
$stringQuote = $char;
continue;
}
if ($char === '[') {
$depth++;
continue;
}
if ($char === ']') {
$depth--;
if ($depth === 0) {
return substr($php, $arrayStart, $i - $arrayStart + 1);
}
}
}
fail('Could not parse array literal for ' . $constantName);
}
function extract_js_const_object_literal(string $js, string $constantName): string
{
$needle = 'const ' . $constantName . ' =';
$start = strpos($js, $needle);
if ($start === false) {
fail('Could not find const ' . $constantName . ' in js/ffxiv-data.js');
}
$objectStart = strpos($js, '{', $start);
if ($objectStart === false) {
fail('Could not find object literal for ' . $constantName);
}
$depth = 0;
$length = strlen($js);
$inString = false;
$stringQuote = '';
$escaped = false;
for ($i = $objectStart; $i < $length; $i++) {
$char = $js[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $stringQuote) {
$inString = false;
$stringQuote = '';
}
continue;
}
if ($char === '\'' || $char === '"' || $char === '`') {
$inString = true;
$stringQuote = $char;
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return substr($js, $objectStart, $i - $objectStart + 1);
}
}
}
fail('Could not parse object literal for ' . $constantName);
}
function read_planner_ability_names(string $sourceFile): array
{
if (!is_file($sourceFile)) {
fail('Missing planner data source file: ' . $sourceFile);
}
$js = file_get_contents($sourceFile);
if ($js === false) {
fail('Could not read planner data source file: ' . $sourceFile);
}
$literal = extract_js_const_object_literal($js, 'JOB_ABILITIES');
if (!preg_match_all('/\bname\s*:\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', $literal, $matches)) {
fail('No abilities found in JOB_ABILITIES');
}
$names = [];
foreach ($matches[2] as $rawName) {
$name = stripcslashes($rawName);
if ($name !== '') {
$names[$name] = true;
}
}
ksort($names, SORT_NATURAL | SORT_FLAG_CASE);
return array_keys($names);
}
function read_mitigation_action_ids(string $sourceFile, array $abilityNames): array
{
if (!is_file($sourceFile)) {
fail('Missing mitigation source file: ' . $sourceFile);
}
$php = file_get_contents($sourceFile);
if ($php === false) {
fail('Could not read mitigation source file: ' . $sourceFile);
}
$literal = extract_constant_array_literal($php, 'MITIGATION_ABILITIES');
$abilities = eval('return ' . $literal . ';');
if (!is_array($abilities)) {
fail('MITIGATION_ABILITIES did not parse as an array');
}
$wantedNames = array_fill_keys($abilityNames, true);
$ids = [];
foreach ($wantedNames as $name => $_) {
if (!isset($abilities[$name])) {
fwrite(STDERR, 'Planner ability missing in MITIGATION_ABILITIES: ' . $name . PHP_EOL);
continue;
}
$ability = $abilities[$name];
$id = (int)($ability['extraAbilityGameID'] ?? 0);
if ($id <= 0) {
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
continue;
}
$ids[$id] = true;
}
if (!$ids) {
fail('No extraAbilityGameID values found for abilities from js/ffxiv-data.js');
}
ksort($ids, SORT_NUMERIC);
return array_keys($ids);
}
function download_url(string $url): string
{
$lastError = '';
$allowInsecureDownload = getenv('FF14_MITIGATOR_INSECURE_DOWNLOAD') === '1';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 120,
CURLOPT_USERAGENT => 'ff14-mitigator-action-cache/1.0',
CURLOPT_SSL_VERIFYPEER => !$allowInsecureDownload,
CURLOPT_SSL_VERIFYHOST => $allowInsecureDownload ? 0 : 2,
]);
$body = curl_exec($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body !== false && $status >= 200 && $status < 300) {
return $body;
}
$lastError = 'cURL HTTP ' . $status . ($error ? ': ' . $error : '');
}
$wrappers = stream_get_wrappers();
if (in_array('https', $wrappers, true)) {
$context = stream_context_create([
'http' => [
'timeout' => 120,
'user_agent' => 'ff14-mitigator-action-cache/1.0',
],
'ssl' => [
'verify_peer' => !$allowInsecureDownload,
'verify_peer_name' => !$allowInsecureDownload,
],
]);
$body = file_get_contents($url, false, $context);
if ($body !== false) {
return $body;
}
$lastError = trim($lastError . '; file_get_contents failed', '; ');
}
if ($lastError !== '') {
fail('Could not download Action.json. ' . $lastError);
}
fail('This PHP installation has neither cURL nor the HTTPS stream wrapper enabled.');
}
function action_field(array $action, string $field): ?int
{
if (array_key_exists($field, $action) && is_numeric($action[$field])) {
return (int)$action[$field];
}
$fields = $action['fields'] ?? null;
if (is_array($fields) && array_key_exists($field, $fields) && is_numeric($fields[$field])) {
return (int)$fields[$field];
}
return null;
}
function action_text_field(array $action, string $field): ?string
{
if (array_key_exists($field, $action) && is_scalar($action[$field])) {
$value = trim((string)$action[$field]);
return $value !== '' ? $value : null;
}
$fields = $action['fields'] ?? null;
if (is_array($fields) && array_key_exists($field, $fields) && is_scalar($fields[$field])) {
$value = trim((string)$fields[$field]);
return $value !== '' ? $value : null;
}
return null;
}
function action_icon_url(array $action): ?string
{
$icon = $action['Icon'] ?? $action['fields']['Icon'] ?? null;
if (!is_array($icon)) {
return null;
}
$path = $icon['path_hr1'] ?? $icon['path'] ?? null;
if (!is_string($path) || $path === '') {
return null;
}
if (!preg_match('#ui/icon/([^/]+)/([^/]+)\.tex$#', $path, $matches)) {
return null;
}
return 'https://xivapi.com/i/' . $matches[1] . '/' . $matches[2] . '.png';
}
function plain_action_text(?string $text): string
{
if ($text === null || $text === '') {
return '';
}
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string)preg_replace('/\s+/u', ' ', $text));
}
function action_shield_text(array $action): ?string
{
$description = plain_action_text(action_text_field($action, 'Description_en'));
if ($description === '' || !preg_match('/barrier|absorbs|absorbed|nullif(?:y|ies)/i', $description)) {
return null;
}
if (preg_match('/(?:absorbs|absorb|nullifies|nullify)[^.]*?(?:totaling|up to)?\s*(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
return $m[1] . '% max HP';
}
if (preg_match('/barrier[^.]*?(?:absorbs|absorb|nullifies|nullify)[^.]*?(\d+)%\s*(?:of\s*)?(?:your\s+|target\'s\s+)?maximum HP/i', $description, $m)) {
return $m[1] . '% max HP';
}
if (preg_match('/barrier[^.]*?(\d+)%\s+of\s+the\s+amount\s+of\s+HP\s+restored/i', $description, $m)) {
return $m[1] . '% of HP restored';
}
if (preg_match('/barrier[^.]*?(?:heal of|Cure Potency:)\s*(\d+)\s*potency/i', $description, $m)) {
return $m[1] . ' potency';
}
return null;
}
$plannerAbilityNames = read_planner_ability_names($plannerDataSource);
$actionIds = read_mitigation_action_ids($mitigationSource, $plannerAbilityNames);
$wanted = array_fill_keys(array_map('strval', $actionIds), true);
$json = download_url(ACTION_SOURCE_URL);
$actions = json_decode($json, true);
if (!is_array($actions)) {
fail('Downloaded Action.json is not valid JSON: ' . json_last_error_msg());
}
$filtered = [];
foreach ($wanted as $id => $_) {
$action = $actions[$id] ?? null;
if (!is_array($action)) {
fwrite(STDERR, 'Missing action in downloaded Action.json: ' . $id . PHP_EOL);
continue;
}
$filtered[$id] = [
'cast' => action_field($action, 'Cast100ms'),
'recast' => action_field($action, 'Recast100ms'),
'names' => [
'en' => action_text_field($action, 'Name_en'),
'de' => action_text_field($action, 'Name_de'),
'fr' => action_text_field($action, 'Name_fr'),
'jp' => action_text_field($action, 'Name_ja'),
],
'icon' => action_icon_url($action),
'shield' => action_shield_text($action),
];
}
if (!$filtered) {
fail('No matching mitigation actions found in downloaded Action.json');
}
ksort($filtered, SORT_NUMERIC);
$outputDir = dirname($outputFile);
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true) && !is_dir($outputDir)) {
fail('Could not create output directory: ' . $outputDir);
}
$encoded = json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
fail('Could not encode filtered Action.json: ' . json_last_error_msg());
}
if (file_put_contents($outputFile, $encoded . PHP_EOL, LOCK_EX) === false) {
fail('Could not write output file: ' . $outputFile);
}
echo 'Saved ' . count($filtered) . ' actions to ' . $outputFile . PHP_EOL;