From 5317cb306eb7f4e35b68453c844b8c65fa75170e Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 16:18:27 +0000 Subject: [PATCH] fix: preserve image servers in dynasty records --- hwe/func_gamerule.php | 10 +- hwe/sql/schema.sql | 24 ++- scripts/migrate-general-picture.php | 223 +++++++++++++++++++++++--- src/centennial_allstar_candidates.sql | 30 ++-- tests/GeneralPictureSchemaTest.php | 39 ++++- 5 files changed, 280 insertions(+), 46 deletions(-) diff --git a/hwe/func_gamerule.php b/hwe/func_gamerule.php index ca448876..ee0790fd 100644 --- a/hwe/func_gamerule.php +++ b/hwe/func_gamerule.php @@ -780,7 +780,7 @@ function checkEmperior() $chiefs = Util::convertArrayToDict( $db->query( - 'SELECT no,npc,name,picture,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5', + 'SELECT no,npc,name,picture,imgsvr,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5', $nationID ), 'officer_level' @@ -893,20 +893,28 @@ function checkEmperior() 'rice' => $nation['rice'], 'l12name' => $chiefs[12]['name'], 'l12pic' => $chiefs[12]['picture'], + 'l12imgsvr' => $chiefs[12]['imgsvr'], 'l11name' => $chiefs[11]['name'], 'l11pic' => $chiefs[11]['picture'], + 'l11imgsvr' => $chiefs[11]['imgsvr'], 'l10name' => $chiefs[10]['name'], 'l10pic' => $chiefs[10]['picture'], + 'l10imgsvr' => $chiefs[10]['imgsvr'], 'l9name' => $chiefs[9]['name'], 'l9pic' => $chiefs[9]['picture'], + 'l9imgsvr' => $chiefs[9]['imgsvr'], 'l8name' => $chiefs[8]['name'], 'l8pic' => $chiefs[8]['picture'], + 'l8imgsvr' => $chiefs[8]['imgsvr'], 'l7name' => $chiefs[7]['name'], 'l7pic' => $chiefs[7]['picture'], + 'l7imgsvr' => $chiefs[7]['imgsvr'], 'l6name' => $chiefs[6]['name'], 'l6pic' => $chiefs[6]['picture'], + 'l6imgsvr' => $chiefs[6]['imgsvr'], 'l5name' => $chiefs[5]['name'], 'l5pic' => $chiefs[5]['picture'], + 'l5imgsvr' => $chiefs[5]['imgsvr'], 'tiger' => $tigerstr, 'eagle' => $eaglestr, 'gen' => $gen, diff --git a/hwe/sql/schema.sql b/hwe/sql/schema.sql index d8f39ad1..46f9bef5 100644 --- a/hwe/sql/schema.sql +++ b/hwe/sql/schema.sql @@ -346,21 +346,29 @@ CREATE TABLE IF NOT EXISTS `emperior` ( `gold` INT(9) NULL DEFAULT '0', `rice` INT(9) NULL DEFAULT '0', `l12name` VARCHAR(64) NULL DEFAULT '', - `l12pic` VARCHAR(32) NULL DEFAULT '', + `l12pic` VARCHAR(64) NULL DEFAULT '', + `l12imgsvr` INT(1) NULL DEFAULT NULL, `l11name` VARCHAR(64) NULL DEFAULT '', - `l11pic` VARCHAR(32) NULL DEFAULT '', + `l11pic` VARCHAR(64) NULL DEFAULT '', + `l11imgsvr` INT(1) NULL DEFAULT NULL, `l10name` VARCHAR(64) NULL DEFAULT '', - `l10pic` VARCHAR(32) NULL DEFAULT '', + `l10pic` VARCHAR(64) NULL DEFAULT '', + `l10imgsvr` INT(1) NULL DEFAULT NULL, `l9name` VARCHAR(64) NULL DEFAULT '', - `l9pic` VARCHAR(32) NULL DEFAULT '', + `l9pic` VARCHAR(64) NULL DEFAULT '', + `l9imgsvr` INT(1) NULL DEFAULT NULL, `l8name` VARCHAR(64) NULL DEFAULT '', - `l8pic` VARCHAR(32) NULL DEFAULT '', + `l8pic` VARCHAR(64) NULL DEFAULT '', + `l8imgsvr` INT(1) NULL DEFAULT NULL, `l7name` VARCHAR(64) NULL DEFAULT '', - `l7pic` VARCHAR(32) NULL DEFAULT '', + `l7pic` VARCHAR(64) NULL DEFAULT '', + `l7imgsvr` INT(1) NULL DEFAULT NULL, `l6name` VARCHAR(64) NULL DEFAULT '', - `l6pic` VARCHAR(32) NULL DEFAULT '', + `l6pic` VARCHAR(64) NULL DEFAULT '', + `l6imgsvr` INT(1) NULL DEFAULT NULL, `l5name` VARCHAR(64) NULL DEFAULT '', - `l5pic` VARCHAR(32) NULL DEFAULT '', + `l5pic` VARCHAR(64) NULL DEFAULT '', + `l5imgsvr` INT(1) NULL DEFAULT NULL, `tiger` VARCHAR(128) NULL DEFAULT '', `eagle` VARCHAR(128) NULL DEFAULT '', `gen` TEXT NULL DEFAULT '', diff --git a/scripts/migrate-general-picture.php b/scripts/migrate-general-picture.php index af19ceef..46ae1ce7 100755 --- a/scripts/migrate-general-picture.php +++ b/scripts/migrate-general-picture.php @@ -25,17 +25,43 @@ Usage: php scripts/migrate-general-picture.php --status php scripts/migrate-general-picture.php --apply --backup=/absolute/path/to/pre-migration.sql ---status is read-only. --apply widens general.picture from VARCHAR(40) to -VARCHAR(64), and requires a pre-existing, non-empty SQL backup. Stop web and -daemon traffic before applying; MariaDB/Aria DDL is not transactional. +--status is read-only. --apply widens general.picture and the eight emperior +chief picture columns to VARCHAR(64), adds nullable l12imgsvr through l5imgsvr, +and backfills only uniquely matched historical values from ng_old_generals. +It requires a pre-existing, non-empty SQL backup whenever a schema or data +change is needed. Stop web and daemon traffic before applying; MariaDB/Aria DDL +is not transactional. Unmatched or ambiguous historical values remain NULL. TEXT); exit($exitCode); } -function pictureColumnCapacity(\MeekroDB $db): ?int +/** @return list */ +function emperiorPictureLevels(): array { - $column = $db->queryFirstRow('SHOW COLUMNS FROM general WHERE Field = %s', 'picture'); + return [12, 11, 10, 9, 8, 7, 6, 5]; +} + +/** @return list */ +function pictureMigrationColumns(): array +{ + $columns = [['general', 'picture', 40]]; + foreach (emperiorPictureLevels() as $level) { + $columns[] = ['emperior', "l{$level}pic", 32]; + } + return $columns; +} + +/** @return array|null */ +function migrationColumnInfo(\MeekroDB $db, string $table, string $field): ?array +{ + $column = $db->queryFirstRow("SHOW COLUMNS FROM `$table` WHERE Field = %s", $field); + return is_array($column) ? $column : null; +} + +function pictureColumnCapacity(\MeekroDB $db, string $table, string $field): ?int +{ + $column = migrationColumnInfo($db, $table, $field); if (!$column || !is_string($column['Type'] ?? null)) { return null; } @@ -45,26 +71,152 @@ function pictureColumnCapacity(\MeekroDB $db): ?int return (int)$matches[1]; } +function imgsvrColumnIsCompatible(\MeekroDB $db, int $level): bool +{ + $column = migrationColumnInfo($db, 'emperior', "l{$level}imgsvr"); + if (!$column || !is_string($column['Type'] ?? null)) { + return false; + } + return preg_match('/^(?:tinyint|smallint|mediumint|int|bigint)\(\d+\)(?: unsigned)?$/i', $column['Type']) === 1 + && strtoupper((string)($column['Null'] ?? '')) === 'YES'; +} + function pictureMigrationState(\MeekroDB $db): string { - $capacity = pictureColumnCapacity($db); - if ($capacity === 40) { - return 'legacy'; + $needsMigration = false; + foreach (pictureMigrationColumns() as [$table, $field, $legacyCapacity]) { + $capacity = pictureColumnCapacity($db, $table, $field); + if ($capacity === $legacyCapacity) { + $needsMigration = true; + continue; + } + if ($capacity === null || $capacity < 64) { + return 'unsupported'; + } } - if ($capacity !== null && $capacity >= 64) { - return 'ready'; + + foreach (emperiorPictureLevels() as $level) { + if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) { + $needsMigration = true; + continue; + } + if (!imgsvrColumnIsCompatible($db, $level)) { + return 'unsupported'; + } } - return 'unsupported'; + + return $needsMigration ? 'legacy' : 'ready'; +} + +function imgsvrColumnsExist(\MeekroDB $db): bool +{ + foreach (emperiorPictureLevels() as $level) { + if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) { + return false; + } + } + return true; +} + +function unresolvedEmperiorImgsvrCount(\MeekroDB $db): ?int +{ + if (!imgsvrColumnsExist($db)) { + return null; + } + $terms = array_map( + static fn(int $level): string => "(`l{$level}imgsvr` IS NULL)", + emperiorPictureLevels(), + ); + $count = $db->queryFirstField('SELECT SUM(' . implode(' + ', $terms) . ') FROM emperior'); + return $count === null ? 0 : (int)$count; +} + +/** @return list */ +function recoverableEmperiorImgsvrRows(\MeekroDB $db, int $level): array +{ + $nameField = "l{$level}name"; + $pictureField = "l{$level}pic"; + $imgsvrField = "l{$level}imgsvr"; + return $db->query( + "SELECT e.`no`, MIN(CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.imgsvr')), '-1') AS SIGNED)) AS `imgsvr` + FROM `emperior` e + JOIN `ng_old_generals` og + ON og.`server_id` = e.`server_id` + AND og.`name` = e.`$nameField` + AND SUBSTRING_INDEX(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.picture')), ''), '?=', 1) + = SUBSTRING_INDEX(COALESCE(e.`$pictureField`, ''), '?=', 1) + AND CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.officer_level')), '-1') AS SIGNED) = %i + WHERE e.`$imgsvrField` IS NULL + GROUP BY e.`no` + HAVING COUNT(*) = 1 AND `imgsvr` IN (0, 1)", + $level, + ); +} + +function recoverableEmperiorImgsvrCount(\MeekroDB $db): int +{ + if (!imgsvrColumnsExist($db)) { + return 0; + } + $count = 0; + foreach (emperiorPictureLevels() as $level) { + $count += count(recoverableEmperiorImgsvrRows($db, $level)); + } + return $count; +} + +function backfillEmperiorImgsvr(\MeekroDB $db): int +{ + $updated = 0; + foreach (emperiorPictureLevels() as $level) { + $field = "l{$level}imgsvr"; + foreach (recoverableEmperiorImgsvrRows($db, $level) as $row) { + $db->update( + 'emperior', + [$field => (int)$row['imgsvr']], + "`no`=%i AND `$field` IS NULL", + (int)$row['no'], + ); + $updated++; + } + } + return $updated; } function printPictureMigrationStatus(\MeekroDB $db): string { $state = pictureMigrationState($db); - $capacity = pictureColumnCapacity($db); - printf("schema_state=%s\npicture_capacity=%s\n", $state, $capacity ?? 'unknown'); + printf("schema_state=%s\n", $state); + foreach (pictureMigrationColumns() as [$table, $field]) { + $capacity = pictureColumnCapacity($db, $table, $field); + $statusKey = $table === 'general' && $field === 'picture' + ? 'picture_capacity' + : "{$table}_{$field}_capacity"; + printf("%s=%s\n", $statusKey, $capacity ?? 'unknown'); + } + foreach (emperiorPictureLevels() as $level) { + $field = "l{$level}imgsvr"; + $column = migrationColumnInfo($db, 'emperior', $field); + printf( + "emperior_%s=%s\n", + $field, + $column === null ? 'missing' : strtolower((string)$column['Type']), + ); + } + $unresolved = unresolvedEmperiorImgsvrCount($db); + printf("unresolved_emperior_imgsvr=%s\n", $unresolved ?? 'unknown'); return $state; } +function requirePictureMigrationBackup(mixed $backup): string +{ + if (!is_string($backup) || $backup === '' || $backup[0] !== '/' || !is_file($backup) || filesize($backup) === 0) { + fwrite(STDERR, "--backup must name a pre-existing, non-empty absolute SQL backup made immediately before migration.\n"); + exit(2); + } + return $backup; +} + $options = getopt('', ['help', 'status', 'apply', 'backup:']); if (isset($options['help'])) { pictureMigrationUsage(); @@ -79,33 +231,52 @@ if (isset($options['status'])) { } $state = pictureMigrationState($db); -if ($state === 'ready') { - fwrite(STDOUT, "general.picture is already VARCHAR(64) or wider; nothing to do.\n"); - exit(0); -} -if ($state !== 'legacy') { - fwrite(STDERR, "general.picture is not the supported VARCHAR(40) schema; inspect --status first.\n"); +if ($state === 'unsupported') { + fwrite(STDERR, "One or more picture columns have an unsupported schema; inspect --status first.\n"); exit(2); } -$backup = $options['backup'] ?? null; -if (!is_string($backup) || $backup === '' || $backup[0] !== '/' || !is_file($backup) || filesize($backup) === 0) { - fwrite(STDERR, "--backup must name a pre-existing, non-empty absolute SQL backup made immediately before migration.\n"); - exit(2); +$recoverableBefore = $state === 'ready' ? recoverableEmperiorImgsvrCount($db) : 0; +if ($state === 'ready' && $recoverableBefore === 0) { + fwrite(STDOUT, "Picture schema is ready and no deterministic IMGSVR backfill candidates remain; nothing to do.\n"); + printPictureMigrationStatus($db); + exit(0); } + +requirePictureMigrationBackup($options['backup'] ?? null); if (!\sammo\tryLock()) { fwrite(STDERR, "Unable to acquire the GAME lock.\n"); exit(3); } +$backfilled = 0; try { - $db->query('ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL'); + if (pictureColumnCapacity($db, 'general', 'picture') === 40) { + $db->query('ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL'); + } + + $emperiorClauses = []; + foreach (emperiorPictureLevels() as $level) { + $pictureField = "l{$level}pic"; + $imgsvrField = "l{$level}imgsvr"; + if (pictureColumnCapacity($db, 'emperior', $pictureField) === 32) { + $emperiorClauses[] = "MODIFY `$pictureField` VARCHAR(64) NULL DEFAULT ''"; + } + if (migrationColumnInfo($db, 'emperior', $imgsvrField) === null) { + $emperiorClauses[] = "ADD COLUMN `$imgsvrField` INT(1) NULL DEFAULT NULL AFTER `$pictureField`"; + } + } + if ($emperiorClauses !== []) { + $db->query('ALTER TABLE emperior ' . implode(', ', $emperiorClauses)); + } + + $backfilled = backfillEmperiorImgsvr($db); } finally { \sammo\unlock(); } if (printPictureMigrationStatus($db) !== 'ready') { - fwrite(STDERR, "general.picture migration verification failed; restore the supplied backup.\n"); + fwrite(STDERR, "Picture-column migration verification failed; restore the supplied backup.\n"); exit(4); } -fwrite(STDOUT, "general.picture migration completed.\n"); +printf("Picture-column migration completed; backfilled_imgsvr=%d.\n", $backfilled); diff --git a/src/centennial_allstar_candidates.sql b/src/centennial_allstar_candidates.sql index 005744fd..4240bf73 100644 --- a/src/centennial_allstar_candidates.sql +++ b/src/centennial_allstar_candidates.sql @@ -6,20 +6,28 @@ phases AS ( g.winner_nation, e.l12name, e.l12pic, + e.l12imgsvr, e.l11name, e.l11pic, + e.l11imgsvr, e.l10name, e.l10pic, + e.l10imgsvr, e.l9name, e.l9pic, + e.l9imgsvr, e.l8name, e.l8pic, + e.l8imgsvr, e.l7name, e.l7pic, + e.l7imgsvr, e.l6name, e.l6pic, + e.l6imgsvr, e.l5name, - e.l5pic + e.l5pic, + e.l5imgsvr FROM emperior e LEFT JOIN ng_games g ON g.server_id = e.server_id WHERE e.no BETWEEN 1 AND 99 @@ -63,21 +71,21 @@ selection_reasons AS ( 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 + SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture, l12imgsvr AS imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic FROM phases + SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic, l11imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic FROM phases + SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic, l10imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic FROM phases + SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic, l9imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic FROM phases + SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic, l8imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic FROM phases + SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic, l7imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic FROM phases + SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic, l6imgsvr FROM phases UNION ALL - SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic FROM phases + SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic, l5imgsvr FROM phases ), chief_reasons AS ( SELECT @@ -94,6 +102,10 @@ chief_reasons AS ( '?=', 1 ) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1) + AND ( + c.imgsvr IS NULL + OR CAST(COALESCE(JSON_VALUE(og.data, '$.imgsvr'), -1) AS SIGNED) = c.imgsvr + ) AND ( CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level OR ( diff --git a/tests/GeneralPictureSchemaTest.php b/tests/GeneralPictureSchemaTest.php index c34133cd..a63260c8 100644 --- a/tests/GeneralPictureSchemaTest.php +++ b/tests/GeneralPictureSchemaTest.php @@ -14,13 +14,23 @@ final class GeneralPictureSchemaTest extends TestCase self::assertIsString($accountSchema); self::assertMatchesRegularExpression('/`picture`\s+VARCHAR\(64\)\s+NOT NULL/i', $gameSchema); self::assertMatchesRegularExpression('/`PICTURE`\s+VARCHAR\(64\)/i', $accountSchema); + foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) { + self::assertMatchesRegularExpression( + sprintf('/`l%dpic`\s+VARCHAR\(64\)/i', $level), + $gameSchema, + ); + self::assertMatchesRegularExpression( + sprintf('/`l%dimgsvr`\s+INT\(1\)\s+NULL\s+DEFAULT\s+NULL/i', $level), + $gameSchema, + ); + } $longestRemotePath = 'users/core/' . str_repeat('a', 32) . '.jpeg?=20260807'; self::assertGreaterThan(40, strlen($longestRemotePath)); self::assertLessThanOrEqual(64, strlen($longestRemotePath)); } - public function testExistingGameMigrationWidensOnlyThePictureColumn(): void + public function testExistingGameMigrationWidensAllConstrainedPictureColumns(): void { $migration = file_get_contents(__DIR__ . '/../scripts/migrate-general-picture.php'); self::assertIsString($migration); @@ -28,8 +38,33 @@ final class GeneralPictureSchemaTest extends TestCase 'ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL', $migration, ); + self::assertStringContainsString('"l{$level}pic"', $migration); + self::assertStringContainsString('"l{$level}imgsvr"', $migration); + self::assertStringContainsString("ALTER TABLE emperior", $migration); + self::assertStringContainsString("ADD COLUMN `\$imgsvrField` INT(1) NULL DEFAULT NULL", $migration); + self::assertStringContainsString('HAVING COUNT(*) = 1', $migration); + self::assertStringContainsString('Unmatched or ambiguous historical values remain NULL', $migration); + self::assertStringContainsString("? 'picture_capacity'", $migration); self::assertStringNotContainsString('UPDATE general', $migration); - self::assertStringContainsString("if (\$state === 'ready')", $migration); + self::assertStringContainsString("\$state === 'ready'", $migration); + } + + public function testUnificationPreservesChiefImageServerAndCentennialMatchingUsesIt(): void + { + $gameRule = file_get_contents(__DIR__ . '/../hwe/func_gamerule.php'); + $candidateSql = file_get_contents(__DIR__ . '/../src/centennial_allstar_candidates.sql'); + self::assertIsString($gameRule); + self::assertIsString($candidateSql); + self::assertStringContainsString('name,picture,imgsvr,belong,officer_level', $gameRule); + foreach ([12, 11, 10, 9, 8, 7, 6, 5] as $level) { + self::assertStringContainsString( + "'l{$level}imgsvr' => \$chiefs[{$level}]['imgsvr']", + $gameRule, + ); + self::assertStringContainsString("e.l{$level}imgsvr", $candidateSql); + } + self::assertStringContainsString('c.imgsvr IS NULL', $candidateSql); + self::assertStringContainsString("JSON_VALUE(og.data, '$.imgsvr')", $candidateSql); } public function testScriptsDirectoryIsDeniedOverApache(): void