380 lines
10 KiB
PHP
380 lines
10 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';
|
|
}
|
|
|
|
$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),
|
|
];
|
|
}
|
|
|
|
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;
|