feat: expand 100th-season pool through phase 99

This commit is contained in:
2026-07-28 00:29:25 +00:00
parent 95f66e4c39
commit d8bf8ba922
5 changed files with 143957 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -38,9 +38,7 @@ class SPoolUnderU100 extends AbsFromUserPool
public static function initPool(\MeekroDB $db)
{
// 현재 ref 저장소에 보존된 공식 클래식 명장 자료는 1~29기 자료다.
// 풀 형식과 event100Unique는 이후 30~99기 자료를 같은 형태로 합칠 수 있다.
$jsonData = Json::decode(file_get_contents(__DIR__ . '/Pool/UnderS30.json'));
$jsonData = Json::decode(file_get_contents(__DIR__ . '/Pool/UnderS100.json'));
$columns = $jsonData['columns'];
$sqlValues = [];
foreach ($jsonData['data'] as $idx => $rawItem) {
+240
View File
@@ -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,
)
);
+130
View File
@@ -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;
+87
View File
@@ -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"
);
}
}
}