#!/usr/bin/env php */ function emperiorPictureLevels(): array { 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; } if (preg_match('/^varchar\((\d+)\)$/i', $column['Type'], $matches) !== 1) { return null; } 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 { $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'; } } foreach (emperiorPictureLevels() as $level) { if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) { $needsMigration = true; continue; } if (!imgsvrColumnIsCompatible($db, $level)) { 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 $server): string { $state = pictureMigrationState($db); printf("server=%s\n", $server); 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; } function acquirePictureMigrationLock(\MeekroDB $db, string $server): bool { return (int)$db->queryFirstField( 'SELECT GET_LOCK(%s, 0)', "sammo-picture-migration-$server", ) === 1; } function releasePictureMigrationLock(\MeekroDB $db, string $server): void { $db->queryFirstField( 'SELECT RELEASE_LOCK(%s)', "sammo-picture-migration-$server", ); } $db = DB::db(); if (isset($options['status'])) { exit(printPictureMigrationStatus($db, $server) === 'unsupported' ? 2 : 0); } $state = pictureMigrationState($db); if ($state === 'unsupported') { fwrite(STDERR, "One or more picture columns have an unsupported schema; inspect --status first.\n"); exit(2); } $recoverableBefore = $state === 'ready' ? recoverableEmperiorImgsvrCount($db) : 0; if ($state === 'ready' && $recoverableBefore === 0) { fwrite(STDOUT, "Picture schema for $server is ready and no deterministic IMGSVR backfill candidates remain; nothing to do.\n"); printPictureMigrationStatus($db, $server); exit(0); } requirePictureMigrationBackup($options['backup'] ?? null); $serverClosed = isset($options['server-closed']); if ($serverClosed) { fwrite( STDERR, "WARNING: --server-closed skips the GAME lock. Continue only if web and daemon traffic for $server is already stopped.\n", ); } if (!acquirePictureMigrationLock($db, $server)) { fwrite(STDERR, "Another picture migration is already running for $server.\n"); exit(3); } $backfilled = 0; $acquiredGameLock = false; if (!$serverClosed && !\sammo\tryLock()) { releasePictureMigrationLock($db, $server); fwrite( STDERR, "Unable to acquire the GAME lock. If the server is intentionally closed and all web/daemon traffic is stopped, rerun with --server-closed.\n", ); exit(3); } if (!$serverClosed) { $acquiredGameLock = true; } try { 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 { if ($acquiredGameLock) { \sammo\unlock(); } releasePictureMigrationLock($db, $server); } if (printPictureMigrationStatus($db, $server) !== 'ready') { fwrite(STDERR, "Picture-column migration verification failed; restore the supplied backup.\n"); exit(4); } printf("Picture-column migration for %s completed; backfilled_imgsvr=%d.\n", $server, $backfilled);