From 66e2e28df2f8967a0f7ff3f08aa7cc34906d3e93 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 07:20:06 +0000 Subject: [PATCH 01/18] fix: order speciality assignment by general id --- hwe/sammo/Event/Action/AssignGeneralSpeciality.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hwe/sammo/Event/Action/AssignGeneralSpeciality.php b/hwe/sammo/Event/Action/AssignGeneralSpeciality.php index 137c4974..49733851 100644 --- a/hwe/sammo/Event/Action/AssignGeneralSpeciality.php +++ b/hwe/sammo/Event/Action/AssignGeneralSpeciality.php @@ -37,7 +37,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action $month, ))); - foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s', GameConst::$defaultSpecialDomestic) as $general) { + foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s ORDER BY no ASC', GameConst::$defaultSpecialDomestic) as $general) { $generalID = $general['no']; $special = SpecialityHelper::pickSpecialDomestic( $rng, @@ -57,7 +57,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action $logger->pushGeneralHistoryLog("특기 【{$specialText}】{$josaUl} 습득"); } - foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s', GameConst::$defaultSpecialWar) as $general) { + foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s ORDER BY no ASC', GameConst::$defaultSpecialWar) as $general) { $generalID = $general['no']; $generalAux = Json::decode($general['aux']); From f9b5733fb8252a9e266c421f43825ec0b3da3c15 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 12:43:16 +0000 Subject: [PATCH 02/18] fix: stabilize legacy general processing order --- hwe/func.php | 2 +- hwe/process_war.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hwe/func.php b/hwe/func.php index 026c4b69..5dd26c7e 100644 --- a/hwe/func.php +++ b/hwe/func.php @@ -1734,7 +1734,7 @@ function deleteNation(General $lord, bool $applyDB): array $nationGeneralList = General::createObjListFromDB( $db->queryFirstColumn( - 'SELECT `no` FROM general WHERE nation=%i AND no != %i', + 'SELECT `no` FROM general WHERE nation=%i AND no != %i ORDER BY no ASC', $nationID, $lordID ), diff --git a/hwe/process_war.php b/hwe/process_war.php index c7c84701..6947f3e6 100644 --- a/hwe/process_war.php +++ b/hwe/process_war.php @@ -37,7 +37,7 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke $city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear); - $defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0', $city->getVar('nation'), $city->getVar('city')); + $defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0 ORDER BY no', $city->getVar('nation'), $city->getVar('city')); $defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null); /** @var WarUnit[] */ From 56a023e19508b44495a85b0f19d2508719271caa Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 02:56:43 +0000 Subject: [PATCH 03/18] fix: preserve reserved and wall-clock server info --- hwe/j_server_basic_info.php | 18 +++++++++++++----- tests/GameClockBoundaryTest.php | 29 ++++++++++++++++++++++++++++- tests/GameClockTest.php | 4 ++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/hwe/j_server_basic_info.php b/hwe/j_server_basic_info.php index 92e86b96..4cfc30f1 100644 --- a/hwe/j_server_basic_info.php +++ b/hwe/j_server_basic_info.php @@ -19,7 +19,6 @@ if(!class_exists('\\sammo\\DB')){ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); -$clock = GameClock::fromStorage($gameStor); if(file_exists(__DIR__.'/.htaccess')){ $reserved = $db->queryFirstRow( @@ -71,15 +70,24 @@ if(file_exists(__DIR__.'/.htaccess')){ //TODO: 천통시에도 예약 오픈 알림이 필요..? +$usesLogicalClock = GameClock::isInitialized($gameStor); $admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']); $admin['maxUserCnt'] = $admin['maxgeneral']; $admin['npcMode'] = $admin['npcmode']; $admin['turnTerm'] = $admin['turnterm']; $admin['isUnited'] = $admin['isunited']; -$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']); -$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime'])); -$admin['starttime'] = substr($admin['opentime'], 5, 11); -$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); +if($usesLogicalClock){ + $clock = GameClock::fromStorage($gameStor); + $admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']); + $admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime'])); + $admin['starttime'] = substr($admin['opentime'], 5, 11); + $admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); +} +else{ + $admin['isOpen'] = new \DateTimeImmutable((string)$admin['opentime']) <= GameClock::readWallTime(); + $admin['starttime'] = substr((string)$admin['opentime'], 5, 11); + $admin['turntime'] = substr((string)$admin['turntime'], 5, 11); +} unset($admin['npcmode']); unset($admin['maxgeneral']); unset($admin['turnterm']); diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php index df8d7fb9..9799f955 100644 --- a/tests/GameClockBoundaryTest.php +++ b/tests/GameClockBoundaryTest.php @@ -146,14 +146,41 @@ final class GameClockBoundaryTest extends TestCase self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue')); } - public function testGatewayFormatsLogicalOpenTimeBeforeReturningIt(): void + public function testGatewayReadsClockOnlyAfterClosedReservationResponse(): void + { + $source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php'); + self::assertIsString($source); + + $closedBranch = strpos($source, "if(file_exists(__DIR__.'/.htaccess'))"); + $closedBranchEnd = strpos($source, '//TODO: 천통시에도 예약 오픈 알림이 필요..?'); + $clockDetection = strpos($source, 'GameClock::isInitialized($gameStor)'); + $clockRead = strpos($source, 'GameClock::fromStorage($gameStor)'); + + self::assertNotFalse($closedBranch); + self::assertNotFalse($closedBranchEnd); + self::assertNotFalse($clockDetection); + self::assertNotFalse($clockRead); + self::assertGreaterThan($closedBranch, $closedBranchEnd); + self::assertGreaterThan($closedBranchEnd, $clockDetection); + self::assertGreaterThan($closedBranchEnd, $clockRead); + } + + public function testGatewayPreservesWallClockProfilesAndFormatsLogicalOpenTime(): void { $source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php'); self::assertIsString($source); + self::assertStringContainsString( + '$usesLogicalClock = GameClock::isInitialized($gameStor);', + $source, + ); self::assertStringContainsString( '$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));', $source, ); + self::assertStringContainsString( + '$admin[\'isOpen\'] = new \\DateTimeImmutable((string)$admin[\'opentime\']) <= GameClock::readWallTime();', + $source, + ); } } diff --git a/tests/GameClockTest.php b/tests/GameClockTest.php index 063468a6..1ab16c79 100644 --- a/tests/GameClockTest.php +++ b/tests/GameClockTest.php @@ -109,6 +109,10 @@ final class GameClockTest extends TestCase ]); self::assertFalse(GameClock::isInitialized($legacyStorage)); + $reservedResetStorage = $this->createMock(KVStorage::class); + $reservedResetStorage->method('getValues')->willReturn([]); + self::assertFalse(GameClock::isInitialized($reservedResetStorage)); + $partialStorage = $this->createMock(KVStorage::class); $partialStorage->method('getValues')->willReturn([ 'clock_tick' => 0, From 234ec919bc08505421fa1a4c3ecbedaec56c3657 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 14:50:08 +0000 Subject: [PATCH 04/18] feat: add signed image repository sync --- README.md | 11 +++- f_install/install.php | 15 +++-- f_install/j_setup_db.php | 25 +++++--- f_install/templates/ServConfig.orig.php | 4 +- hwe/ts/gateway/install.ts | 17 +++--- j_updateServer.php | 26 ++++----- scripts/sync-image-repository.php | 20 +++++++ src/sammo/ImageSyncClient.php | 77 +++++++++++++++++++++++++ tests/ImageSyncClientTest.php | 35 +++++++++++ 9 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 scripts/sync-image-repository.php create mode 100644 src/sammo/ImageSyncClient.php create mode 100644 tests/ImageSyncClientTest.php diff --git a/README.md b/README.md index 4c6658d8..8723dbdb 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,14 @@ sudo -u www-data git clone https://storage.hided.net/gitea/devsam/core.git sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git ``` -> 이미지는 hook/git_hook.php을 통해 동기화되며, 서버 설치 과정에 이미지 갱신 키를 지정하는 것으로 '훼' 서버 업데이트 시 동기화됩니다. 이미지 서버가 게임 서버와 별개여도 동작하나, php와 git을 지원해야합니다. +> 이미지는 Gitea webhook을 기본으로 동기화합니다. Webhook 전달이 누락된 경우에도 서버 설치 과정에서 이미지 갱신 API와 `core` 전용 비밀값을 지정하면 게임 서버 업데이트 명령이 서명된 동기화를 요청합니다. 이미지 서버의 브랜치 변경 권한은 부여되지 않습니다. + +CLI에서 수동으로 복구 동기화를 요청할 수도 있습니다. + +```sh +IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \ +php scripts/sync-image-repository.php +``` ### 설치 @@ -67,4 +74,4 @@ Database 수는 로그인 관리 서버 1개, 내부 서버 7개로, 총 8개의 * MIT License * GPL 2.0 또는 이후 -만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오. \ No newline at end of file +만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오. diff --git a/f_install/install.php b/f_install/install.php index 751fe4da..b4f9b838 100644 --- a/f_install/install.php +++ b/f_install/install.php @@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
- +
- + +
+
+ +
+ +
+
- +
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
- \ No newline at end of file + diff --git a/f_install/j_setup_db.php b/f_install/j_setup_db.php index 96471480..18c22d41 100644 --- a/f_install/j_setup_db.php +++ b/f_install/j_setup_db.php @@ -12,13 +12,14 @@ $dbName = Util::getPost('db_name'); $servHost = Util::getPost('serv_host'); $sharedIconPath = Util::getPost('shared_icon_path'); $gameImagePath = Util::getPost('game_image_path'); +$imageRequestPath = Util::getPost('image_request_path'); $imageRequestKey = Util::getPost('image_request_key'); $kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', ''); $kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', ''); -if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath) { +if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath || !$imageRequestPath) { Json::die([ 'result' => false, 'reason' => '입력 값이 올바르지 않습니다' @@ -32,6 +33,21 @@ if (!filter_var($servHost, FILTER_VALIDATE_URL)) { ]); } +if (!filter_var($imageRequestPath, FILTER_VALIDATE_URL) + || parse_url($imageRequestPath, PHP_URL_SCHEME) !== 'https') { + Json::die([ + 'result' => false, + 'reason' => '이미지 갱신 API는 HTTPS URL이어야 합니다.' + ]); +} + +if ($imageRequestKey !== null && $imageRequestKey !== '' && strlen($imageRequestKey) < 32) { + Json::die([ + 'result' => false, + 'reason' => '이미지 동기화 비밀값은 32자 이상이어야 합니다.' + ]); +} + if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) { Json::die([ 'result' => false, @@ -185,8 +201,7 @@ $globalSalt = bin2hex(random_bytes(16)); $sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost); $gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost); -$imageRequestPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/git_pull.php', $servHost); -$imageKeyInstallPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/InstallKey.php', $servHost); +$imageRequestPath = WebUtil::resolveRelativePath($imageRequestPath, $servHost); $result = Util::generateFileUsingSimpleTemplate( __DIR__ . '/templates/ServConfig.orig.php', @@ -209,10 +224,6 @@ $result = Util::generateFileUsingSimpleTemplate( true ); -if ($imageRequestKey) { - @file_get_contents($imageKeyInstallPath . '?key=' . $imageRequestKey); -} - if ($result !== true) { Json::die([ 'result' => false, diff --git a/f_install/templates/ServConfig.orig.php b/f_install/templates/ServConfig.orig.php index 9160da14..ab3947aa 100644 --- a/f_install/templates/ServConfig.orig.php +++ b/f_install/templates/ServConfig.orig.php @@ -38,9 +38,7 @@ class ServConfig public static function getImagePullURI(): string { - $now = time(); - $req_hash = Util::hashPassword(sprintf("%016x", $now), static::$imageRequestKey); - return static::$imageRequestPath . "?req={$req_hash}&time={$now}"; + return static::$imageRequestPath; } /** diff --git a/hwe/ts/gateway/install.ts b/hwe/ts/gateway/install.ts index c39501e2..32f0ffb8 100644 --- a/hwe/ts/gateway/install.ts +++ b/hwe/ts/gateway/install.ts @@ -69,11 +69,8 @@ function setupDBForm() { $('#btn_random_generate_key').on('click', function (e) { e.preventDefault(); - let token = ''; - while (token.length < 24) { - token += (Math.random() + 1).toString(36).substring(7); - } - token = token.substr(0, 24); + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); $('#image_request_key').val(token); }); @@ -86,6 +83,7 @@ function setupDBForm() { serv_host: string, shared_icon_path: string, game_image_path: string, + image_request_path: string, image_request_key: string, kakao_rest_key: string, kakao_admin_key: string, @@ -124,10 +122,14 @@ function setupDBForm() { required: true, type: 'string', }, + image_request_path: { + required: true, + type: 'string', + }, image_request_key: { required: false, type: 'string', - min: 16, + min: 32, }, kakao_rest_key: { required: false, @@ -163,6 +165,7 @@ function setupDBForm() { serv_host: values.serv_host, shared_icon_path: values.shared_icon_path, game_image_path: values.game_image_path, + image_request_path: values.image_request_path, image_request_key: values.image_request_key, kakao_rest_key: values.kakao_rest_key, kakao_admin_key: values.kakao_admin_key, @@ -313,4 +316,4 @@ $(function () { -}); \ No newline at end of file +}); diff --git a/j_updateServer.php b/j_updateServer.php index 7e8abf4b..53c8675e 100644 --- a/j_updateServer.php +++ b/j_updateServer.php @@ -335,20 +335,18 @@ if ($server == $baseServerName) { if (ServConfig::$imageRequestKey) { try { - $imagePullPath = ServConfig::getImagePullURI(); - $pullResult = @file_get_contents($imagePullPath); - if ($pullResult === false) { - throw new \ErrorException('Invalid URI'); - } - $pullResult = Json::decode($pullResult); - if ($pullResult['result']) { - $imgResult = true; - $imgDetail = $pullResult['version']; - } else { - $imgResult = false; - $imgDetail = $pullResult['reason']; - } - } catch (\Exception $e) { + $configuredPath = ServConfig::$imageRequestPath; + $legacyPath = str_ends_with((string)parse_url($configuredPath, PHP_URL_PATH), '.php'); + $imageSyncPath = getenv('SAMMO_IMAGE_SYNC_URL') + ?: ($legacyPath ? 'https://sam-image.hided.net/v1/sync' : $configuredPath); + $pullResult = ImageSyncClient::sync( + $imageSyncPath, + 'core', + ServConfig::$imageRequestKey + ); + $imgResult = true; + $imgDetail = $pullResult['lastSuccess']['commit'] ?? ($pullResult['changed'] ? 'updated' : 'current'); + } catch (\Throwable $e) { $imgResult = false; $imgDetail = $e->getMessage(); } diff --git a/scripts/sync-image-repository.php b/scripts/sync-image-repository.php new file mode 100644 index 00000000..2c19b146 --- /dev/null +++ b/scripts/sync-image-repository.php @@ -0,0 +1,20 @@ +,requestId:string} + */ + public static function buildRequest( + string $client, + string $secret, + ?string $commit = null, + ?int $timestampMs = null, + ?string $requestId = null + ): array { + if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) { + throw new \InvalidArgumentException('Invalid image sync client'); + } + if (strlen($secret) < 32) { + throw new \InvalidArgumentException('Image sync secret must be at least 32 characters'); + } + if ($commit !== null && !preg_match('/^[0-9a-f]{40,64}$/i', $commit)) { + throw new \InvalidArgumentException('Image commit must be a full Git SHA'); + } + $body = Json::encode($commit === null ? (object)[] : ['commit' => $commit]); + $timestamp = (string)($timestampMs ?? (int)floor(microtime(true) * 1000)); + $requestId ??= bin2hex(random_bytes(16)); + $signature = hash_hmac('sha256', "{$timestamp}.{$requestId}.{$body}", $secret); + return [ + 'body' => $body, + 'requestId' => $requestId, + 'headers' => [ + 'Content-Type: application/json', + "X-Image-Client: {$client}", + "X-Image-Timestamp: {$timestamp}", + "X-Image-Request-Id: {$requestId}", + "X-Image-Signature: {$signature}", + ], + ]; + } + + /** @return array */ + public static function sync(string $url, string $client, string $secret, ?string $commit = null): array + { + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) { + throw new \InvalidArgumentException('Image sync URL must use HTTPS except for loopback tests'); + } + $request = self::buildRequest($client, $secret, $commit); + $curl = curl_init($url); + if ($curl === false) { + throw new \RuntimeException('Unable to initialize image sync request'); + } + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $request['headers'], + CURLOPT_POSTFIELDS => $request['body'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + ]); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($response === false) { + throw new \RuntimeException("Image sync request failed: {$error}"); + } + $decoded = Json::decode($response); + if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) { + throw new \RuntimeException("Image sync rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error')); + } + return $decoded; + } +} diff --git a/tests/ImageSyncClientTest.php b/tests/ImageSyncClientTest.php new file mode 100644 index 00000000..d1b185f7 --- /dev/null +++ b/tests/ImageSyncClientTest.php @@ -0,0 +1,35 @@ +expectException(InvalidArgumentException::class); + ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef'); + } +} From b095fb4bf1b079f8669d9579cdac768d21d4d87d Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 15:43:44 +0000 Subject: [PATCH 05/18] feat: add flagged remote user icon upload --- README.md | 17 ++++ f_install/j_setup_db.php | 3 + f_install/j_update_js_css_path.php | 14 ++- f_install/templates/ServConfig.orig.php | 29 ++++++ i_entrance/j_icon_change.php | 28 +++++- src/sammo/RemoteUserIconUploadClient.php | 118 +++++++++++++++++++++++ tests/RemoteUserIconUploadClientTest.php | 45 +++++++++ 7 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/sammo/RemoteUserIconUploadClient.php create mode 100644 tests/RemoteUserIconUploadClientTest.php diff --git a/README.md b/README.md index 8723dbdb..40441df2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,23 @@ IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \ php scripts/sync-image-repository.php ``` +Ref의 사용자 아이콘 원격 업로드 구현은 기본적으로 꺼져 있습니다. 이미지 서버의 +`image_upload_core_secret`과 동일한 값을 Git 제외 파일 +`d_setting/image_upload_core_secret`에 저장한 뒤 실제 +`d_setting/ServConfig.php`에서 다음 값만 변경하면 기존 화면을 그대로 둔 채 +원격 bind 저장소로 전환됩니다. + +```php +public static $remoteUserIconUploadEnabled = true; +public static $remoteUserIconUploadPath = 'https://sam-image.hided.net'; +public static $remoteUserIconUploadSecretFile = 'd_setting/image_upload_core_secret'; +``` + +플래그가 `false`이면 기존 `d_pic` 및 `IMGSVR=1` 동작을 유지합니다. `true`이면 +PHP 서버가 인증 사용자와 이미지 규격을 먼저 검사한 후 60초 HMAC 권한으로 +이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다. 공유 +비밀값은 브라우저나 Cloudflare로 보내지 않습니다. + ### 설치 이후 해당 경로를 웹 브라우저를 통해 접근하여 설치를 진행할 수 있습니다. diff --git a/f_install/j_setup_db.php b/f_install/j_setup_db.php index 18c22d41..88044563 100644 --- a/f_install/j_setup_db.php +++ b/f_install/j_setup_db.php @@ -212,6 +212,9 @@ $result = Util::generateFileUsingSimpleTemplate( 'gameImagePath' => $gameImagePath, 'imageRequestPath' => $imageRequestPath, 'imageRequestKey' => $imageRequestKey, + 'remoteUserIconUploadEnabled' => 'false', + 'remoteUserIconUploadPath' => 'https://sam-image.hided.net', + 'remoteUserIconUploadSecretFile' => 'd_setting/image_upload_core_secret', 'serverList' => [ ['che', '체', 'white'], ['kwe', '퀘', 'yellow'], diff --git a/f_install/j_update_js_css_path.php b/f_install/j_update_js_css_path.php index e561e3b9..8b4ebcd1 100644 --- a/f_install/j_update_js_css_path.php +++ b/f_install/j_update_js_css_path.php @@ -33,7 +33,12 @@ if ($servHost) { [ 'serverBasePath' => $servHost, 'sharedIconPath' => $sharedIconPath, - 'gameImagePath' => $gameImagePath + 'gameImagePath' => $gameImagePath, + 'imageRequestPath' => ServConfig::$imageRequestPath, + 'imageRequestKey' => ServConfig::$imageRequestKey, + 'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false', + 'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath, + 'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile ], true ); @@ -64,7 +69,12 @@ if ($servHost) { [ 'serverBasePath' => $servHost, 'sharedIconPath' => $sharedIconPath, - 'gameImagePath' => $gameImagePath + 'gameImagePath' => $gameImagePath, + 'imageRequestPath' => ServConfig::$imageRequestPath, + 'imageRequestKey' => ServConfig::$imageRequestKey, + 'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false', + 'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath, + 'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile ], true ); diff --git a/f_install/templates/ServConfig.orig.php b/f_install/templates/ServConfig.orig.php index ab3947aa..7ac66e3e 100644 --- a/f_install/templates/ServConfig.orig.php +++ b/f_install/templates/ServConfig.orig.php @@ -13,6 +13,9 @@ class ServConfig public static $gameImagePath = "_tK_gameImagePath_"; public static $imageRequestPath = "_tK_imageRequestPath_"; public static $imageRequestKey = '_tK_imageRequestKey_'; + public static $remoteUserIconUploadEnabled = _tK_remoteUserIconUploadEnabled_; + public static $remoteUserIconUploadPath = '_tK_remoteUserIconUploadPath_'; + public static $remoteUserIconUploadSecretFile = '_tK_remoteUserIconUploadSecretFile_'; private static $serverList = null; public static function getSharedIconPath(string $filepath = ''): string @@ -41,6 +44,32 @@ class ServConfig return static::$imageRequestPath; } + public static function isRemoteUserIconUploadEnabled(): bool + { + return static::$remoteUserIconUploadEnabled; + } + + public static function getRemoteUserIconUploadURI(string $filename): string + { + return rtrim(static::$remoteUserIconUploadPath, '/') . '/v1/uploads/user-icons/core/' . $filename; + } + + public static function getRemoteUserIconUploadSecret(): string + { + $path = static::$remoteUserIconUploadSecretFile; + if ($path === '' || str_contains($path, "\0")) { + throw new \RuntimeException('Remote user icon upload secret file is not configured'); + } + if ($path[0] !== '/') { + $path = ROOT . '/' . $path; + } + $secret = trim((string)file_get_contents($path)); + if (strlen($secret) < 32) { + throw new \RuntimeException('Remote user icon upload secret must be at least 32 characters'); + } + return $secret; + } + /** * 서버 설정 반환 * diff --git a/i_entrance/j_icon_change.php b/i_entrance/j_icon_change.php index afc1e2b1..3b77822d 100644 --- a/i_entrance/j_icon_change.php +++ b/i_entrance/j_icon_change.php @@ -78,14 +78,36 @@ if(!is_uploaded_file($image['tmp_name'])) { break; } - if(!move_uploaded_file($image['tmp_name'], $dest)) { + if (RemoteUserIconUploadClient::isConfiguredEnabled()) { + try { + $remoteName = bin2hex(random_bytes(16)).$newExt; + $contentType = image_type_to_mime_type($imageType); + RemoteUserIconUploadClient::uploadConfigured( + $remoteName, + $contentType, + (string)file_get_contents($image['tmp_name']) + ); + $newPicName = "users/core/{$remoteName}"; + $storedRemotely = true; + } catch (\Throwable $error) { + error_log('Remote user icon upload failed: ' . $error->getMessage()); + $storedRemotely = false; + } + } else { + $storedRemotely = null; + } + + if($storedRemotely === false) { + $response['reason'] = '원격 이미지 저장소 업로드에 실패했습니다!'; + $response['result'] = false; + } elseif($storedRemotely === null && !move_uploaded_file($image['tmp_name'], $dest)) { $response['reason'] = '업로드에 실패했습니다!'; $response['result'] = false; } else { $pic = "{$newPicName}?={$rf}"; RootDB::db()->update('member',[ 'PICTURE' => $pic, - 'IMGSVR' => 1 + 'IMGSVR' => $storedRemotely === true ? 0 : 1 ], 'NO=%i', $userID); $servers = []; @@ -104,4 +126,4 @@ if(!is_uploaded_file($image['tmp_name'])) { } -Json::die($response); \ No newline at end of file +Json::die($response); diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php new file mode 100644 index 00000000..cc4e3b02 --- /dev/null +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -0,0 +1,118 @@ + */ + public static function uploadConfigured(string $filename, string $contentType, string $body): array + { + if (!self::isConfiguredEnabled() + || !property_exists(ServConfig::class, 'remoteUserIconUploadPath') + || !property_exists(ServConfig::class, 'remoteUserIconUploadSecretFile')) { + throw new \RuntimeException('Remote user icon upload is not configured'); + } + $secretPath = ServConfig::$remoteUserIconUploadSecretFile; + if (!is_string($secretPath) || $secretPath === '' || str_contains($secretPath, "\0")) { + throw new \RuntimeException('Remote user icon upload secret file is not configured'); + } + if ($secretPath[0] !== '/') { + $secretPath = ROOT . '/' . $secretPath; + } + $secret = trim((string)file_get_contents($secretPath)); + return self::upload( + rtrim((string)ServConfig::$remoteUserIconUploadPath, '/') + . '/v1/uploads/user-icons/core/' . $filename, + 'core', + $secret, + $contentType, + $body + ); + } + + /** @return array{headers:list,requestId:string,expires:string} */ + public static function buildRequest( + string $url, + string $client, + string $secret, + string $contentType, + string $body, + ?int $expires = null, + ?string $requestId = null + ): array { + if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) { + throw new \InvalidArgumentException('Invalid image upload client'); + } + if (strlen($secret) < 32) { + throw new \InvalidArgumentException('Image upload secret must be at least 32 characters'); + } + $path = parse_url($url, PHP_URL_PATH); + if (!is_string($path) || !preg_match('#^/v1/uploads/user-icons/' . preg_quote($client, '#') . '/[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$#', $path)) { + throw new \InvalidArgumentException('Invalid image upload URL'); + } + $expiresText = (string)($expires ?? time() + 60); + $requestId ??= bin2hex(random_bytes(16)); + $digest = hash('sha256', $body); + $signature = hash_hmac( + 'sha256', + "{$expiresText}.{$requestId}.{$path}.{$contentType}.{$digest}", + $secret + ); + return [ + 'requestId' => $requestId, + 'expires' => $expiresText, + 'headers' => [ + "Content-Type: {$contentType}", + "X-Image-Client: {$client}", + "X-Image-Expires: {$expiresText}", + "X-Image-Request-Id: {$requestId}", + "X-Image-Signature: {$signature}", + ], + ]; + } + + /** @return array */ + public static function upload(string $url, string $client, string $secret, string $contentType, string $body): array + { + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) { + throw new \InvalidArgumentException('Image upload URL must use HTTPS except for loopback tests'); + } + $request = self::buildRequest($url, $client, $secret, $contentType, $body); + $curl = curl_init($url); + if ($curl === false) { + throw new \RuntimeException('Unable to initialize image upload request'); + } + curl_setopt_array($curl, [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_HTTPHEADER => $request['headers'], + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 20, + ]); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($response === false) { + throw new \RuntimeException("Image upload request failed: {$error}"); + } + $decoded = Json::decode($response); + if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) { + throw new \RuntimeException("Image upload rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error')); + } + $filename = basename((string)parse_url($url, PHP_URL_PATH)); + if (($decoded['path'] ?? null) !== "icons/users/{$client}/{$filename}") { + throw new \RuntimeException('Image upload returned an unexpected path'); + } + return $decoded; + } +} diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php new file mode 100644 index 00000000..271f2ad8 --- /dev/null +++ b/tests/RemoteUserIconUploadClientTest.php @@ -0,0 +1,45 @@ +expectException(InvalidArgumentException::class); + RemoteUserIconUploadClient::buildRequest( + 'https://sam-image.hided.net/v1/uploads/user-icons/core2026/' . str_repeat('a', 32) . '.png', + 'core', + str_repeat('u', 32), + 'image/png', + 'body' + ); + } +} From eeb931d3f09dde69d0383503e6c9b46de67694d5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 15:53:06 +0000 Subject: [PATCH 06/18] feat: upload editor images to image service --- README.md | 5 ++- hwe/sammo/API/Misc/UploadImage.php | 42 ++++++++++++-------- src/sammo/RemoteUserIconUploadClient.php | 49 +++++++++++++++++++----- tests/RemoteUserIconUploadClientTest.php | 14 +++++++ 4 files changed, 83 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 40441df2..e31fe02a 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ public static $remoteUserIconUploadSecretFile = 'd_setting/image_upload_core_sec 플래그가 `false`이면 기존 `d_pic` 및 `IMGSVR=1` 동작을 유지합니다. `true`이면 PHP 서버가 인증 사용자와 이미지 규격을 먼저 검사한 후 60초 HMAC 권한으로 -이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다. 공유 -비밀값은 브라우저나 Cloudflare로 보내지 않습니다. +이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다. +국방·외교 등 TipTap 편집기 첨부 이미지도 같은 플래그로 `/uploads/core/` bind +저장소로 전환됩니다. 공유 비밀값은 브라우저나 Cloudflare로 보내지 않습니다. ### 설치 diff --git a/hwe/sammo/API/Misc/UploadImage.php b/hwe/sammo/API/Misc/UploadImage.php index 36e06043..c58f419a 100644 --- a/hwe/sammo/API/Misc/UploadImage.php +++ b/hwe/sammo/API/Misc/UploadImage.php @@ -8,6 +8,7 @@ use sammo\AppConf; use sammo\Enums\APIRecoveryType; use sammo\KVStorage; use sammo\RootDB; +use sammo\RemoteUserIconUploadClient; use sammo\TimeUtil; use sammo\UniqueConst; use sammo\Validator; @@ -60,22 +61,33 @@ class UploadImage extends \sammo\BaseAPI $imgName = hash_final($oMD); $imgFullName = "{$imgName}.{$extension}"; - $destDir = AppConf::getUserIconPathFS() . '/uploaded_image'; - $destPath = "{$destDir}/{$imgFullName}"; + $remotePath = null; + if (RemoteUserIconUploadClient::isConfiguredEnabled()) { + try { + RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData); + $remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName); + } catch (\Throwable $error) { + error_log('Remote content image upload failed: ' . $error->getMessage()); + return '원격 이미지 저장소 업로드에 실패했습니다!'; + } + } else { + $destDir = AppConf::getUserIconPathFS() . '/uploaded_image'; + $destPath = "{$destDir}/{$imgFullName}"; - if (!file_exists($destPath)) { - if (!file_exists($destDir)) { - mkdir($destDir); - } - if (!is_dir($destDir)) { - return '버그! 업로드 경로 확인!'; - } - if (!is_writable($destDir)) { - return '버그! 업로드 권한 확인!'; - } + if (!file_exists($destPath)) { + if (!file_exists($destDir)) { + mkdir($destDir); + } + if (!is_dir($destDir)) { + return '버그! 업로드 경로 확인!'; + } + if (!is_writable($destDir)) { + return '버그! 업로드 권한 확인!'; + } - if (!file_put_contents($destPath, $imageData)) { - return '업로드에 실패했습니다!'; + if (!file_put_contents($destPath, $imageData)) { + return '업로드에 실패했습니다!'; + } } } @@ -96,7 +108,7 @@ class UploadImage extends \sammo\BaseAPI return [ 'result' => true, - 'path'=>AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName, + 'path'=>$remotePath ?? AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName, ]; } } diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php index cc4e3b02..1ae7aa28 100644 --- a/src/sammo/RemoteUserIconUploadClient.php +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -12,6 +12,38 @@ final class RemoteUserIconUploadClient /** @return array */ public static function uploadConfigured(string $filename, string $contentType, string $body): array + { + [$baseUrl, $secret] = self::configuredBaseUrlAndSecret(); + return self::upload( + "{$baseUrl}/v1/uploads/user-icons/core/{$filename}", + 'core', + $secret, + $contentType, + $body + ); + } + + /** @return array */ + public static function uploadContentConfigured(string $filename, string $contentType, string $body): array + { + [$baseUrl, $secret] = self::configuredBaseUrlAndSecret(); + return self::upload( + "{$baseUrl}/v1/uploads/content/core/{$filename}", + 'core', + $secret, + $contentType, + $body + ); + } + + public static function getConfiguredContentPublicUrl(string $filename): string + { + [$baseUrl] = self::configuredBaseUrlAndSecret(); + return "{$baseUrl}/uploads/core/{$filename}"; + } + + /** @return array{string,string} */ + private static function configuredBaseUrlAndSecret(): array { if (!self::isConfiguredEnabled() || !property_exists(ServConfig::class, 'remoteUserIconUploadPath') @@ -26,14 +58,7 @@ final class RemoteUserIconUploadClient $secretPath = ROOT . '/' . $secretPath; } $secret = trim((string)file_get_contents($secretPath)); - return self::upload( - rtrim((string)ServConfig::$remoteUserIconUploadPath, '/') - . '/v1/uploads/user-icons/core/' . $filename, - 'core', - $secret, - $contentType, - $body - ); + return [rtrim((string)ServConfig::$remoteUserIconUploadPath, '/'), $secret]; } /** @return array{headers:list,requestId:string,expires:string} */ @@ -53,7 +78,7 @@ final class RemoteUserIconUploadClient throw new \InvalidArgumentException('Image upload secret must be at least 32 characters'); } $path = parse_url($url, PHP_URL_PATH); - if (!is_string($path) || !preg_match('#^/v1/uploads/user-icons/' . preg_quote($client, '#') . '/[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$#', $path)) { + if (!is_string($path) || !preg_match('#^/v1/uploads/(?:user-icons|content)/' . preg_quote($client, '#') . '/[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$#', $path)) { throw new \InvalidArgumentException('Invalid image upload URL'); } $expiresText = (string)($expires ?? time() + 60); @@ -110,7 +135,11 @@ final class RemoteUserIconUploadClient throw new \RuntimeException("Image upload rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error')); } $filename = basename((string)parse_url($url, PHP_URL_PATH)); - if (($decoded['path'] ?? null) !== "icons/users/{$client}/{$filename}") { + $category = str_contains((string)parse_url($url, PHP_URL_PATH), '/content/') ? 'content' : 'user-icons'; + $expectedPath = $category === 'content' + ? "uploads/{$client}/{$filename}" + : "icons/users/{$client}/{$filename}"; + if (($decoded['path'] ?? null) !== $expectedPath) { throw new \RuntimeException('Image upload returned an unexpected path'); } return $decoded; diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php index 271f2ad8..a62bfe60 100644 --- a/tests/RemoteUserIconUploadClientTest.php +++ b/tests/RemoteUserIconUploadClientTest.php @@ -42,4 +42,18 @@ final class RemoteUserIconUploadClientTest extends TestCase 'body' ); } + + public function testBuildRequestAcceptsScopedEditorContent(): void + { + $request = RemoteUserIconUploadClient::buildRequest( + 'https://sam-image.hided.net/v1/uploads/content/core/' . str_repeat('b', 32) . '.jpeg', + 'core', + str_repeat('u', 32), + 'image/jpeg', + "\xff\xd8\xffbody", + 1786012860, + 'core-content-1234' + ); + self::assertStringContainsString('X-Image-Client: core', implode("\n", $request['headers'])); + } } From f3bf490df3470c83529cdd782ce320ada700ff09 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 14:59:44 +0000 Subject: [PATCH 07/18] fix: expose safe remote upload errors --- hwe/sammo/API/Misc/UploadImage.php | 2 +- i_entrance/j_icon_change.php | 2 +- src/sammo/RemoteUserIconUploadClient.php | 108 ++++++++++++++++++++--- tests/RemoteUserIconUploadClientTest.php | 65 ++++++++++++++ 4 files changed, 165 insertions(+), 12 deletions(-) diff --git a/hwe/sammo/API/Misc/UploadImage.php b/hwe/sammo/API/Misc/UploadImage.php index c58f419a..519a97d6 100644 --- a/hwe/sammo/API/Misc/UploadImage.php +++ b/hwe/sammo/API/Misc/UploadImage.php @@ -67,7 +67,7 @@ class UploadImage extends \sammo\BaseAPI RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData); $remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName); } catch (\Throwable $error) { - error_log('Remote content image upload failed: ' . $error->getMessage()); + RemoteUserIconUploadClient::logFailure('content-image', $error); return '원격 이미지 저장소 업로드에 실패했습니다!'; } } else { diff --git a/i_entrance/j_icon_change.php b/i_entrance/j_icon_change.php index 3b77822d..3e7bb3a8 100644 --- a/i_entrance/j_icon_change.php +++ b/i_entrance/j_icon_change.php @@ -90,7 +90,7 @@ if(!is_uploaded_file($image['tmp_name'])) { $newPicName = "users/core/{$remoteName}"; $storedRemotely = true; } catch (\Throwable $error) { - error_log('Remote user icon upload failed: ' . $error->getMessage()); + RemoteUserIconUploadClient::logFailure('user-icon', $error); $storedRemotely = false; } } else { diff --git a/src/sammo/RemoteUserIconUploadClient.php b/src/sammo/RemoteUserIconUploadClient.php index 1ae7aa28..047ca72d 100644 --- a/src/sammo/RemoteUserIconUploadClient.php +++ b/src/sammo/RemoteUserIconUploadClient.php @@ -2,8 +2,27 @@ namespace sammo; +final class RemoteImageUploadException extends \RuntimeException +{ +} + final class RemoteUserIconUploadClient { + private const SAFE_EXACT_ERRORS = [ + 'Invalid image upload client', + 'Image upload secret must be at least 32 characters', + 'Invalid image upload URL', + 'Image upload URL must use HTTPS except for loopback tests', + 'Remote user icon upload is not configured', + 'Remote user icon upload secret file is not configured', + 'Remote user icon upload secret file cannot be read', + 'Remote user icon upload secret is too short', + 'Unable to initialize image upload request', + 'Image upload returned invalid JSON', + 'Image upload returned an unsuccessful response', + 'Image upload returned an unexpected path', + ]; + public static function isConfiguredEnabled(): bool { return property_exists(ServConfig::class, 'remoteUserIconUploadEnabled') @@ -42,22 +61,84 @@ final class RemoteUserIconUploadClient return "{$baseUrl}/uploads/core/{$filename}"; } + /** + * Record a caught upload failure in both the PHP service log and the + * operator-facing SQLite log without persisting request arguments, response + * bodies, headers, or secret values. + */ + public static function logFailure( + string $operation, + \Throwable $error, + ?callable $systemLogger = null, + ?callable $structuredLogger = null + ): void { + $label = match ($operation) { + 'user-icon' => 'Remote user icon upload', + 'content-image' => 'Remote content image upload', + default => 'Remote image upload', + }; + $reason = self::safeFailureReason($error); + $message = "{$label} failed: {$reason}"; + + if ($systemLogger === null) { + error_log($message); + } else { + $systemLogger($message); + } + + try { + $arguments = [ + 'RemoteImageUploadFailure', + $message, + $error->getFile() . ':' . $error->getLine(), + [], + ]; + if ($structuredLogger === null) { + logError(...$arguments); + } else { + $structuredLogger(...$arguments); + } + } catch (\Throwable $loggingError) { + error_log('Remote image upload structured logging failed: ' . get_debug_type($loggingError)); + } + } + + private static function safeFailureReason(\Throwable $error): string + { + if ($error instanceof RemoteImageUploadException || $error instanceof \InvalidArgumentException) { + $message = $error->getMessage(); + if (in_array($message, self::SAFE_EXACT_ERRORS, true) + || ($error instanceof RemoteImageUploadException + && preg_match('/^(?:Image upload rejected \([1-5][0-9]{2}\)|Image upload request failed \(cURL [0-9]+\))$/D', $message))) { + return $message; + } + } + return 'Unexpected ' . get_debug_type($error); + } + /** @return array{string,string} */ private static function configuredBaseUrlAndSecret(): array { if (!self::isConfiguredEnabled() || !property_exists(ServConfig::class, 'remoteUserIconUploadPath') || !property_exists(ServConfig::class, 'remoteUserIconUploadSecretFile')) { - throw new \RuntimeException('Remote user icon upload is not configured'); + throw new RemoteImageUploadException('Remote user icon upload is not configured'); } $secretPath = ServConfig::$remoteUserIconUploadSecretFile; if (!is_string($secretPath) || $secretPath === '' || str_contains($secretPath, "\0")) { - throw new \RuntimeException('Remote user icon upload secret file is not configured'); + throw new RemoteImageUploadException('Remote user icon upload secret file is not configured'); } if ($secretPath[0] !== '/') { $secretPath = ROOT . '/' . $secretPath; } - $secret = trim((string)file_get_contents($secretPath)); + $secretContents = @file_get_contents($secretPath); + if ($secretContents === false) { + throw new RemoteImageUploadException('Remote user icon upload secret file cannot be read'); + } + $secret = trim($secretContents); + if (strlen($secret) < 32) { + throw new RemoteImageUploadException('Remote user icon upload secret is too short'); + } return [rtrim((string)ServConfig::$remoteUserIconUploadPath, '/'), $secret]; } @@ -113,7 +194,7 @@ final class RemoteUserIconUploadClient $request = self::buildRequest($url, $client, $secret, $contentType, $body); $curl = curl_init($url); if ($curl === false) { - throw new \RuntimeException('Unable to initialize image upload request'); + throw new RemoteImageUploadException('Unable to initialize image upload request'); } curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => 'PUT', @@ -125,14 +206,21 @@ final class RemoteUserIconUploadClient ]); $response = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); - $error = curl_error($curl); + $curlErrorNumber = curl_errno($curl); curl_close($curl); if ($response === false) { - throw new \RuntimeException("Image upload request failed: {$error}"); + throw new RemoteImageUploadException("Image upload request failed (cURL {$curlErrorNumber})"); } - $decoded = Json::decode($response); - if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) { - throw new \RuntimeException("Image upload rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error')); + if ($status < 200 || $status >= 300) { + throw new RemoteImageUploadException("Image upload rejected ({$status})"); + } + try { + $decoded = Json::decode($response); + } catch (\Throwable $error) { + throw new RemoteImageUploadException('Image upload returned invalid JSON', previous: $error); + } + if (!($decoded['ok'] ?? false)) { + throw new RemoteImageUploadException('Image upload returned an unsuccessful response'); } $filename = basename((string)parse_url($url, PHP_URL_PATH)); $category = str_contains((string)parse_url($url, PHP_URL_PATH), '/content/') ? 'content' : 'user-icons'; @@ -140,7 +228,7 @@ final class RemoteUserIconUploadClient ? "uploads/{$client}/{$filename}" : "icons/users/{$client}/{$filename}"; if (($decoded['path'] ?? null) !== $expectedPath) { - throw new \RuntimeException('Image upload returned an unexpected path'); + throw new RemoteImageUploadException('Image upload returned an unexpected path'); } return $decoded; } diff --git a/tests/RemoteUserIconUploadClientTest.php b/tests/RemoteUserIconUploadClientTest.php index a62bfe60..6703f9bd 100644 --- a/tests/RemoteUserIconUploadClientTest.php +++ b/tests/RemoteUserIconUploadClientTest.php @@ -1,6 +1,7 @@ Date: Fri, 7 Aug 2026 16:01:32 +0000 Subject: [PATCH 08/18] fix: allow remote icon paths in generals --- hwe/sql/schema.sql | 2 +- scripts/.htaccess | 6 ++ scripts/migrate-general-picture.php | 111 ++++++++++++++++++++++++++++ tests/GeneralPictureSchemaTest.php | 42 +++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 scripts/.htaccess create mode 100755 scripts/migrate-general-picture.php create mode 100644 tests/GeneralPictureSchemaTest.php diff --git a/hwe/sql/schema.sql b/hwe/sql/schema.sql index 6eeefdfb..d8f39ad1 100644 --- a/hwe/sql/schema.sql +++ b/hwe/sql/schema.sql @@ -11,7 +11,7 @@ CREATE TABLE `general` ( `bornyear` INT(3) NULL DEFAULT '180', `deadyear` INT(3) NULL DEFAULT '300', `newmsg` INT(1) NULL DEFAULT '0', - `picture` VARCHAR(40) NOT NULL, + `picture` VARCHAR(64) NOT NULL, `imgsvr` INT(1) NOT NULL DEFAULT '0', `name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin', `owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin', diff --git a/scripts/.htaccess b/scripts/.htaccess new file mode 100644 index 00000000..a4738cfd --- /dev/null +++ b/scripts/.htaccess @@ -0,0 +1,6 @@ + + Require all denied + + + Deny from all + diff --git a/scripts/migrate-general-picture.php b/scripts/migrate-general-picture.php new file mode 100755 index 00000000..af19ceef --- /dev/null +++ b/scripts/migrate-general-picture.php @@ -0,0 +1,111 @@ +#!/usr/bin/env php +queryFirstRow('SHOW COLUMNS FROM general WHERE Field = %s', 'picture'); + 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 pictureMigrationState(\MeekroDB $db): string +{ + $capacity = pictureColumnCapacity($db); + if ($capacity === 40) { + return 'legacy'; + } + if ($capacity !== null && $capacity >= 64) { + return 'ready'; + } + return 'unsupported'; +} + +function printPictureMigrationStatus(\MeekroDB $db): string +{ + $state = pictureMigrationState($db); + $capacity = pictureColumnCapacity($db); + printf("schema_state=%s\npicture_capacity=%s\n", $state, $capacity ?? 'unknown'); + return $state; +} + +$options = getopt('', ['help', 'status', 'apply', 'backup:']); +if (isset($options['help'])) { + pictureMigrationUsage(); +} +if (isset($options['status']) === isset($options['apply'])) { + pictureMigrationUsage(2); +} + +$db = DB::db(); +if (isset($options['status'])) { + exit(printPictureMigrationStatus($db) === 'unsupported' ? 2 : 0); +} + +$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"); + 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); +} +if (!\sammo\tryLock()) { + fwrite(STDERR, "Unable to acquire the GAME lock.\n"); + exit(3); +} + +try { + $db->query('ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL'); +} finally { + \sammo\unlock(); +} + +if (printPictureMigrationStatus($db) !== 'ready') { + fwrite(STDERR, "general.picture migration verification failed; restore the supplied backup.\n"); + exit(4); +} +fwrite(STDOUT, "general.picture migration completed.\n"); diff --git a/tests/GeneralPictureSchemaTest.php b/tests/GeneralPictureSchemaTest.php new file mode 100644 index 00000000..c34133cd --- /dev/null +++ b/tests/GeneralPictureSchemaTest.php @@ -0,0 +1,42 @@ + Date: Fri, 7 Aug 2026 16:18:27 +0000 Subject: [PATCH 09/18] 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 From 463554654c127210c6637938f5904ff58eb9b927 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 16:26:56 +0000 Subject: [PATCH 10/18] fix: select game server for picture migration --- scripts/migrate-general-picture.php | 60 +++++++++++++++++++---------- tests/GeneralPictureSchemaTest.php | 53 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/scripts/migrate-general-picture.php b/scripts/migrate-general-picture.php index 46ae1ce7..f563448b 100755 --- a/scripts/migrate-general-picture.php +++ b/scripts/migrate-general-picture.php @@ -10,11 +10,34 @@ if (PHP_SAPI !== 'cli') { exit(1); } -$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1'; -$_SERVER['REQUEST_URI'] ??= '/cli/migrate-general-picture'; +$options = getopt('', ['help', 'server:', 'status', 'apply', 'backup:']); +if (isset($options['help'])) { + pictureMigrationUsage(); +} +if (isset($options['status']) === isset($options['apply'])) { + pictureMigrationUsage(2); +} -require dirname(__DIR__) . '/hwe/lib.php'; -require dirname(__DIR__) . '/hwe/func.php'; +$server = $options['server'] ?? null; +if (!is_string($server) || preg_match('/^[a-z][a-z0-9_-]*$/', $server) !== 1) { + fwrite(STDERR, "--server must name one game-server directory, for example che, kwe, or hwe.\n"); + exit(2); +} + +$projectRoot = dirname(__DIR__); +$serverDirectory = $projectRoot . '/' . $server; +foreach (['lib.php', 'func.php', 'd_setting/DB.php'] as $requiredFile) { + if (!is_file($serverDirectory . '/' . $requiredFile)) { + fwrite(STDERR, "Server directory '$server' is not a configured game server: missing $requiredFile.\n"); + exit(2); + } +} + +$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1'; +$_SERVER['REQUEST_URI'] ??= "/cli/migrate-general-picture/$server"; + +require $serverDirectory . '/lib.php'; +require $serverDirectory . '/func.php'; /** @return never */ function pictureMigrationUsage(int $exitCode = 0): void @@ -22,8 +45,12 @@ function pictureMigrationUsage(int $exitCode = 0): void $stream = $exitCode === 0 ? STDOUT : STDERR; fwrite($stream, <<<'TEXT' Usage: - php scripts/migrate-general-picture.php --status - php scripts/migrate-general-picture.php --apply --backup=/absolute/path/to/pre-migration.sql + php scripts/migrate-general-picture.php --server=PREFIX --status + php scripts/migrate-general-picture.php --server=PREFIX --apply --backup=/absolute/path/to/pre-migration.sql + +PREFIX is one configured game directory such as che, kwe, or hwe. Run status, +backup, apply, and verification separately for every game database; this script +never loops over all servers implicitly. --status is read-only. --apply widens general.picture and the eight emperior chief picture columns to VARCHAR(64), adds nullable l12imgsvr through l5imgsvr, @@ -183,9 +210,10 @@ function backfillEmperiorImgsvr(\MeekroDB $db): int return $updated; } -function printPictureMigrationStatus(\MeekroDB $db): string +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); @@ -217,17 +245,9 @@ function requirePictureMigrationBackup(mixed $backup): string return $backup; } -$options = getopt('', ['help', 'status', 'apply', 'backup:']); -if (isset($options['help'])) { - pictureMigrationUsage(); -} -if (isset($options['status']) === isset($options['apply'])) { - pictureMigrationUsage(2); -} - $db = DB::db(); if (isset($options['status'])) { - exit(printPictureMigrationStatus($db) === 'unsupported' ? 2 : 0); + exit(printPictureMigrationStatus($db, $server) === 'unsupported' ? 2 : 0); } $state = pictureMigrationState($db); @@ -238,8 +258,8 @@ if ($state === 'unsupported') { $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); + fwrite(STDOUT, "Picture schema for $server is ready and no deterministic IMGSVR backfill candidates remain; nothing to do.\n"); + printPictureMigrationStatus($db, $server); exit(0); } @@ -275,8 +295,8 @@ try { \sammo\unlock(); } -if (printPictureMigrationStatus($db) !== 'ready') { +if (printPictureMigrationStatus($db, $server) !== 'ready') { fwrite(STDERR, "Picture-column migration verification failed; restore the supplied backup.\n"); exit(4); } -printf("Picture-column migration completed; backfilled_imgsvr=%d.\n", $backfilled); +printf("Picture-column migration for %s completed; backfilled_imgsvr=%d.\n", $server, $backfilled); diff --git a/tests/GeneralPictureSchemaTest.php b/tests/GeneralPictureSchemaTest.php index a63260c8..757486f9 100644 --- a/tests/GeneralPictureSchemaTest.php +++ b/tests/GeneralPictureSchemaTest.php @@ -6,6 +6,33 @@ use PHPUnit\Framework\TestCase; final class GeneralPictureSchemaTest extends TestCase { + /** + * @param list $arguments + * @return array{int, string, string} + */ + private function runMigrationCommand(array $arguments): array + { + $command = array_merge( + [PHP_BINARY, __DIR__ . '/../scripts/migrate-general-picture.php'], + $arguments, + ); + $pipes = []; + $process = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + self::assertIsResource($process); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + self::assertIsString($stdout); + self::assertIsString($stderr); + return [$exitCode, $stdout, $stderr]; + } + public function testGameAndAccountSchemasAcceptRemoteUserIconPaths(): void { $gameSchema = file_get_contents(__DIR__ . '/../hwe/sql/schema.sql'); @@ -45,6 +72,11 @@ final class GeneralPictureSchemaTest extends TestCase self::assertStringContainsString('HAVING COUNT(*) = 1', $migration); self::assertStringContainsString('Unmatched or ambiguous historical values remain NULL', $migration); self::assertStringContainsString("? 'picture_capacity'", $migration); + self::assertStringContainsString("['help', 'server:', 'status', 'apply', 'backup:']", $migration); + self::assertStringContainsString("require \$serverDirectory . '/lib.php'", $migration); + self::assertStringContainsString("require \$serverDirectory . '/func.php'", $migration); + self::assertStringNotContainsString("'/hwe/lib.php'", $migration); + self::assertStringNotContainsString("'/hwe/func.php'", $migration); self::assertStringNotContainsString('UPDATE general', $migration); self::assertStringContainsString("\$state === 'ready'", $migration); } @@ -67,6 +99,27 @@ final class GeneralPictureSchemaTest extends TestCase self::assertStringContainsString("JSON_VALUE(og.data, '$.imgsvr')", $candidateSql); } + public function testMigrationRequiresAnExplicitSafeServerPrefixBeforeLoadingConfiguration(): void + { + [$helpExit, $helpOutput, $helpError] = $this->runMigrationCommand(['--help']); + self::assertSame(0, $helpExit); + self::assertStringContainsString('--server=PREFIX', $helpOutput); + self::assertSame('', $helpError); + + [$missingExit, $missingOutput, $missingError] = $this->runMigrationCommand(['--status']); + self::assertSame(2, $missingExit); + self::assertSame('', $missingOutput); + self::assertStringContainsString('--server must name one game-server directory', $missingError); + + [$traversalExit, $traversalOutput, $traversalError] = $this->runMigrationCommand([ + '--server=../hwe', + '--status', + ]); + self::assertSame(2, $traversalExit); + self::assertSame('', $traversalOutput); + self::assertStringContainsString('--server must name one game-server directory', $traversalError); + } + public function testScriptsDirectoryIsDeniedOverApache(): void { $accessRules = file_get_contents(__DIR__ . '/../scripts/.htaccess'); From dc56ee1c063bb655208997e54aeb603f6b8dd8ca Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 16:35:53 +0000 Subject: [PATCH 11/18] fix: project troop times for game clock responses --- hwe/sammo/API/Nation/GeneralList.php | 12 ++++++++---- tests/GameClockBoundaryTest.php | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/hwe/sammo/API/Nation/GeneralList.php b/hwe/sammo/API/Nation/GeneralList.php index 26001519..31e3e3c8 100644 --- a/hwe/sammo/API/Nation/GeneralList.php +++ b/hwe/sammo/API/Nation/GeneralList.php @@ -151,6 +151,11 @@ class GeneralList extends \sammo\BaseAPI $gameStor = \sammo\KVStorage::getStorage($db, 'game_env'); $env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']); + $clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null; + $formatStoredTime = static fn (mixed $value): string => $clock === null + ? (string)$value + : $clock->formatTick(Util::toInt($value)); + $env['turntime'] = $formatStoredTime($env['turntime']); $me = $db->queryFirstRow( 'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general` @@ -187,7 +192,7 @@ class GeneralList extends \sammo\BaseAPI if (!key_exists($troopLeaderID, $rawGeneralList)) { continue; } - $troopTurnTime = $rawGeneralList[$troopLeaderID]['turntime']; + $troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID]['turntime']); $troops[$troopLeaderID] = new ArrayObject([ 'id' => $troopLeaderID, 'name' => $troopName, @@ -265,11 +270,10 @@ class GeneralList extends \sammo\BaseAPI 'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']), 'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']), //'0000-00-00 11:23'; - 'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor) - ->formatTick(Util::toInt($rawGeneral['turntime'])), + 'turntime' => fn ($rawGeneral) => substr($formatStoredTime($rawGeneral['turntime']), 0, 19), 'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null ? null - : GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])), + : substr($formatStoredTime($rawGeneral['recent_war']), 0, 19), 'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']), 'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null, 'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0, diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php index 9799f955..36e57144 100644 --- a/tests/GameClockBoundaryTest.php +++ b/tests/GameClockBoundaryTest.php @@ -183,4 +183,27 @@ final class GameClockBoundaryTest extends TestCase ); } + public function testNationGeneralListProjectsStoredTimesAtApiBoundary(): void + { + $source = file_get_contents(__DIR__ . '/../hwe/sammo/API/Nation/GeneralList.php'); + self::assertIsString($source); + + self::assertStringContainsString( + '$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;', + $source, + ); + self::assertStringContainsString( + '$env[\'turntime\'] = $formatStoredTime($env[\'turntime\']);', + $source, + ); + self::assertStringContainsString( + '$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID][\'turntime\']);', + $source, + ); + self::assertStringContainsString( + "? (string)\$value", + $source, + ); + } + } From d9ad14c027ea30e61f4f707d89db2bbee2945581 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 16:44:02 +0000 Subject: [PATCH 12/18] fix: support closed-server picture migration --- scripts/migrate-general-picture.php | 53 ++++++++++++++++++++++++++--- tests/GeneralPictureSchemaTest.php | 10 ++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/scripts/migrate-general-picture.php b/scripts/migrate-general-picture.php index f563448b..732b4b13 100755 --- a/scripts/migrate-general-picture.php +++ b/scripts/migrate-general-picture.php @@ -10,7 +10,7 @@ if (PHP_SAPI !== 'cli') { exit(1); } -$options = getopt('', ['help', 'server:', 'status', 'apply', 'backup:']); +$options = getopt('', ['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']); if (isset($options['help'])) { pictureMigrationUsage(); } @@ -47,6 +47,7 @@ function pictureMigrationUsage(int $exitCode = 0): void Usage: php scripts/migrate-general-picture.php --server=PREFIX --status php scripts/migrate-general-picture.php --server=PREFIX --apply --backup=/absolute/path/to/pre-migration.sql + php scripts/migrate-general-picture.php --server=PREFIX --apply --server-closed --backup=/absolute/path/to/pre-migration.sql PREFIX is one configured game directory such as che, kwe, or hwe. Run status, backup, apply, and verification separately for every game database; this script @@ -57,7 +58,10 @@ 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. +is not transactional. Normally the script acquires and releases the GAME lock. +Use --server-closed only after independently stopping web and daemon traffic; +that flag skips the GAME lock without changing its existing state. Unmatched or +ambiguous historical values remain NULL. TEXT); exit($exitCode); @@ -245,6 +249,22 @@ function requirePictureMigrationBackup(mixed $backup): string 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); @@ -264,12 +284,32 @@ if ($state === 'ready' && $recoverableBefore === 0) { } requirePictureMigrationBackup($options['backup'] ?? null); -if (!\sammo\tryLock()) { - fwrite(STDERR, "Unable to acquire the GAME lock.\n"); +$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'); @@ -292,7 +332,10 @@ try { $backfilled = backfillEmperiorImgsvr($db); } finally { - \sammo\unlock(); + if ($acquiredGameLock) { + \sammo\unlock(); + } + releasePictureMigrationLock($db, $server); } if (printPictureMigrationStatus($db, $server) !== 'ready') { diff --git a/tests/GeneralPictureSchemaTest.php b/tests/GeneralPictureSchemaTest.php index 757486f9..7686dd0f 100644 --- a/tests/GeneralPictureSchemaTest.php +++ b/tests/GeneralPictureSchemaTest.php @@ -70,13 +70,18 @@ final class GeneralPictureSchemaTest extends TestCase 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('ambiguous historical values remain NULL', $migration); self::assertStringContainsString("? 'picture_capacity'", $migration); - self::assertStringContainsString("['help', 'server:', 'status', 'apply', 'backup:']", $migration); + self::assertStringContainsString("['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']", $migration); self::assertStringContainsString("require \$serverDirectory . '/lib.php'", $migration); self::assertStringContainsString("require \$serverDirectory . '/func.php'", $migration); self::assertStringNotContainsString("'/hwe/lib.php'", $migration); self::assertStringNotContainsString("'/hwe/func.php'", $migration); + self::assertStringContainsString('SELECT GET_LOCK(%s, 0)', $migration); + self::assertStringContainsString('SELECT RELEASE_LOCK(%s)', $migration); + self::assertStringContainsString('if (!$serverClosed && !\\sammo\\tryLock())', $migration); + self::assertStringContainsString('if ($acquiredGameLock)', $migration); + self::assertStringContainsString('--server-closed skips the GAME lock', $migration); self::assertStringNotContainsString('UPDATE general', $migration); self::assertStringContainsString("\$state === 'ready'", $migration); } @@ -104,6 +109,7 @@ final class GeneralPictureSchemaTest extends TestCase [$helpExit, $helpOutput, $helpError] = $this->runMigrationCommand(['--help']); self::assertSame(0, $helpExit); self::assertStringContainsString('--server=PREFIX', $helpOutput); + self::assertStringContainsString('--server-closed', $helpOutput); self::assertSame('', $helpError); [$missingExit, $missingOutput, $missingError] = $this->runMigrationCommand(['--status']); From 504b4cda69bcdb3282638f93c6279011362bd082 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 8 Aug 2026 02:39:31 +0000 Subject: [PATCH 13/18] fix: require stopped server for picture migration --- scripts/migrate-general-picture.php | 33 ++++++++++------------------- tests/GeneralPictureSchemaTest.php | 7 +++--- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/scripts/migrate-general-picture.php b/scripts/migrate-general-picture.php index 732b4b13..8cd896b5 100755 --- a/scripts/migrate-general-picture.php +++ b/scripts/migrate-general-picture.php @@ -46,7 +46,6 @@ function pictureMigrationUsage(int $exitCode = 0): void fwrite($stream, <<<'TEXT' Usage: php scripts/migrate-general-picture.php --server=PREFIX --status - php scripts/migrate-general-picture.php --server=PREFIX --apply --backup=/absolute/path/to/pre-migration.sql php scripts/migrate-general-picture.php --server=PREFIX --apply --server-closed --backup=/absolute/path/to/pre-migration.sql PREFIX is one configured game directory such as che, kwe, or hwe. Run status, @@ -58,9 +57,10 @@ 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. Normally the script acquires and releases the GAME lock. -Use --server-closed only after independently stopping web and daemon traffic; -that flag skips the GAME lock without changing its existing state. Unmatched or +is not transactional. --apply requires --server-closed as an explicit operator +confirmation that web and daemon traffic has already been stopped. The script +does not acquire or alter the GAME lock; it only takes a separate MariaDB named +lock to prevent two picture migrations from running together. Unmatched or ambiguous historical values remain NULL. TEXT); @@ -285,31 +285,23 @@ if ($state === 'ready' && $recoverableBefore === 0) { requirePictureMigrationBackup($options['backup'] ?? null); $serverClosed = isset($options['server-closed']); -if ($serverClosed) { +if (!$serverClosed) { fwrite( STDERR, - "WARNING: --server-closed skips the GAME lock. Continue only if web and daemon traffic for $server is already stopped.\n", + "WARNING: Picture migration must run while $server web and daemon traffic is stopped. After stopping them, rerun with --server-closed.\n", ); + exit(3); } +fwrite( + STDERR, + "WARNING: --server-closed is an operator confirmation; this script cannot verify that $server web and daemon traffic is 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'); @@ -332,9 +324,6 @@ try { $backfilled = backfillEmperiorImgsvr($db); } finally { - if ($acquiredGameLock) { - \sammo\unlock(); - } releasePictureMigrationLock($db, $server); } diff --git a/tests/GeneralPictureSchemaTest.php b/tests/GeneralPictureSchemaTest.php index 7686dd0f..5377a4a6 100644 --- a/tests/GeneralPictureSchemaTest.php +++ b/tests/GeneralPictureSchemaTest.php @@ -79,9 +79,10 @@ final class GeneralPictureSchemaTest extends TestCase self::assertStringNotContainsString("'/hwe/func.php'", $migration); self::assertStringContainsString('SELECT GET_LOCK(%s, 0)', $migration); self::assertStringContainsString('SELECT RELEASE_LOCK(%s)', $migration); - self::assertStringContainsString('if (!$serverClosed && !\\sammo\\tryLock())', $migration); - self::assertStringContainsString('if ($acquiredGameLock)', $migration); - self::assertStringContainsString('--server-closed skips the GAME lock', $migration); + self::assertStringContainsString('if (!$serverClosed)', $migration); + self::assertStringContainsString('--server-closed is an operator confirmation', $migration); + self::assertStringNotContainsString('\\sammo\\tryLock()', $migration); + self::assertStringNotContainsString('\\sammo\\unlock()', $migration); self::assertStringNotContainsString('UPDATE general', $migration); self::assertStringContainsString("\$state === 'ready'", $migration); } From bf3725e0406fc2ab50231476b3a886b21395008f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 8 Aug 2026 04:41:16 +0000 Subject: [PATCH 14/18] fix(auth): resume Kakao OTP during auto login --- hwe/test-ts/loginFlow.test.ts | 44 +++++ hwe/ts/defs/API/Login.ts | 3 +- hwe/ts/gateway/login.ts | 53 +++--- hwe/ts/gateway/loginFlow.ts | 18 ++ src/sammo/API/Login/LoginByToken.php | 45 ++++- tests/LoginByTokenKakaoFailureTest.php | 36 ++++ tests/browser/auto-login-otp-dialog.mjs | 236 ++++++++++++++++++++++++ 7 files changed, 404 insertions(+), 31 deletions(-) create mode 100644 hwe/test-ts/loginFlow.test.ts create mode 100644 hwe/ts/gateway/loginFlow.ts create mode 100644 tests/LoginByTokenKakaoFailureTest.php create mode 100644 tests/browser/auto-login-otp-dialog.mjs diff --git a/hwe/test-ts/loginFlow.test.ts b/hwe/test-ts/loginFlow.test.ts new file mode 100644 index 00000000..0e7ff076 --- /dev/null +++ b/hwe/test-ts/loginFlow.test.ts @@ -0,0 +1,44 @@ +import { assert } from 'chai'; +import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '../ts/gateway/loginFlow'; + +describe('automatic login failure flow', () => { + it('prompts for OTP without treating the challenge as a terminal login failure', () => { + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reqOTP: true, + reason: '인증 코드를 입력해주세요', + }, 0), + 'prompt_otp' + ); + assert.equal(OTP_REQUIRED_MESSAGE, '인증 코드 입력이 필요합니다.'); + }); + + it('keeps the existing retry and alert behavior for non-OTP failures', () => { + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reason: '자동 로그인: 절차 오류', + }, 0), + 'retry' + ); + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reason: '로그인할 수 없습니다.', + }, 1), + 'alert' + ); + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: true, + reason: 'failed', + }, 1), + 'silent' + ); + }); +}); diff --git a/hwe/ts/defs/API/Login.ts b/hwe/ts/defs/API/Login.ts index 5bf0ca26..18fc5503 100644 --- a/hwe/ts/defs/API/Login.ts +++ b/hwe/ts/defs/API/Login.ts @@ -36,5 +36,6 @@ export type AutoLoginResponse = { export type AutoLoginFailed = { result: false, silent: boolean, + reqOTP?: boolean, reason: string, -} \ No newline at end of file +} diff --git a/hwe/ts/gateway/login.ts b/hwe/ts/gateway/login.ts index 984fadad..adde525c 100644 --- a/hwe/ts/gateway/login.ts +++ b/hwe/ts/gateway/login.ts @@ -12,8 +12,9 @@ import { delay } from '@util/delay'; import { Modal } from 'bootstrap'; import '@/gateway/common'; import { isString } from 'lodash-es'; -import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; -import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; +import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; +import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; +import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '@/gateway/loginFlow'; declare global { interface Window { getOAuthToken: (mode: string, scope_list: string[]) => void; @@ -51,9 +52,20 @@ function getToken(): [number, string] | undefined { return token; } -function resetToken() { - localStorage.removeItem(LOGIN_TOKEN_KEY); -} +function resetToken() { + localStorage.removeItem(LOGIN_TOKEN_KEY); +} + +function showOTPModal() { + const modalEl = unwrap(document.querySelector('#modalOTP')); + if (!modalOTP) { + modalOTP = new Modal(modalEl); + modalEl.addEventListener('shown.bs.modal', function () { + unwrap(document.querySelector('#otp_code')).focus(); + }); + } + modalOTP.show(); +} async function tryAutoLogin() { try { @@ -93,13 +105,18 @@ async function tryAutoLogin() { }, true); if (!loginResult.result) { - if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) { + const failureAction = classifyAutoLoginFailure(loginResult, attempt); + if (failureAction === 'retry') { console.warn('auto login failed by procedure error. retrying once.'); await delay(150); continue; } - - if (!loginResult.silent) { + if (failureAction === 'prompt_otp') { + alert(OTP_REQUIRED_MESSAGE); + showOTPModal(); + return; + } + if (failureAction === 'alert') { alert(loginResult.reason); } console.error(loginResult.reason); @@ -208,14 +225,7 @@ async function doLoginUsingOAuth() { return; } - const modalEl = unwrap(document.querySelector('#modalOTP')) - if (!modalOTP) { - modalOTP = new Modal(modalEl); - modalEl.addEventListener('shown.bs.modal', function () { - unwrap(document.querySelector('#otp_code')).focus(); - }); - } - modalOTP.show(); + showOTPModal(); } function postOAuthResult(mode: string) { @@ -314,14 +324,7 @@ $(async function ($) { return; } - const modalEl = unwrap(document.querySelector('#modalOTP')) - if (!modalOTP) { - modalOTP = new Modal(modalEl); - modalEl.addEventListener('shown.bs.modal', function () { - unwrap(document.querySelector('#otp_code')).focus(); - }); - } - modalOTP.show(); + showOTPModal(); }); $('#otp_form').on('submit', async function (e) { @@ -393,4 +396,4 @@ window.fitIframe = function () { const iframe = unwrap(document.querySelector('#running_map'));//TODO: 근황 여러개 볼 수 있도록? const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight; iframe.style.height = `${scrollHeight}px`; -} \ No newline at end of file +} diff --git a/hwe/ts/gateway/loginFlow.ts b/hwe/ts/gateway/loginFlow.ts new file mode 100644 index 00000000..25a8d5a2 --- /dev/null +++ b/hwe/ts/gateway/loginFlow.ts @@ -0,0 +1,18 @@ +import type { AutoLoginFailed } from '@/defs/API/Login'; + +export const OTP_REQUIRED_MESSAGE = '인증 코드 입력이 필요합니다.'; + +export type AutoLoginFailureAction = 'retry' | 'prompt_otp' | 'alert' | 'silent'; + +export function classifyAutoLoginFailure(result: AutoLoginFailed, attempt: number): AutoLoginFailureAction { + if (result.reason === '자동 로그인: 절차 오류' && attempt === 0) { + return 'retry'; + } + if (result.reqOTP) { + return 'prompt_otp'; + } + if (result.silent) { + return 'silent'; + } + return 'alert'; +} diff --git a/src/sammo/API/Login/LoginByToken.php b/src/sammo/API/Login/LoginByToken.php index d8aa9d44..61092833 100644 --- a/src/sammo/API/Login/LoginByToken.php +++ b/src/sammo/API/Login/LoginByToken.php @@ -19,6 +19,30 @@ class LoginByToken extends LoginByID { static array $sensitiveArgs = ['hashedToken']; + public static function kakaoFailureResponse(bool $reqOTP, string $reason): string | array + { + if (!$reqOTP) { + return $reason; + } + + return [ + 'result' => false, + 'silent' => false, + 'reqOTP' => true, + 'reason' => $reason, + ]; + } + + public static function shouldDiscardTokenAfterKakaoFailure(bool $reqOTP): bool + { + return !$reqOTP; + } + + public static function pendingSessionTokenID(bool $reqOTP, int $tokenID): ?int + { + return $reqOTP ? $tokenID : null; + } + public function getRequiredSessionMode(): int { return \sammo\BaseAPI::NO_LOGIN; @@ -117,13 +141,24 @@ class LoginByToken extends LoginByID if ($userInfo['oauth_type'] == 'KAKAO') { $oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo); if ($oauthFailResult !== null) { - $session->login($userInfo['no'], $userInfo['name'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}')); [$oauthReqOTP, $oauthFailReason] = $oauthFailResult; - $RootDB->delete( - 'login_token', - 'id = %i', $token_id + $session->login( + $userInfo['no'], + $userInfo['name'], + $userInfo['grade'], + true, + $userInfo['token_valid_until'], + static::pendingSessionTokenID($oauthReqOTP, $token_id), + Json::decode($userInfo['acl'] ?? '{}') ); - return $oauthFailReason; + if (static::shouldDiscardTokenAfterKakaoFailure($oauthReqOTP)) { + $RootDB->delete( + 'login_token', + 'id = %i', + $token_id + ); + } + return static::kakaoFailureResponse($oauthReqOTP, $oauthFailReason); } } diff --git a/tests/LoginByTokenKakaoFailureTest.php b/tests/LoginByTokenKakaoFailureTest.php new file mode 100644 index 00000000..793ddea0 --- /dev/null +++ b/tests/LoginByTokenKakaoFailureTest.php @@ -0,0 +1,36 @@ + false, + 'silent' => false, + 'reqOTP' => true, + 'reason' => '인증 코드를 입력해주세요', + ], + LoginByToken::kakaoFailureResponse(true, '인증 코드를 입력해주세요') + ); + } + + public function testNonOtpKakaoFailureKeepsLegacyErrorAndDiscardsToken(): void + { + self::assertTrue(LoginByToken::shouldDiscardTokenAfterKakaoFailure(false)); + self::assertNull(LoginByToken::pendingSessionTokenID(false, 321)); + self::assertSame( + '카카오 API 앱이 등록되지 않았습니다.', + LoginByToken::kakaoFailureResponse(false, '카카오 API 앱이 등록되지 않았습니다.') + ); + } +} diff --git a/tests/browser/auto-login-otp-dialog.mjs b/tests/browser/auto-login-otp-dialog.mjs new file mode 100644 index 00000000..8dd0b91a --- /dev/null +++ b/tests/browser/auto-login-otp-dialog.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const artifactDir = process.env.REF_AUTO_LOGIN_OTP_ARTIFACT_DIR; +if (!artifactDir) { + throw new Error('REF_AUTO_LOGIN_OTP_ARTIFACT_DIR is required'); +} + +const playwrightRequire = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); +let chromium; +try { + ({chromium} = playwrightRequire('playwright')); +} catch { + ({chromium} = playwrightRequire('@playwright/test')); +} + +const bundleDir = path.join(projectRoot, 'dist_js/gateway'); +const loginToken = [1, [321, 'browser-test-token'], Date.now()]; +let autoLoginCount = 0; +let manualLoginBody = null; +let otpBody = ''; +let otpAccepted = false; + +const loginHTML = ` + + + + + 자동로그인 OTP 검증 + + + + + + + +
+
+ + + + + + +
+ + 비밀번호 초기화 +
+ + +`; + +const json = (response, body) => { + response.writeHead(200, {'content-type': 'application/json; charset=utf-8'}); + response.end(JSON.stringify(body)); +}; + +const readBody = async request => { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const server = http.createServer(async (request, response) => { + const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (requestURL.pathname === '/') { + response.writeHead(200, {'content-type': 'text/html; charset=utf-8'}); + response.end(otpAccepted ? '

로그인 완료

' : loginHTML); + return; + } + if (requestURL.pathname.startsWith('/dist_js/gateway/')) { + const fileName = path.basename(requestURL.pathname); + const filePath = path.join(bundleDir, fileName); + const contentType = fileName.endsWith('.css') ? 'text/css' : 'text/javascript'; + response.writeHead(200, {'content-type': `${contentType}; charset=utf-8`}); + response.end(fs.readFileSync(filePath)); + return; + } + if (requestURL.pathname === '/api.php') { + const apiPath = requestURL.searchParams.get('path'); + const rawBody = await readBody(request); + if (apiPath === 'Login/ReqNonce') { + json(response, {result: true, loginNonce: `nonce-${autoLoginCount + 1}`}); + return; + } + if (apiPath === 'Login/LoginByToken') { + autoLoginCount += 1; + json(response, { + result: false, + silent: false, + reqOTP: true, + reason: '인증 코드를 입력해주세요', + }); + return; + } + if (apiPath === 'Login/LoginByID') { + manualLoginBody = JSON.parse(rawBody); + json(response, {result: false, reqOTP: false, reason: '수동 로그인 요청 확인'}); + return; + } + } + if (requestURL.pathname === '/oauth_kakao/j_check_OTP.php') { + otpBody = await readBody(request); + otpAccepted = true; + json(response, {result: true, reset: false, validUntil: '2026-08-18 00:00:00'}); + return; + } + response.writeHead(404); + response.end('not found'); +}); + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +assert(address && typeof address !== 'string'); +const baseURL = `http://127.0.0.1:${address.port}/`; + +const browser = await chromium.launch({headless: true}); +try { + const context = await browser.newContext({ + viewport: {width: 1280, height: 960}, + deviceScaleFactor: 1, + locale: 'ko-KR', + }); + await context.addInitScript(token => { + if (!localStorage.getItem('sammo_login_token')) { + localStorage.setItem('sammo_login_token', JSON.stringify(token)); + } + }, loginToken); + const page = await context.newPage(); + + const openAndAcceptOtpPrompt = async navigation => { + const dialogPromise = page.waitForEvent('dialog'); + const navigationPromise = navigation(); + const dialog = await dialogPromise; + assert.equal(dialog.message(), '인증 코드 입력이 필요합니다.'); + await dialog.accept(); + await navigationPromise; + await page.locator('#modalOTP.show').waitFor({state: 'visible'}); + await page.waitForFunction(() => document.activeElement?.id === 'otp_code'); + }; + + await openAndAcceptOtpPrompt(() => page.goto(baseURL, {waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 1); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); + + fs.mkdirSync(artifactDir, {recursive: true}); + const screenshotPath = path.join(artifactDir, 'auto-login-otp-modal.png'); + await page.screenshot({path: screenshotPath, fullPage: true}); + const modalGeometry = await page.locator('#modalOTP .modal-dialog').evaluate(element => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, + display: style.display, + opacity: style.opacity, + }; + }); + + await page.getByRole('button', {name: '취소'}).click(); + await page.locator('#modalOTP').waitFor({state: 'hidden'}); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); + + await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 2); + await page.getByRole('button', {name: '취소'}).click(); + await page.locator('#modalOTP').waitFor({state: 'hidden'}); + await page.waitForTimeout(250); + + await page.locator('#username').fill('manual-user'); + await page.locator('#password').fill('manual-password'); + await page.waitForTimeout(100); + const manualDialogPromise = page.waitForEvent('dialog'); + await page.locator('#main_form').evaluate(form => form.requestSubmit()); + const manualDialog = await manualDialogPromise; + assert.equal(manualDialog.message(), '수동 로그인 요청 확인'); + await manualDialog.accept(); + assert.equal(manualLoginBody?.username, 'manual-user'); + assert.match(manualLoginBody?.password ?? '', /^[0-9a-f]{128}$/); + + await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 3); + await page.locator('#otp_code').fill('1234'); + const successDialogPromise = page.waitForEvent('dialog'); + await page.getByRole('button', {name: '제출'}).click(); + const successDialog = await successDialogPromise; + assert.equal(successDialog.message(), '로그인되었습니다. 2026-08-18 00:00:00까지 유효합니다.'); + await successDialog.accept(); + await page.locator('#logged-in').waitFor({state: 'visible'}); + assert.match(otpBody, /name="otp"/); + assert.match(otpBody, /\r\n1234\r\n/); + + const resultPath = path.join(artifactDir, 'auto-login-otp-result.json'); + fs.writeFileSync(resultPath, `${JSON.stringify({ + url: baseURL, + viewport: {width: 1280, height: 960, deviceScaleFactor: 1}, + autoLoginCount, + preservedTokenAfterCancel: true, + reopenedAfterReload: true, + manualLoginSubmitted: true, + otpSuccessNavigated: true, + modalGeometry, + screenshotPath, + }, null, 2)}\n`, {mode: 0o600}); + + console.log(`automatic-login OTP Chromium flow verified: ${resultPath}`); +} finally { + await browser.close(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +} From e3fa195d0cf8c3694eef88826ac7f30135973125 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 08:13:03 +0000 Subject: [PATCH 15/18] fix: exclude synchronized dex from inheritance --- hwe/sammo/InheritancePointManager.php | 36 +++++++++++++++++---------- tests/CentennialAllStarGrowthTest.php | 23 +++++++++++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/hwe/sammo/InheritancePointManager.php b/hwe/sammo/InheritancePointManager.php index d185d607..b0e9e5a4 100644 --- a/hwe/sammo/InheritancePointManager.php +++ b/hwe/sammo/InheritancePointManager.php @@ -152,19 +152,7 @@ class InheritancePointManager }; switch ($key) { case InheritanceKey::dex: - $extractFn = function () use ($general, $multiplier) { - $dexLimit = Util::array_last(getDexLevelList())[0]; - $totalDex = 0; - foreach (array_keys(GameUnitConst::allType()) as $armType) { - $subDex = $general->getVar("dex{$armType}"); - if ($subDex > $dexLimit) { - $totalDex += ($subDex - $dexLimit) / 3; - $subDex = $dexLimit; - } - $totalDex += $subDex; - } - return [$totalDex * $multiplier, null]; - }; + $extractFn = fn () => [$this->getDexInheritancePoint($general), null]; break; case InheritanceKey::betting: $extractFn = function () use ($general, $multiplier) { @@ -189,6 +177,28 @@ class InheritancePointManager return $value; } + /** @param null|int[] $armTypes */ + public function getDexInheritancePoint( + General $general, + ?int $dexLimit = null, + ?array $armTypes = null + ): float + { + $dexLimit ??= Util::array_last(getDexLevelList())[0]; + $armTypes ??= array_keys(GameUnitConst::allType()); + $multiplier = $this->getInheritancePointType(InheritanceKey::dex)->pointCoeff; + $totalDex = 0; + foreach ($armTypes as $armType) { + $subDex = CentennialAllStarGrowthService::recordableValue($general, "dex{$armType}"); + if ($subDex > $dexLimit) { + $totalDex += ($subDex - $dexLimit) / 3; + $subDex = $dexLimit; + } + $totalDex += $subDex; + } + return $totalDex * $multiplier; + } + public function setInheritancePoint(General $general, InheritanceKey $key, $value, $aux = null) { diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 579c0064..ccca836d 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -5,6 +5,7 @@ use sammo\CentennialAllStarGrowth; use sammo\CentennialAllStarGrowthService; use sammo\General; use sammo\GameConst; +use sammo\InheritancePointManager; $loader = require __DIR__ . '/../vendor/autoload.php'; $loader->addPsr4('sammo\\', __DIR__ . '/../hwe/sammo', true); @@ -371,6 +372,28 @@ final class CentennialAllStarGrowthTest extends TestCase self::assertSame(900000, CentennialAllStarGrowth::dexFloor(900000, 1)); } + public function testSynchronizedDexDoesNotIncreaseInheritancePoint(): void + { + $vars = $this->emptyGeneralVars(); + $vars['dex1'] = 1_300_000; + $vars['dex2'] = 1_300_000; + $aux = CentennialAllStarGrowthService::initialAux( + $this->singleDexTarget('inheritance', 0), + [] + ); + $aux['granted']['dex1'] = 1_000_000; + $general = $this->createStateGeneralMock($vars, $aux); + + self::assertSame( + 1_400.0, + InheritancePointManager::getInstance()->getDexInheritancePoint( + $general, + 1_000_000, + [1, 2, 3, 4, 5] + ) + ); + } + public function testNpcDexStopsAtFortyPercentOfHistoricalTarget(): void { $target = 900000; From c0e776c88d379a0667a1961db66f44e19e47f3a0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 12 Aug 2026 15:35:39 +0000 Subject: [PATCH 16/18] fix: keep invader game reset watchdog active --- hwe/func_gamerule.php | 1 + hwe/sammo/Event/Action/InvaderEnding.php | 4 ++- hwe/sammo/Event/Action/RaiseInvader.php | 2 ++ hwe/sammo/TurnExecutionHelper.php | 16 ++++++++++ src/sammo/AutoResetProgress.php | 40 ++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/sammo/AutoResetProgress.php diff --git a/hwe/func_gamerule.php b/hwe/func_gamerule.php index ee0790fd..88d43620 100644 --- a/hwe/func_gamerule.php +++ b/hwe/func_gamerule.php @@ -769,6 +769,7 @@ function checkEmperior() $gameStor->isunited = 2; $gameStor->refreshLimit = $gameStor->refreshLimit * 100; + AutoResetProgress::record($gameStor); foreach ($db->queryFirstColumn('SELECT no FROM general WHERE npc<2 AND age>=%i', GameConst::$minPushHallAge) as $hallGeneralNo) { CheckHall($hallGeneralNo); diff --git a/hwe/sammo/Event/Action/InvaderEnding.php b/hwe/sammo/Event/Action/InvaderEnding.php index aa458fbc..328f2190 100644 --- a/hwe/sammo/Event/Action/InvaderEnding.php +++ b/hwe/sammo/Event/Action/InvaderEnding.php @@ -2,6 +2,7 @@ namespace sammo\Event\Action; use sammo\ActionLogger; +use sammo\AutoResetProgress; use sammo\CityConst; use sammo\DB; use sammo\KVStorage; @@ -61,6 +62,7 @@ class InvaderEnding extends \sammo\Event\Action{ $logger->pushGlobalHistoryLog("【이벤트】백성은 언젠가 영웅이 나타나길 기다립니다."); } $gameStor->setValue('isunited', 3); + AutoResetProgress::record($gameStor); $logger->flush(); $gameStor->refreshLimit = $gameStor->refreshLimit * 100; @@ -71,4 +73,4 @@ class InvaderEnding extends \sammo\Event\Action{ return [__CLASS__, 'Deleted']; } -} \ No newline at end of file +} diff --git a/hwe/sammo/Event/Action/RaiseInvader.php b/hwe/sammo/Event/Action/RaiseInvader.php index dacef71f..31124777 100644 --- a/hwe/sammo/Event/Action/RaiseInvader.php +++ b/hwe/sammo/Event/Action/RaiseInvader.php @@ -4,6 +4,7 @@ namespace sammo\Event\Action; use Ds\Set; use sammo\ActionLogger; +use sammo\AutoResetProgress; use sammo\CityConst; use sammo\DB; use sammo\Json; @@ -119,6 +120,7 @@ class RaiseInvader extends \sammo\Event\Action $gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor->setValue('isunited', 1); + AutoResetProgress::record($gameStor); $turnterm = $gameStor->turnterm; $generalCnt = $db->queryFirstField('SELECT count(*) FROM general'); diff --git a/hwe/sammo/TurnExecutionHelper.php b/hwe/sammo/TurnExecutionHelper.php index 3dc67ce4..a5d20558 100644 --- a/hwe/sammo/TurnExecutionHelper.php +++ b/hwe/sammo/TurnExecutionHelper.php @@ -20,6 +20,18 @@ class TurnExecutionHelper { return max($completedTick, $candidateTick); } + + private static function recordInvaderProgress(KVStorage $gameStorage, int $beforeTick): void + { + if (Util::toInt($gameStorage->isunited) !== 1) { + return; + } + AutoResetProgress::recordIfAdvanced( + $gameStorage, + $beforeTick, + Util::toInt($gameStorage->turntime), + ); + } public function __destruct() { @@ -416,6 +428,8 @@ class TurnExecutionHelper $locked = true; return $gameStor->turntime; } + + $completionTickBeforeRun = Util::toInt($gameStor->turntime); $gameStor->cacheAll(); // 1턴이상 갱신 없었으면 서버 지연 @@ -462,6 +476,7 @@ class TurnExecutionHelper $currentTurn, ); } + self::recordInvaderProgress($gameStor, $completionTickBeforeRun); unlock(); return $gameStor->turntime; } @@ -520,6 +535,7 @@ class TurnExecutionHelper processTournament(); //거래 처리 processAuction(); + self::recordInvaderProgress($gameStor, $completionTickBeforeRun); // 잠금 해제 $turntime = $gameStor->turntime; diff --git a/src/sammo/AutoResetProgress.php b/src/sammo/AutoResetProgress.php new file mode 100644 index 00000000..8290bae9 --- /dev/null +++ b/src/sammo/AutoResetProgress.php @@ -0,0 +1,40 @@ +setValue(self::STORAGE_KEY, TimeUtil::format($wallTime, true)); + } + + public static function recordIfAdvanced( + KVStorage $gameStorage, + int $beforeTick, + int $afterTick, + ?\DateTimeInterface $wallTime = null, + ): bool { + if ($afterTick <= $beforeTick) { + return false; + } + self::record($gameStorage, $wallTime); + return true; + } +} From 1721e5f6d416f8c0ce31ccff3152750b8eb47338 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 12 Aug 2026 15:35:52 +0000 Subject: [PATCH 17/18] test(compare): reproduce invader game resume --- .../fixtures/unification_invader_resume.json | 85 ++++++++ hwe/compare/monthly_event_trace.php | 206 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 hwe/compare/fixtures/unification_invader_resume.json diff --git a/hwe/compare/fixtures/unification_invader_resume.json b/hwe/compare/fixtures/unification_invader_resume.json new file mode 100644 index 00000000..b2e5b2a2 --- /dev/null +++ b/hwe/compare/fixtures/unification_invader_resume.json @@ -0,0 +1,85 @@ +{ + "action": "UnificationInvaderResume", + "winnerGeneralId": 1, + "setup": { + "resetCities": true, + "setAllCitiesNation": 1, + "resetGenerals": true, + "deleteOtherGenerals": true, + "keepGeneralIds": [1, 2, 3, 4, 5, 6, 7, 8], + "resetNations": true, + "deleteOtherNations": true, + "keepNationIds": [1], + "resetDiplomacy": true, + "resetEvents": true, + "clearInheritanceOwnerIds": [1], + "inheritancePrevious": { + "1": 0 + }, + "nation": [ + { + "id": 1, + "values": { + "name": "재현국", + "color": "#336699", + "capital": 1, + "gennum": 8, + "gold": 100000, + "rice": 100000, + "tech": 1000, + "power": 1000, + "level": 2, + "type": "che_유가", + "aux": {} + } + } + ], + "general": [ + { + "id": 1, + "values": { + "name": "재현군주", + "nation": 1, + "city": 1, + "officer_level": 12, + "owner": 1, + "npc": 0, + "age": 30, + "bornyear": 170, + "deadyear": 260, + "belong": 240, + "aux": {} + } + }, + { "id": 2, "values": { "name": "재현관직11", "nation": 1, "city": 1, "officer_level": 11, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 3, "values": { "name": "재현관직10", "nation": 1, "city": 1, "officer_level": 10, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 4, "values": { "name": "재현관직9", "nation": 1, "city": 1, "officer_level": 9, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 5, "values": { "name": "재현관직8", "nation": 1, "city": 1, "officer_level": 8, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 6, "values": { "name": "재현관직7", "nation": 1, "city": 1, "officer_level": 7, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 7, "values": { "name": "재현관직6", "nation": 1, "city": 1, "officer_level": 6, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 8, "values": { "name": "재현관직5", "nation": 1, "city": 1, "officer_level": 5, "owner": 0, "npc": 2, "aux": {} } } + ], + "nationEnvironment": [ + { "nationId": 1, "key": "nationNotice", "value": { "msg": "재현" } }, + { "nationId": 1, "key": "scout_msg", "value": "재현" }, + { "nationId": 1, "key": "max_power", "value": {} } + ], + "gameEnvironment": { + "isunited": 0, + "refreshLimit": 30, + "block_change_scout": true + }, + "syncEnvironment": true + }, + "environment": { + "year": 200, + "month": 1, + "startyear": 180, + "turnterm": 10, + "turntime": "0200-01-01 00:00:00", + "show_img_level": 3, + "stored_icons": [], + "icon_path": ".", + "fiction": [0] + } +} diff --git a/hwe/compare/monthly_event_trace.php b/hwe/compare/monthly_event_trace.php index f160f5a7..2c100cc3 100644 --- a/hwe/compare/monthly_event_trace.php +++ b/hwe/compare/monthly_event_trace.php @@ -752,6 +752,204 @@ function comparisonNationBettingLifecycleTrace( ]; } +/** + * Reproduce the player-visible handoff from a completed unification to the + * invader event and prove that the real turn engine can cross the next monthly + * boundary afterwards. The surrounding runner executes this only against its + * disposable MariaDB clone. + * + * @param array $request + * @param array $environment + * @return array + */ +function comparisonUnificationInvaderResumeTrace(array $request, array $environment): array +{ + $db = DB::db(); + $gameStorage = KVStorage::getStorage($db, 'game_env'); + $winnerGeneralId = $request['winnerGeneralId'] ?? null; + if (!is_int($winnerGeneralId) || $winnerGeneralId <= 0) { + throw new \InvalidArgumentException('UnificationInvaderResume requires a positive winnerGeneralId'); + } + + $winnerNationId = (int)($db->queryFirstField( + 'SELECT nation FROM general WHERE no = %i', + $winnerGeneralId, + ) ?? 0); + if ($winnerNationId <= 0) { + throw new \RuntimeException('winner general must belong to a nation'); + } + + $allCityCount = count(CityConst::all()); + $winnerCityCount = (int)$db->queryFirstField( + 'SELECT COUNT(*) FROM city WHERE nation = %i', + $winnerNationId, + ); + if ($winnerCityCount !== $allCityCount) { + throw new \RuntimeException( + "winner must occupy every scenario city: winner={$winnerCityCount}, scenario={$allCityCount}", + ); + } + + $year = $environment['year'] ?? null; + $month = $environment['month'] ?? null; + $startYear = $environment['startyear'] ?? null; + if (!is_int($year) || !is_int($month) || !is_int($startYear) || $year - $startYear < 20) { + throw new \InvalidArgumentException('fixture must start at least 20 game years after startyear'); + } + + $unificationTick = Util::toInt($gameStorage->turntime); + $elapsedMonths = ($year - $startYear) * 12 + $month - 1; + $gameStorage->starttime = GameClock::addTicks( + cutTurn($unificationTick, Util::toInt($gameStorage->turnterm)), + -$elapsedMonths * GameClock::TICKS_PER_TURN, + ); + $clock = GameClock::fromStorage($gameStorage); + $clock->persistTick($gameStorage, $unificationTick, GameClock::MODE_MANUAL); + $db->update('general', ['turntime' => $unificationTick], true); + $db->update('plock', ['plock' => 0], true); + $gameStorage->resetCache(); + refreshNationStaticInfo(); + + $before = [ + 'year' => Util::toInt($gameStorage->year), + 'month' => Util::toInt($gameStorage->month), + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'nationCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0'), + 'winnerCityCount' => $winnerCityCount, + ]; + + checkEmperior(); + $gameStorage->resetCache(); + + $invaderMessages = []; + foreach ($db->query( + 'SELECT id, mailbox, message FROM message WHERE mailbox = %i ORDER BY id', + $winnerGeneralId, + ) as $row) { + $message = Json::decode($row['message']); + if (($message['option']['action'] ?? null) !== 'raiseInvader') { + continue; + } + $invaderMessages[] = [ + 'id' => (int)$row['id'], + 'mailbox' => (int)$row['mailbox'], + 'text' => (string)($message['text'] ?? ''), + 'args' => $message['option']['args'] ?? null, + 'used' => (bool)($message['option']['used'] ?? false), + ]; + } + if (count($invaderMessages) !== 3) { + throw new \RuntimeException('unification must create exactly three invader choices for the ruler'); + } + + $buttonSnapshot = [ + 'year' => Util::toInt($gameStorage->year), + 'month' => Util::toInt($gameStorage->month), + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockMode' => GameClock::fromStorage($gameStorage)->getMode(), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"), + 'messages' => $invaderMessages, + ]; + + // Simulate an autoreset poll that recorded the original unification pause. + // A successful event start must replace this stale operational wall anchor. + $pausedAnchor = '2000-01-01 00:00:00.000000'; + $gameStorage->autoreset_united_wall_anchor = $pausedAnchor; + + $selectedMessageId = $invaderMessages[array_key_last($invaderMessages)]['id']; + $message = Message::getMessageByID($selectedMessageId); + if (!$message instanceof RaiseInvaderMessage) { + throw new \RuntimeException('selected invader message did not hydrate as RaiseInvaderMessage'); + } + $reason = ''; + $decisionResult = $message->agreeMessage($winnerGeneralId, $reason); + $gameStorage->resetCache(); + + $afterSummon = [ + 'result' => $decisionResult, + 'reason' => $reason, + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockMode' => GameClock::fromStorage($gameStorage)->getMode(), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'turnterm' => Util::toInt($gameStorage->turnterm), + 'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"), + 'nationCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0'), + 'invaderNationCount' => (int)$db->queryFirstField("SELECT COUNT(*) FROM nation WHERE name LIKE 'ⓞ%족'"), + 'eventCount' => (int)$db->queryFirstField( + "SELECT COUNT(*) FROM event WHERE action LIKE '%Invader%'", + ), + 'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor, + 'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $pausedAnchor, + ]; + + $resumeBoundary = addTurn( + cutTurn(Util::toInt($gameStorage->turntime), Util::toInt($gameStorage->turnterm)), + Util::toInt($gameStorage->turnterm), + ); + GameClock::fromStorage($gameStorage)->persistTick( + $gameStorage, + GameClock::addTicks($resumeBoundary, 1), + GameClock::MODE_MANUAL, + ); + $gameStorage->resetCache(); + + // Likewise, active event progress must move the inactivity anchor. Leaving + // this stale makes j_autoreset close a healthy invader game on the original + // unification timeline. + $stalledAnchor = '2000-01-02 00:00:00.000000'; + $gameStorage->autoreset_united_wall_anchor = $stalledAnchor; + + $engineCalls = 0; + $executedCalls = 0; + $lockedCalls = 0; + while ($engineCalls < 20 && Util::toInt($gameStorage->turntime) < $resumeBoundary) { + $executed = false; + $locked = false; + TurnExecutionHelper::executeAllCommand($executed, $locked); + $engineCalls++; + if ($executed) { + $executedCalls++; + } + if ($locked) { + $lockedCalls++; + } + $gameStorage->resetCache(); + } + + $afterResume = [ + 'year' => Util::toInt($gameStorage->year), + 'month' => Util::toInt($gameStorage->month), + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockMode' => GameClock::fromStorage($gameStorage)->getMode(), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'resumeBoundary' => $resumeBoundary, + 'engineCalls' => $engineCalls, + 'executedCalls' => $executedCalls, + 'lockedCalls' => $lockedCalls, + 'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"), + 'worldHistoryCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM world_history'), + 'generalRecordCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM general_record'), + 'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor, + 'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $stalledAnchor, + ]; + + return [ + 'schemaVersion' => 1, + 'engine' => 'ref', + 'action' => 'UnificationInvaderResume', + 'before' => $before, + 'buttonSnapshot' => $buttonSnapshot, + 'afterSummon' => $afterSummon, + 'afterResume' => $afterResume, + ]; +} + function comparisonMonthlyEventTraceMain(): void { try { @@ -775,6 +973,7 @@ function comparisonMonthlyEventTraceMain(): void 'NpcNationLifecycle', 'RaiseInvader', 'InvaderLifecycle', + 'UnificationInvaderResume', 'AutoDeleteInvader', 'InvaderEnding', 'ChangeCity', @@ -1141,6 +1340,13 @@ function comparisonMonthlyEventTraceMain(): void ), PHP_EOL; return; } + if ($actionName === 'UnificationInvaderResume') { + echo json_encode( + comparisonUnificationInvaderResumeTrace($request, $environment), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ); + return; + } if ($actionName === 'NpcNationLifecycle') { foreach (['turnterm', 'turntime'] as $requiredEnvironmentKey) { if (!array_key_exists($requiredEnvironmentKey, $environment)) { From 0d990c090a6b7d6cc7cea1a02abb6dd00551fdf4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 12 Aug 2026 15:40:07 +0000 Subject: [PATCH 18/18] test(compare): enforce invader resume watchdog --- hwe/compare/monthly_event_trace.php | 22 ++++++++++++ tests/AutoResetProgressTest.php | 53 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/AutoResetProgressTest.php diff --git a/hwe/compare/monthly_event_trace.php b/hwe/compare/monthly_event_trace.php index 2c100cc3..cb44f003 100644 --- a/hwe/compare/monthly_event_trace.php +++ b/hwe/compare/monthly_event_trace.php @@ -886,6 +886,15 @@ function comparisonUnificationInvaderResumeTrace(array $request, array $environm 'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor, 'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $pausedAnchor, ]; + if ( + $decisionResult !== RaiseInvaderMessage::ACCEPTED + || $afterSummon['isunited'] !== 1 + || $afterSummon['plock'] !== 0 + || $afterSummon['invaderNationCount'] !== 7 + || $afterSummon['autoresetAnchorRefreshed'] !== true + ) { + throw new \RuntimeException('invader choice did not start an unlocked, watchdog-active event game'); + } $resumeBoundary = addTurn( cutTurn(Util::toInt($gameStorage->turntime), Util::toInt($gameStorage->turnterm)), @@ -938,6 +947,19 @@ function comparisonUnificationInvaderResumeTrace(array $request, array $environm 'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor, 'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $stalledAnchor, ]; + $expectedYear = $month === 12 ? $year + 1 : $year; + $expectedMonth = $month === 12 ? 1 : $month + 1; + if ( + $afterResume['turntime'] < $resumeBoundary + || $afterResume['year'] !== $expectedYear + || $afterResume['month'] !== $expectedMonth + || $afterResume['isunited'] !== 1 + || $afterResume['lockedCalls'] !== 0 + || $afterResume['plock'] !== 0 + || $afterResume['autoresetAnchorRefreshed'] !== true + ) { + throw new \RuntimeException('invader event game did not advance through the next monthly boundary'); + } return [ 'schemaVersion' => 1, diff --git a/tests/AutoResetProgressTest.php b/tests/AutoResetProgressTest.php new file mode 100644 index 00000000..31d7b1bb --- /dev/null +++ b/tests/AutoResetProgressTest.php @@ -0,0 +1,53 @@ +createMock(KVStorage::class); + $storage->expects(self::once()) + ->method('setValue') + ->with( + AutoResetProgress::STORAGE_KEY, + '2026-08-12 15:40:12.345678', + ) + ->willReturnSelf(); + + AutoResetProgress::record( + $storage, + new \DateTimeImmutable('2026-08-12 15:40:12.345678 UTC'), + ); + } + + public function testRecordIfAdvancedIgnoresEqualOrRegressiveTicks(): void + { + $storage = $this->createMock(KVStorage::class); + $storage->expects(self::never())->method('setValue'); + + self::assertFalse(AutoResetProgress::recordIfAdvanced($storage, 100, 100)); + self::assertFalse(AutoResetProgress::recordIfAdvanced($storage, 100, 99)); + } + + public function testRecordIfAdvancedRefreshesTheWatchdogAnchor(): void + { + $storage = $this->createMock(KVStorage::class); + $storage->expects(self::once()) + ->method('setValue') + ->with( + AutoResetProgress::STORAGE_KEY, + '2026-08-12 15:41:00.000001', + ) + ->willReturnSelf(); + + self::assertTrue(AutoResetProgress::recordIfAdvanced( + $storage, + 100, + 101, + new \DateTimeImmutable('2026-08-12 15:41:00.000001 UTC'), + )); + } +}