feat: expand 100th-season pool through phase 99
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -38,9 +38,7 @@ class SPoolUnderU100 extends AbsFromUserPool
|
|||||||
|
|
||||||
public static function initPool(\MeekroDB $db)
|
public static function initPool(\MeekroDB $db)
|
||||||
{
|
{
|
||||||
// 현재 ref 저장소에 보존된 공식 클래식 명장 자료는 1~29기 자료다.
|
$jsonData = Json::decode(file_get_contents(__DIR__ . '/Pool/UnderS100.json'));
|
||||||
// 풀 형식과 event100Unique는 이후 30~99기 자료를 같은 형태로 합칠 수 있다.
|
|
||||||
$jsonData = Json::decode(file_get_contents(__DIR__ . '/Pool/UnderS30.json'));
|
|
||||||
$columns = $jsonData['columns'];
|
$columns = $jsonData['columns'];
|
||||||
$sqlValues = [];
|
$sqlValues = [];
|
||||||
foreach ($jsonData['data'] as $idx => $rawItem) {
|
foreach ($jsonData['data'] as $idx => $rawItem) {
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the static 100th-season all-star pool from a tab-separated query result.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* mariadb --batch --raw --skip-column-names DATABASE \
|
||||||
|
* < src/centennial_allstar_candidates.sql \
|
||||||
|
* | php src/build_centennial_allstar_pool.php OUTPUT.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OUTPUT_COLUMNS = [
|
||||||
|
'generalName',
|
||||||
|
'leadership',
|
||||||
|
'strength',
|
||||||
|
'intel',
|
||||||
|
'specialDomestic',
|
||||||
|
'dex',
|
||||||
|
'imgsvr',
|
||||||
|
'picture',
|
||||||
|
'sourcePhase',
|
||||||
|
'sourceServerId',
|
||||||
|
'sourceGeneralNo',
|
||||||
|
'selectionReasons',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_SPECIAL_WAR_MAP = [
|
||||||
|
40 => 'che_event_귀병',
|
||||||
|
41 => 'che_event_신산',
|
||||||
|
42 => 'che_event_환술',
|
||||||
|
43 => 'che_event_집중',
|
||||||
|
44 => 'che_event_신중',
|
||||||
|
45 => 'che_event_반계',
|
||||||
|
50 => 'che_event_보병',
|
||||||
|
51 => 'che_event_궁병',
|
||||||
|
52 => 'che_event_기병',
|
||||||
|
53 => 'che_event_공성',
|
||||||
|
60 => 'che_event_돌격',
|
||||||
|
61 => 'che_event_무쌍',
|
||||||
|
62 => 'che_event_견고',
|
||||||
|
63 => 'che_event_위압',
|
||||||
|
70 => 'che_event_저격',
|
||||||
|
71 => 'che_event_필살',
|
||||||
|
72 => 'che_event_징병',
|
||||||
|
73 => 'che_event_의술',
|
||||||
|
74 => 'che_event_격노',
|
||||||
|
75 => 'che_event_척사',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fail(string $message): never
|
||||||
|
{
|
||||||
|
fwrite(STDERR, "error: {$message}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstInt(array $data, array $keys): int
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (array_key_exists($key, $data) && is_numeric($data[$key])) {
|
||||||
|
return (int) $data[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail('missing numeric field: ' . implode(' or ', $keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEventSpecial(mixed $rawSpecial): ?string
|
||||||
|
{
|
||||||
|
if (is_numeric($rawSpecial)) {
|
||||||
|
return LEGACY_SPECIAL_WAR_MAP[(int) $rawSpecial] ?? null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!is_string($rawSpecial)
|
||||||
|
|| $rawSpecial === ''
|
||||||
|
|| $rawSpecial === '0'
|
||||||
|
|| strcasecmp($rawSpecial, 'none') === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (str_starts_with($rawSpecial, 'che_event_')) {
|
||||||
|
return $rawSpecial;
|
||||||
|
}
|
||||||
|
if (str_starts_with($rawSpecial, 'che_')) {
|
||||||
|
return 'che_event_' . substr($rawSpecial, strlen('che_'));
|
||||||
|
}
|
||||||
|
fail("unknown historical war special: {$rawSpecial}");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSourceRow(string $line, int $lineNo): array
|
||||||
|
{
|
||||||
|
$fields = explode("\t", rtrim($line, "\r\n"), 6);
|
||||||
|
if (count($fields) !== 6) {
|
||||||
|
fail("line {$lineNo}: expected 6 tab-separated fields, got " . count($fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
[$phase, $serverId, $generalNo, $name, $rawData, $rawReasons] = $fields;
|
||||||
|
$data = json_decode($rawData, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
fail("line {$lineNo}: general data is not an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
$phaseNo = (int) $phase;
|
||||||
|
$sourceGeneralNo = (int) $generalNo;
|
||||||
|
if ($phaseNo < 1 || $phaseNo > 99 || $sourceGeneralNo <= 2) {
|
||||||
|
fail("line {$lineNo}: invalid phase/general number");
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceName = trim($name);
|
||||||
|
if ($sourceName === '') {
|
||||||
|
fail("line {$lineNo}: empty general name");
|
||||||
|
}
|
||||||
|
$generalName = sprintf('【%d기】%s', $phaseNo, $sourceName);
|
||||||
|
if (mb_strlen($generalName) > 32) {
|
||||||
|
fail("line {$lineNo}: generated name exceeds 32 characters: {$generalName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$dex = [
|
||||||
|
firstInt($data, ['dex1', 'dex0']),
|
||||||
|
firstInt($data, ['dex2', 'dex10']),
|
||||||
|
firstInt($data, ['dex3', 'dex20']),
|
||||||
|
firstInt($data, ['dex4', 'dex30']),
|
||||||
|
firstInt($data, ['dex5', 'dex40']),
|
||||||
|
];
|
||||||
|
foreach ($dex as $value) {
|
||||||
|
if ($value < 0) {
|
||||||
|
fail("line {$lineNo}: negative dex value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasons = $rawReasons === '' ? [] : explode(',', $rawReasons);
|
||||||
|
sort($reasons, SORT_STRING);
|
||||||
|
|
||||||
|
return [
|
||||||
|
$generalName,
|
||||||
|
firstInt($data, ['leadership', 'leader']),
|
||||||
|
firstInt($data, ['strength', 'power']),
|
||||||
|
firstInt($data, ['intel']),
|
||||||
|
normalizeEventSpecial($data['special2'] ?? null),
|
||||||
|
$dex,
|
||||||
|
firstInt($data, ['imgsvr']),
|
||||||
|
is_string($data['picture'] ?? null) && $data['picture'] !== ''
|
||||||
|
? $data['picture']
|
||||||
|
: 'default.jpg',
|
||||||
|
$phaseNo,
|
||||||
|
$serverId,
|
||||||
|
$sourceGeneralNo,
|
||||||
|
$reasons,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($argc !== 2) {
|
||||||
|
fail('usage: php build_centennial_allstar_pool.php OUTPUT.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputPath = $argv[1];
|
||||||
|
$rows = [];
|
||||||
|
$seenSources = [];
|
||||||
|
$nameIndexes = [];
|
||||||
|
$phaseCounts = [];
|
||||||
|
$reasonCounts = [];
|
||||||
|
$lineNo = 0;
|
||||||
|
|
||||||
|
while (($line = fgets(STDIN)) !== false) {
|
||||||
|
$lineNo++;
|
||||||
|
if (trim($line) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$row = decodeSourceRow($line, $lineNo);
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
fail("line {$lineNo}: invalid JSON: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceKey = "{$row[8]}:{$row[10]}";
|
||||||
|
if (isset($seenSources[$sourceKey])) {
|
||||||
|
fail("line {$lineNo}: duplicate source general {$sourceKey}");
|
||||||
|
}
|
||||||
|
$seenSources[$sourceKey] = true;
|
||||||
|
$nameIndexes[$row[0]][] = count($rows);
|
||||||
|
$phaseCounts[$row[8]] = ($phaseCounts[$row[8]] ?? 0) + 1;
|
||||||
|
foreach ($row[11] as $reason) {
|
||||||
|
$reasonGroup = str_starts_with($reason, 'chief:') ? 'chief' : 'hall';
|
||||||
|
$reasonCounts[$reasonGroup] = ($reasonCounts[$reasonGroup] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
fail('no input rows');
|
||||||
|
}
|
||||||
|
foreach ($nameIndexes as $generalName => $indexes) {
|
||||||
|
if (count($indexes) === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($indexes as $index) {
|
||||||
|
$rows[$index][0] .= "#{$rows[$index][10]}";
|
||||||
|
if (mb_strlen($rows[$index][0]) > 32) {
|
||||||
|
fail("disambiguated name exceeds 32 characters: {$rows[$index][0]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($phaseCounts, SORT_NUMERIC);
|
||||||
|
if (array_keys($phaseCounts) !== range(1, 99)) {
|
||||||
|
fail('input does not cover every phase from 1 through 99');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'columns' => OUTPUT_COLUMNS,
|
||||||
|
'data' => $rows,
|
||||||
|
];
|
||||||
|
$encoded = json_encode(
|
||||||
|
$payload,
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
||||||
|
) . "\n";
|
||||||
|
|
||||||
|
$outputDir = dirname($outputPath);
|
||||||
|
if (!is_dir($outputDir)) {
|
||||||
|
fail("output directory does not exist: {$outputDir}");
|
||||||
|
}
|
||||||
|
$tempPath = tempnam($outputDir, basename($outputPath) . '.tmp.');
|
||||||
|
if ($tempPath === false) {
|
||||||
|
fail("could not create temporary output in {$outputDir}");
|
||||||
|
}
|
||||||
|
if (file_put_contents($tempPath, $encoded) === false || !rename($tempPath, $outputPath)) {
|
||||||
|
@unlink($tempPath);
|
||||||
|
fail("could not write output: {$outputPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(
|
||||||
|
STDERR,
|
||||||
|
sprintf(
|
||||||
|
"wrote %d candidates across phases %d-%d (%d hall reasons, %d chief reasons)\n",
|
||||||
|
count($rows),
|
||||||
|
min(array_keys($phaseCounts)),
|
||||||
|
max(array_keys($phaseCounts)),
|
||||||
|
$reasonCounts['hall'] ?? 0,
|
||||||
|
$reasonCounts['chief'] ?? 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
WITH
|
||||||
|
phases AS (
|
||||||
|
SELECT
|
||||||
|
e.no AS phase_no,
|
||||||
|
e.server_id,
|
||||||
|
g.winner_nation,
|
||||||
|
e.l12name,
|
||||||
|
e.l12pic,
|
||||||
|
e.l11name,
|
||||||
|
e.l11pic,
|
||||||
|
e.l10name,
|
||||||
|
e.l10pic,
|
||||||
|
e.l9name,
|
||||||
|
e.l9pic,
|
||||||
|
e.l8name,
|
||||||
|
e.l8pic,
|
||||||
|
e.l7name,
|
||||||
|
e.l7pic,
|
||||||
|
e.l6name,
|
||||||
|
e.l6pic,
|
||||||
|
e.l5name,
|
||||||
|
e.l5pic
|
||||||
|
FROM emperior e
|
||||||
|
LEFT JOIN ng_games g ON g.server_id = e.server_id
|
||||||
|
WHERE e.no BETWEEN 1 AND 99
|
||||||
|
),
|
||||||
|
hall_eligible AS (
|
||||||
|
SELECT
|
||||||
|
p.phase_no,
|
||||||
|
h.server_id,
|
||||||
|
h.general_no,
|
||||||
|
h.type,
|
||||||
|
h.value,
|
||||||
|
h.id
|
||||||
|
FROM phases p
|
||||||
|
JOIN hall h ON h.server_id = p.server_id
|
||||||
|
JOIN ng_old_generals og
|
||||||
|
ON og.server_id = h.server_id
|
||||||
|
AND og.general_no = h.general_no
|
||||||
|
WHERE h.general_no > 2
|
||||||
|
AND CAST(COALESCE(JSON_VALUE(og.data, '$.npc'), 0) AS SIGNED) = 0
|
||||||
|
),
|
||||||
|
hall_ranked AS (
|
||||||
|
SELECT
|
||||||
|
phase_no,
|
||||||
|
server_id,
|
||||||
|
general_no,
|
||||||
|
type,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY server_id, type
|
||||||
|
ORDER BY value DESC, id ASC
|
||||||
|
) AS hall_rank
|
||||||
|
FROM hall_eligible
|
||||||
|
),
|
||||||
|
selection_reasons AS (
|
||||||
|
SELECT
|
||||||
|
phase_no,
|
||||||
|
server_id,
|
||||||
|
general_no,
|
||||||
|
CONCAT('hall:', type) AS reason
|
||||||
|
FROM hall_ranked
|
||||||
|
WHERE hall_rank <= 10
|
||||||
|
),
|
||||||
|
chief_slots AS (
|
||||||
|
SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic FROM phases
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic FROM phases
|
||||||
|
),
|
||||||
|
chief_reasons AS (
|
||||||
|
SELECT
|
||||||
|
c.phase_no,
|
||||||
|
c.server_id,
|
||||||
|
og.general_no,
|
||||||
|
CONCAT('chief:', c.officer_level) AS reason
|
||||||
|
FROM chief_slots c
|
||||||
|
JOIN ng_old_generals og
|
||||||
|
ON og.server_id = c.server_id
|
||||||
|
AND og.name = c.name
|
||||||
|
AND SUBSTRING_INDEX(
|
||||||
|
COALESCE(JSON_VALUE(og.data, '$.picture'), ''),
|
||||||
|
'?=',
|
||||||
|
1
|
||||||
|
) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1)
|
||||||
|
AND (
|
||||||
|
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
|
||||||
|
OR (
|
||||||
|
JSON_VALUE(og.data, '$.officer_level') IS NULL
|
||||||
|
AND (
|
||||||
|
c.winner_nation IS NULL
|
||||||
|
OR CAST(JSON_VALUE(og.data, '$.nation') AS SIGNED) = c.winner_nation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE og.general_no > 2
|
||||||
|
),
|
||||||
|
all_reasons AS (
|
||||||
|
SELECT phase_no, server_id, general_no, reason FROM selection_reasons
|
||||||
|
UNION ALL
|
||||||
|
SELECT phase_no, server_id, general_no, reason FROM chief_reasons
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.phase_no,
|
||||||
|
r.server_id,
|
||||||
|
r.general_no,
|
||||||
|
og.name,
|
||||||
|
og.data,
|
||||||
|
GROUP_CONCAT(DISTINCT r.reason ORDER BY r.reason SEPARATOR ',') AS reasons
|
||||||
|
FROM all_reasons r
|
||||||
|
JOIN ng_old_generals og
|
||||||
|
ON og.server_id = r.server_id
|
||||||
|
AND og.general_no = r.general_no
|
||||||
|
GROUP BY
|
||||||
|
r.phase_no,
|
||||||
|
r.server_id,
|
||||||
|
r.general_no,
|
||||||
|
og.name,
|
||||||
|
og.data
|
||||||
|
ORDER BY r.phase_no, r.general_no;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CentennialAllStarPoolTest extends TestCase
|
||||||
|
{
|
||||||
|
private const POOL_PATH = __DIR__ . '/../hwe/sammo/GeneralPool/Pool/UnderS100.json';
|
||||||
|
|
||||||
|
public function testPoolCoversEveryCompletedPhase(): void
|
||||||
|
{
|
||||||
|
$pool = json_decode(
|
||||||
|
file_get_contents(self::POOL_PATH),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'generalName',
|
||||||
|
'leadership',
|
||||||
|
'strength',
|
||||||
|
'intel',
|
||||||
|
'specialDomestic',
|
||||||
|
'dex',
|
||||||
|
'imgsvr',
|
||||||
|
'picture',
|
||||||
|
'sourcePhase',
|
||||||
|
'sourceServerId',
|
||||||
|
'sourceGeneralNo',
|
||||||
|
'selectionReasons',
|
||||||
|
],
|
||||||
|
$pool['columns']
|
||||||
|
);
|
||||||
|
self::assertCount(5757, $pool['data']);
|
||||||
|
|
||||||
|
$column = array_flip($pool['columns']);
|
||||||
|
$phases = [];
|
||||||
|
$sourceKeys = [];
|
||||||
|
$generalNames = [];
|
||||||
|
foreach ($pool['data'] as $row) {
|
||||||
|
self::assertCount(count($pool['columns']), $row);
|
||||||
|
|
||||||
|
$phase = $row[$column['sourcePhase']];
|
||||||
|
$sourceKey = "{$phase}:{$row[$column['sourceGeneralNo']]}";
|
||||||
|
self::assertArrayNotHasKey($sourceKey, $sourceKeys);
|
||||||
|
$sourceKeys[$sourceKey] = true;
|
||||||
|
$phases[$phase] = true;
|
||||||
|
|
||||||
|
$generalName = $row[$column['generalName']];
|
||||||
|
self::assertArrayNotHasKey($generalName, $generalNames);
|
||||||
|
$generalNames[$generalName] = true;
|
||||||
|
|
||||||
|
self::assertCount(5, $row[$column['dex']]);
|
||||||
|
self::assertNotEmpty($row[$column['selectionReasons']]);
|
||||||
|
foreach ($row[$column['selectionReasons']] as $reason) {
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^(hall:[a-z0-9_]+|chief:(?:5|6|7|8|9|10|11|12))$/',
|
||||||
|
$reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($phases, SORT_NUMERIC);
|
||||||
|
self::assertSame(range(1, 99), array_keys($phases));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEveryHistoricalEventSpecialHasAnImplementation(): void
|
||||||
|
{
|
||||||
|
$pool = json_decode(
|
||||||
|
file_get_contents(self::POOL_PATH),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
$column = array_flip($pool['columns']);
|
||||||
|
|
||||||
|
foreach ($pool['data'] as $row) {
|
||||||
|
$special = $row[$column['specialDomestic']];
|
||||||
|
if ($special === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::assertFileExists(
|
||||||
|
__DIR__ . "/../hwe/sammo/ActionSpecialDomestic/{$special}.php"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user