From 162f198f08463847252a2b1471ab847fe2489395 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 01:11:47 +0000 Subject: [PATCH 1/2] fix: stabilize S100 cooldowns and generated NPC growth --- hwe/b_myPage.php | 30 ++++++-- hwe/j_select_picked_general.php | 4 + hwe/sammo/API/General/DieOnPrestart.php | 26 +++++-- hwe/sammo/API/General/Join.php | 7 ++ hwe/sammo/CentennialAllStarGrowthService.php | 31 ++++++++ tests/CentennialAllStarGrowthTest.php | 45 +++++++++++ .../s100-my-page-cooldown-refresh-guard.mjs | 76 +++++++++++++++++++ 7 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 tests/browser/s100-my-page-cooldown-refresh-guard.mjs diff --git a/hwe/b_myPage.php b/hwe/b_myPage.php index 54052f97..ea3d488d 100644 --- a/hwe/b_myPage.php +++ b/hwe/b_myPage.php @@ -22,8 +22,6 @@ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']); -increaseRefresh("내정보", 1); - $me = General::createObjFromDB($generalID, null, GeneralQueryMode::FullWithAccessLog); $myset = $me->getVar('myset'); @@ -41,10 +39,26 @@ $lastRefresh = $db->queryFirstField( $generalID ); -$targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart); -if ($gameStor->turntime <= $gameStor->opentime) { - //서버 가오픈시 할 수 있는 행동 - if ($me->getNPCType() == 0 && $me->getNationID() == 0) { +$nextChange = $me->getAuxVar('next_change'); +if (!is_string($nextChange) || $nextChange === '') { + $nextChange = null; +} + +increaseRefresh("내정보", 1); +if ($gameStor->turntime <= $gameStor->opentime) { + $targetTime = $me->getAuxVar('prestart_delete_after'); + if (!is_string($targetTime) || $targetTime === '') { + $targetTime = addTurn( + $lastRefresh ?: TimeUtil::now(), + $gameStor->turnterm, + GameConst::$minTurnDieOnPrestart + ); + $me->setAuxVar('prestart_delete_after', $targetTime); + $me->applyDB($db); + } + + //서버 가오픈시 할 수 있는 행동 + if ($me->getNPCType() == 0 && $me->getNationID() == 0) { $showDieOnPrestartBtn = true; if ($targetTime <= TimeUtil::now()) { $availableDieOnPrestart = true; @@ -174,7 +188,7 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $ npcmode == 2 && $me->getNPCType() == 0) : ?> - 다른 장수 선택 (getAuxVar('next_change') ?? TimeUtil::now(), 0, 19) ?> 부터)
+ 다른 장수 선택 (부터)


