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}`);