From edfeae5f0f1f2f75b95443f48bfaaea872810818 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 29 Jul 2026 08:42:38 +0000 Subject: [PATCH] fix: block S100 inheritance stat resets --- hwe/sammo/API/InheritAction/ResetStat.php | 5 + hwe/sammo/CentennialAllStarGrowthService.php | 5 + hwe/ts/PageInheritPoint.vue | 17 +- hwe/v_inheritPoint.php | 3 +- tests/CentennialAllStarGrowthTest.php | 14 ++ .../s100-inheritance-stat-reset-guard.mjs | 148 ++++++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 tests/browser/s100-inheritance-stat-reset-guard.mjs diff --git a/hwe/sammo/API/InheritAction/ResetStat.php b/hwe/sammo/API/InheritAction/ResetStat.php index e0eae16b..bb18f8a0 100644 --- a/hwe/sammo/API/InheritAction/ResetStat.php +++ b/hwe/sammo/API/InheritAction/ResetStat.php @@ -4,6 +4,7 @@ namespace sammo\API\InheritAction; use sammo\Session; use DateTimeInterface; +use sammo\CentennialAllStarGrowthService; use sammo\DB; use sammo\Enums\APIRecoveryType; use sammo\Enums\RankColumn; @@ -98,6 +99,10 @@ class ResetStat extends \sammo\BaseAPI return 'NPC는 능력치 초기화를 할 수 없습니다.'; } + if (!CentennialAllStarGrowthService::isStatResetAllowed()) { + return '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.'; + } + $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); diff --git a/hwe/sammo/CentennialAllStarGrowthService.php b/hwe/sammo/CentennialAllStarGrowthService.php index 26228888..f8c46eb5 100644 --- a/hwe/sammo/CentennialAllStarGrowthService.php +++ b/hwe/sammo/CentennialAllStarGrowthService.php @@ -19,6 +19,11 @@ final class CentennialAllStarGrowthService return GameConst::$targetGeneralPool === self::POOL_CLASS; } + public static function isStatResetAllowed(): bool + { + return !self::isActive(); + } + public static function initialAux(array $targetInfo, ?array $userInitialStats = null): array { $granted = array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0); diff --git a/hwe/ts/PageInheritPoint.vue b/hwe/ts/PageInheritPoint.vue index 57756e98..246e321d 100644 --- a/hwe/ts/PageInheritPoint.vue +++ b/hwe/ts/PageInheritPoint.vue @@ -172,7 +172,7 @@ 소유자 찾기 -
+
능력치 초기화
@@ -222,6 +222,17 @@ 능력치 초기화
+
+
+
능력치 초기화
+
사용 불가
+
+
+ + 100기 올스타 장수는 장수 전환 시 능력치 성장 기록을 보존하기 위해 능력치 초기화를 사용할 수 없습니다. + +
+
@@ -297,6 +308,7 @@ declare const staticValues: { } >; availableTargetGeneral: Record; + canResetStat: boolean; currentStat: { leadership: number; strength: number; @@ -447,6 +459,7 @@ const { availableSpecialWar, availableUnique, availableTargetGeneral, + canResetStat, currentStat } = staticValues; @@ -739,4 +752,4 @@ async function getMoreLog(): Promise { .tnum { font-feature-settings: "tnum"; } - \ No newline at end of file + diff --git a/hwe/v_inheritPoint.php b/hwe/v_inheritPoint.php index b4e1271c..16564f1f 100644 --- a/hwe/v_inheritPoint.php +++ b/hwe/v_inheritPoint.php @@ -105,6 +105,7 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex 'availableUnique' => $availableUnique, 'lastInheritPointLogs' => $lastInheritPointLogs, 'availableTargetGeneral' => $availableTargetGeneral, + 'canResetStat' => CentennialAllStarGrowthService::isStatResetAllowed(), 'currentStat' => [ 'leadership' => Util::clamp($me->getVar('leadership'), GameConst::$defaultStatMin, GameConst::$defaultStatMax), 'strength' => Util::clamp($me->getVar('strength'), GameConst::$defaultStatMin, GameConst::$defaultStatMax), @@ -120,4 +121,4 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex
- \ No newline at end of file + diff --git a/tests/CentennialAllStarGrowthTest.php b/tests/CentennialAllStarGrowthTest.php index 65b9d4c4..613a5cc0 100644 --- a/tests/CentennialAllStarGrowthTest.php +++ b/tests/CentennialAllStarGrowthTest.php @@ -25,6 +25,20 @@ final class CentennialAllStarGrowthTest extends TestCase self::assertSame(1.0, CentennialAllStarGrowth::progress(180, 210, 1)); } + public function testStatResetIsDisabledOnlyForCentennialAllStarPool(): void + { + $previousPool = GameConst::$targetGeneralPool; + try { + GameConst::$targetGeneralPool = CentennialAllStarGrowthService::POOL_CLASS; + self::assertFalse(CentennialAllStarGrowthService::isStatResetAllowed()); + + GameConst::$targetGeneralPool = 'RandomNameGeneral'; + self::assertTrue(CentennialAllStarGrowthService::isStatResetAllowed()); + } finally { + GameConst::$targetGeneralPool = $previousPool; + } + } + public function testMAndGGeneralsAdvanceAtNinetyPercentProgress(): void { self::assertSame( diff --git a/tests/browser/s100-inheritance-stat-reset-guard.mjs b/tests/browser/s100-inheritance-stat-reset-guard.mjs new file mode 100644 index 00000000..2b6f6835 --- /dev/null +++ b/tests/browser/s100-inheritance-stat-reset-guard.mjs @@ -0,0 +1,148 @@ +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(); +const expectedReason = '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.'; +const expectedWarning = '100기 올스타 장수는 장수 전환 시 능력치 성장 기록을 보존하기 위해 능력치 초기화를 사용할 수 없습니다.'; + +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(); +const browserMessages = []; +page.on('console', message => { + browserMessages.push({type: message.type(), text: message.text()}); +}); +page.on('pageerror', error => { + browserMessages.push({type: 'pageerror', text: error.message}); +}); + +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 gameLoginResponse = await page.request.get( + new URL('hwe/api.php?path=Global/GetConst', baseURL).href, +); +const gameLoginResult = await gameLoginResponse.json(); +if (!gameLoginResponse.ok() || gameLoginResult.result !== true) { + throw new Error(`game login failed: ${JSON.stringify(gameLoginResult)}`); +} + +await page.goto( + new URL('hwe/v_inheritPoint.php', baseURL).href, + {waitUntil: 'networkidle', timeout: 60_000}, +); +const bodyText = await page.locator('body').innerText(); +if (bodyText.includes('#0 /var/www/html/')) { + throw new Error('PHP stack trace was rendered in the inheritance page'); +} +if (!bodyText.includes(expectedWarning)) { + await page.screenshot({path: screenshotPath, fullPage: true}); + fs.writeFileSync( + resultPath, + `${JSON.stringify({ + username, + url: page.url(), + bodyText: bodyText.slice(0, 4000), + browserMessages, + }, null, 2)}\n`, + {mode: 0o600}, + ); + throw new Error('S100 stat-reset restriction was not rendered'); +} +if (await page.getByRole('button', {name: '능력치 초기화'}).count() !== 0) { + throw new Error('S100 stat-reset button must not be rendered'); +} + +const warning = page.getByText(expectedWarning, {exact: true}); +await warning.waitFor({state: 'visible', timeout: 60_000}); +const warningGeometry = await warning.evaluate(element => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + style: { + color: style.color, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }, + }; +}); +const beforeState = await page.evaluate(() => ({ + currentStat: staticValues.currentStat, + previousPoint: staticValues.items.previous, +})); + +const resetResponse = await page.request.put( + new URL('hwe/api.php?path=InheritAction/ResetStat', baseURL).href, + { + data: { + leadership: 55, + strength: 55, + intel: 55, + }, + }, +); +const resetResult = await resetResponse.json(); +if (!resetResponse.ok() + || resetResult.result !== false + || resetResult.reason !== expectedReason +) { + throw new Error(`unexpected ResetStat response: ${JSON.stringify(resetResult)}`); +} + +await page.reload({waitUntil: 'networkidle', timeout: 60_000}); +const afterState = await page.evaluate(() => ({ + currentStat: staticValues.currentStat, + previousPoint: staticValues.items.previous, +})); +if (JSON.stringify(afterState) !== JSON.stringify(beforeState)) { + throw new Error(`ResetStat rejection changed state: ${JSON.stringify({beforeState, afterState})}`); +} + +await page.screenshot({path: screenshotPath, fullPage: true}); +fs.writeFileSync( + resultPath, + `${JSON.stringify({ + username, + url: page.url(), + viewport: {width: 1280, height: 960, deviceScaleFactor: 1}, + warningGeometry, + resetResult, + beforeState, + afterState, + browserMessages, + }, null, 2)}\n`, + {mode: 0o600}, +); + +await browser.close(); +console.log(`S100 inheritance stat-reset guard verified: ${screenshotPath}`);