@@ -272,4 +286,4 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $ - \ No newline at end of file + diff --git a/hwe/j_select_picked_general.php b/hwe/j_select_picked_general.php index 81bea0cf..0b014069 100644 --- a/hwe/j_select_picked_general.php +++ b/hwe/j_select_picked_general.php @@ -177,6 +177,10 @@ $builder->setOwnerName($userNick); $builder->setKillturn(5); $builder->setNPCType(0); $builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm'])); +$builder->setAuxVar( + 'prestart_delete_after', + addTurn($now, $env['turnterm'], GameConst::$minTurnDieOnPrestart) +); $builder->fillRemainSpecAsZero($env); if ($isCentennialAllStar) { $candidateCities = $db->queryFirstColumn( diff --git a/hwe/sammo/API/General/DieOnPrestart.php b/hwe/sammo/API/General/DieOnPrestart.php index e2c8552b..c38a8673 100644 --- a/hwe/sammo/API/General/DieOnPrestart.php +++ b/hwe/sammo/API/General/DieOnPrestart.php @@ -39,6 +39,10 @@ class DieOnPrestart extends \sammo\BaseAPI $gameStor->cacheValues(['turnterm', 'opentime', 'turntime', 'year', 'month']); $general = $db->queryFirstRow('SELECT no,name,nation,owner_name,npc FROM general WHERE owner=%i AND npc = 0', $userID); + if (!$general) { + return '장수가 없습니다'; + } + $lastRefresh = $db->queryFirstField( 'SELECT %b FROM general_access_log WHERE %b = %i', GeneralAccessLogColumn::lastRefresh->value, @@ -46,8 +50,9 @@ class DieOnPrestart extends \sammo\BaseAPI $general['no'] ); - if (!$general) { - return '장수가 없습니다'; + $generalObj = General::createObjFromDB($general['no']); + if ($generalObj instanceof DummyGeneral) { + trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING); } increaseRefresh("장수 삭제", 1); @@ -61,18 +66,23 @@ class DieOnPrestart extends \sammo\BaseAPI return '이미 국가에 소속되어있습니다.'; } + $targetTime = $generalObj->getAuxVar('prestart_delete_after'); + if (!is_string($targetTime) || $targetTime === '') { + $targetTime = addTurn( + $lastRefresh ?: TimeUtil::now(), + $gameStor->turnterm, + GameConst::$minTurnDieOnPrestart + ); + $generalObj->setAuxVar('prestart_delete_after', $targetTime); + $generalObj->applyDB($db); + } + //서버 가오픈시 할 수 있는 행동 - $targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart); if ($targetTime > TimeUtil::now()) { $targetTimeShort = substr($targetTime, 0, 19); return "아직 삭제할 수 없습니다. {$targetTimeShort} 부터 가능합니다."; } - $generalObj = General::createObjFromDB($general['no']); - if ($generalObj instanceof DummyGeneral) { - trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING); - } - $generalName = $generalObj->getName(); $josaYi = JosaUtil::pick($generalName, '이'); $generalObj->kill($db, true, "{$generalName}{$josaYi} 홀연히 모습을 감추었습니다"); diff --git a/hwe/sammo/API/General/Join.php b/hwe/sammo/API/General/Join.php index bc20229c..43b5a2cc 100644 --- a/hwe/sammo/API/General/Join.php +++ b/hwe/sammo/API/General/Join.php @@ -435,6 +435,13 @@ class Join extends \sammo\BaseAPI 'specage2' => $specage2, 'special2' => $special2, 'penalty' => Json::encode($penalty), + 'aux' => Json::encode([ + 'prestart_delete_after' => addTurn( + $now, + $admin['turnterm'], + GameConst::$minTurnDieOnPrestart + ), + ]), ]); $generalID = $db->insertId(); $db->insert('general_access_log', [ diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php index f8c46eb5..8ae99c21 100644 --- a/hwe/sammo/CentennialAllStarGrowthService.php +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -451,6 +451,7 @@ final class CentennialAllStarGrowthService if (!in_array($general->getNPCType(), [3, 4], true)) { return null; } + self::initializeGeneratedNPC($general, $targetInfo); $result = self::applyTarget( $general, $targetInfo, @@ -462,6 +463,36 @@ final class CentennialAllStarGrowthService return $result; } + /** + * Discards the builder's generic random stat/dex values before applying + * the selected all-star target. A newly generated NPC has no organic + * growth yet, so those temporary values must not mask the creation-date + * event baseline. + */ + public static function initializeGeneratedNPC( + General $general, + array $targetInfo + ): void { + foreach (self::STAT_KEYS as $key) { + $target = min( + GameConst::$maxLevel, + max(0, (int) ($targetInfo[$key] ?? 0)) + ); + $general->updateVar( + $key, + CentennialAllStarGrowth::statFloor( + $target, + GameConst::$defaultStatMin, + 0 + ) + ); + } + foreach (self::DEX_KEYS as $key) { + $general->updateVar($key, 0); + } + $general->setAuxVar(self::AUX_KEY, self::initialAux($targetInfo)); + } + /** * Moves the event-backed part of a dex conversion with the converted * value and consumes any guaranteed floor crossed by the source value. diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 613a5cc0..34266707 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -83,6 +83,51 @@ final class CentennialAllStarGrowthTest extends TestCase ); } + public function testGeneratedNpcUsesExactCreationDateTargetInsteadOfRandomBase(): void + { + $vars = [ + 'leadership' => 72, + 'strength' => 61, + 'intel' => 32, + 'dex1' => 120000, + 'dex2' => 240000, + 'dex3' => 360000, + 'dex4' => 480000, + 'dex5' => 600000, + 'special' => 'None', + ]; + $target = [ + 'uniqueName' => 'A1000001', + 'leadership' => 100, + 'strength' => 80, + 'intel' => 10, + 'dex' => [900000, 800000, 700000, 600000, 500000], + ]; + $aux = CentennialAllStarGrowthService::initialAux($target); + $general = $this->createStateGeneralMock($vars, $aux); + + CentennialAllStarGrowthService::initializeGeneratedNPC($general, $target); + CentennialAllStarGrowthService::applyTarget( + $general, + $target, + ['startyear' => 180, 'year' => 195, 'month' => 1], + CentennialAllStarGrowthService::NPC_PROGRESS_MULTIPLIER, + GameConst::$centennialNpcDexTargetRatio + ); + + self::assertSame(91, $vars['leadership']); + self::assertSame(73, $vars['strength']); + self::assertSame(10, $vars['intel']); + self::assertSame( + [360000, 320000, 280000, 240000, 200000], + $this->dexValues($vars) + ); + self::assertSame(76, $aux['granted']['leadership']); + self::assertSame(58, $aux['granted']['strength']); + self::assertSame(0, $aux['granted']['intel']); + self::assertSame(360000, $aux['granted']['dex1']); + } + public function testProgressMultiplierMustStayWithinUnitInterval(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/browser/s100-my-page-cooldown-refresh-guard.mjs b/tests/browser/s100-my-page-cooldown-refresh-guard.mjs new file mode 100644 index 00000000..9bf34ba4 --- /dev/null +++ b/tests/browser/s100-my-page-cooldown-refresh-guard.mjs @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import {createHash} from 'node:crypto'; +import {chromium} from 'playwright'; + +const baseURL = process.env.REF_BROWSER_URL; +const screenshotPath = process.env.REF_SCREENSHOT_PATH; +const resultPath = process.env.REF_RESULT_PATH; +const username = process.env.REF_USER_ID ?? 's100user01'; +const password = fs.readFileSync('/run/secrets/test_user_password', 'utf8').trim(); + +if (!baseURL || !screenshotPath || !resultPath) { + throw new Error('REF_BROWSER_URL, REF_SCREENSHOT_PATH, and REF_RESULT_PATH are required'); +} + +const browser = await chromium.launch({headless: true}); +const context = await browser.newContext({ + viewport: {width: 1280, height: 960}, + deviceScaleFactor: 1, +}); +const page = await context.newPage(); + +await page.goto(baseURL, {waitUntil: 'domcontentloaded', timeout: 60_000}); +const salt = await page.locator('#global_salt').inputValue(); +const passwordHash = createHash('sha512') + .update(salt + password + salt) + .digest('hex'); +const loginResponse = await page.request.post( + new URL('api.php?path=Login/LoginByID', baseURL).href, + {data: {username, password: passwordHash}}, +); +const loginResult = await loginResponse.json(); +if (!loginResponse.ok() || loginResult.result !== true) { + throw new Error(`login failed: ${String(loginResult.reason ?? loginResponse.status())}`); +} + +const myPageURL = new URL('hwe/b_myPage.php', baseURL).href; +const readCooldownText = async () => { + await page.goto(myPageURL, {waitUntil: 'networkidle', timeout: 60_000}); + const bodyText = await page.locator('body').innerText(); + const lines = bodyText.split('\n').map(line => line.trim()); + const reselect = lines.find(line => line.startsWith('다른 장수 선택 (')); + const deletion = lines.find(line => line.startsWith('가오픈 기간 내 장수 삭제 (')); + if (!reselect || !deletion) { + throw new Error(`cooldown text missing: ${bodyText.slice(0, 4000)}`); + } + return {reselect, deletion}; +}; + +const first = await readCooldownText(); +await page.waitForTimeout(1100); +const second = await readCooldownText(); +await page.waitForTimeout(1100); +const third = await readCooldownText(); + +if (JSON.stringify(first) !== JSON.stringify(second) + || JSON.stringify(second) !== JSON.stringify(third) +) { + throw new Error(`cooldown moved after refresh: ${JSON.stringify({first, second, third})}`); +} + +await page.screenshot({path: screenshotPath, fullPage: true}); +fs.writeFileSync( + resultPath, + `${JSON.stringify({ + username, + url: page.url(), + viewport: {width: 1280, height: 960, deviceScaleFactor: 1}, + first, + second, + third, + }, null, 2)}\n`, + {mode: 0o600}, +); + +await browser.close(); +console.log(`S100 my-page cooldown refresh guard verified: ${screenshotPath}`); From 1e30659bddbdbf5eb58027be9fe9adf19b82d2a1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 01:19:23 +0000 Subject: [PATCH 2/2] test: verify S100 final-growth reselection --- tests/s100-reselection-final-growth-check.php | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/s100-reselection-final-growth-check.php diff --git a/tests/s100-reselection-final-growth-check.php b/tests/s100-reselection-final-growth-check.php new file mode 100644 index 00000000..0e038494 --- /dev/null +++ b/tests/s100-reselection-final-growth-check.php @@ -0,0 +1,212 @@ + 180, 'year' => 195, 'month' => 1]; +const STAT_KEYS = ['leadership', 'strength', 'intel']; +const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5']; + +$_SERVER['REMOTE_ADDR'] = '127.0.0.1'; +$_SERVER['REQUEST_URI'] = '/s100-reselection-final-growth-check'; + +require APP_ROOT . '/hwe/lib.php'; +require APP_ROOT . '/hwe/func.php'; + +$mode = $argv[1] ?? ''; +$username = $argv[2] ?? 's100user01'; +$snapshotPath = $argv[3] ?? '/tmp/s100-reselection-final-growth.json'; +if (!in_array($mode, ['prepare', 'verify'], true)) { + throw new InvalidArgumentException('Mode must be prepare or verify'); +} +if (preg_match('/^s100user[0-9]{2}$/', $username) !== 1) { + throw new InvalidArgumentException('Username must match s100userNN'); +} + +$db = DB::db(); +$owner = RootDB::db()->queryFirstField( + 'SELECT no FROM member WHERE id=%s', + $username +); +if ($owner === null) { + throw new RuntimeException("No member for {$username}"); +} +$generalID = $db->queryFirstField('SELECT no FROM general WHERE owner=%i', $owner); +if ($generalID === null) { + throw new RuntimeException("No general for owner {$owner}"); +} + +if ($mode === 'prepare') { + $gameStor = KVStorage::getStorage($db, 'game_env'); + $gameStor->setValue('year', TEST_ENV['year']); + $gameStor->setValue('month', TEST_ENV['month']); + + $eventResult = (new AdvanceCentennialAllStar())->run(TEST_ENV); + $general = General::createObjFromDB((int) $generalID); + $beforeGrowth = readGeneralState($general); + + $statGrowthKey = null; + foreach (STAT_KEYS as $key) { + if ((int) $general->getVar($key) < GameConst::$maxLevel) { + $statGrowthKey = $key; + break; + } + } + if ($statGrowthKey === null) { + throw new RuntimeException('No stat can receive an organic level-up'); + } + $general->increaseVar( + "{$statGrowthKey}_exp", + GameConst::$upgradeLimit + ); + if (!$general->checkStatChange()) { + throw new RuntimeException('Organic stat level-up did not occur'); + } + + $general->addDex($general->getCrewTypeObj(), 12345, false); + $general->setAuxVar('next_change', '2000-01-01 00:00:00'); + $general->applyDB($db); + + $afterGrowth = readGeneralState($general); + $snapshot = [ + 'username' => $username, + 'generalID' => (int) $generalID, + 'eventResult' => $eventResult, + 'statGrowthKey' => $statGrowthKey, + 'beforeGrowth' => $beforeGrowth, + 'afterGrowth' => $afterGrowth, + ]; + if (file_put_contents( + $snapshotPath, + Json::encode($snapshot, Json::PRETTY) . PHP_EOL + ) === false) { + throw new RuntimeException("Could not write {$snapshotPath}"); + } + chmod($snapshotPath, 0600); + printf( + "Prepared final-growth reselection: user=%s target=%s stat=%s dexDelta=%d\n", + $username, + $afterGrowth['targetId'], + $statGrowthKey, + array_sum($afterGrowth['dex']) - array_sum($beforeGrowth['dex']) + ); + exit(0); +} + +$snapshot = Json::decode((string) file_get_contents($snapshotPath)); +$general = General::createObjFromDB((int) $generalID); +$actual = readGeneralState($general); +$targetInfoRaw = $db->queryFirstField( + 'SELECT info FROM select_pool WHERE general_id=%i', + $generalID +); +if ($targetInfoRaw === null) { + throw new RuntimeException('Reselected target is not assigned to the general'); +} +$targetInfo = Json::decode($targetInfoRaw); +$oldTargetId = $snapshot['afterGrowth']['targetId']; +$newTargetId = (string) ($targetInfo['uniqueName'] ?? ''); +if ($newTargetId === '' || $newTargetId === $oldTargetId) { + throw new RuntimeException('Chromium reselection did not change the target'); +} +if ($actual['targetId'] !== $newTargetId) { + throw new RuntimeException('General aux target does not match the assigned pool target'); +} + +$expectedStats = CentennialAllStarGrowthService::calculateUserCurrentTargetStats( + $targetInfo, + TEST_ENV +); +$expectedDex = []; +foreach (STAT_KEYS as $key) { + $organic = max( + 0, + (int) $snapshot['afterGrowth']['stats'][$key] + - (int) $snapshot['afterGrowth']['granted'][$key] + ); + $expectedStats[$key] = max($organic, $expectedStats[$key]); + assertSameValue($expectedStats[$key], $actual['stats'][$key], $key); + assertSameValue( + $actual['stats'][$key] - $organic, + $actual['granted'][$key], + "{$key} event grant" + ); +} +foreach (DEX_KEYS as $idx => $key) { + $organic = max( + 0, + (int) $snapshot['afterGrowth']['dex'][$key] + - (int) $snapshot['afterGrowth']['granted'][$key] + ); + $targetFloor = CentennialAllStarGrowthService::calculateDexTargetFloor( + (int) ($targetInfo['dex'][$idx] ?? 0), + TEST_ENV + ); + $expectedDex[$key] = max($organic, $targetFloor); + assertSameValue($expectedDex[$key], $actual['dex'][$key], $key); + assertSameValue($targetFloor, $actual['dexFloor'][$key], "{$key} floor"); + assertSameValue( + $actual['dex'][$key] - $organic, + $actual['granted'][$key], + "{$key} event grant" + ); +} + +printf( + "Verified final-growth reselection: user=%s old=%s new=%s stats=%s dex=%s\n", + $username, + $oldTargetId, + $newTargetId, + Json::encode($actual['stats']), + Json::encode($actual['dex']) +); + +/** + * @return array{ + * targetId:string, + * stats:array, + * dex:array, + * granted:array, + * dexFloor:array + * } + */ +function readGeneralState(General $general): array +{ + $aux = $general->getAuxVar(CentennialAllStarGrowthService::AUX_KEY); + if (!is_array($aux)) { + throw new RuntimeException('Centennial growth ledger is missing'); + } + $stats = []; + foreach (STAT_KEYS as $key) { + $stats[$key] = (int) $general->getVar($key); + } + $dex = []; + foreach (DEX_KEYS as $key) { + $dex[$key] = (int) $general->getVar($key); + } + return [ + 'targetId' => (string) ($aux['targetId'] ?? ''), + 'stats' => $stats, + 'dex' => $dex, + 'granted' => $aux['granted'] ?? [], + 'dexFloor' => $aux['dexFloor'] ?? [], + ]; +} + +function assertSameValue(int $expected, int $actual, string $label): void +{ + if ($actual !== $expected) { + throw new RuntimeException( + "{$label}: expected {$expected}, got {$actual}" + ); + } +}