fix: block S100 inheritance stat resets

This commit is contained in:
2026-07-29 08:42:38 +00:00
parent 424184a51a
commit edfeae5f0f
6 changed files with 189 additions and 3 deletions
@@ -4,6 +4,7 @@ namespace sammo\API\InheritAction;
use sammo\Session; use sammo\Session;
use DateTimeInterface; use DateTimeInterface;
use sammo\CentennialAllStarGrowthService;
use sammo\DB; use sammo\DB;
use sammo\Enums\APIRecoveryType; use sammo\Enums\APIRecoveryType;
use sammo\Enums\RankColumn; use sammo\Enums\RankColumn;
@@ -98,6 +99,10 @@ class ResetStat extends \sammo\BaseAPI
return 'NPC는 능력치 초기화를 할 수 없습니다.'; return 'NPC는 능력치 초기화를 할 수 없습니다.';
} }
if (!CentennialAllStarGrowthService::isStatResetAllowed()) {
return '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.';
}
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
@@ -19,6 +19,11 @@ final class CentennialAllStarGrowthService
return GameConst::$targetGeneralPool === self::POOL_CLASS; return GameConst::$targetGeneralPool === self::POOL_CLASS;
} }
public static function isStatResetAllowed(): bool
{
return !self::isActive();
}
public static function initialAux(array $targetInfo, ?array $userInitialStats = null): array public static function initialAux(array $targetInfo, ?array $userInitialStats = null): array
{ {
$granted = array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0); $granted = array_fill_keys(array_merge(self::STAT_KEYS, self::DEX_KEYS), 0);
+15 -2
View File
@@ -172,7 +172,7 @@
<BButton class="col-6 offset-6" variant="primary" @click="checkOwner"> 소유자 찾기 </BButton> <BButton class="col-6 offset-6" variant="primary" @click="checkOwner"> 소유자 찾기 </BButton>
</div> </div>
</div> </div>
<div class="col col-lg-4 col-sm-6 col-12 py-2"> <div v-if="canResetStat" class="col col-lg-4 col-sm-6 col-12 py-2">
<div class="row px-4"> <div class="row px-4">
<div class="a-right col-6 align-self-center">능력치 초기화</div> <div class="a-right col-6 align-self-center">능력치 초기화</div>
<div class="col-6"> <div class="col-6">
@@ -222,6 +222,17 @@
<BButton class="col-6 offset-6" variant="primary" @click="resetStat"> 능력치 초기화</BButton> <BButton class="col-6 offset-6" variant="primary" @click="resetStat"> 능력치 초기화</BButton>
</div> </div>
</div> </div>
<div v-else class="col col-lg-4 col-sm-6 col-12 py-2">
<div class="row px-4">
<div class="a-right col-6 align-self-center">능력치 초기화</div>
<div class="col-6 align-self-center">사용 불가</div>
</div>
<div class="a-right">
<small class="form-text text-muted">
100 올스타 장수는 장수 전환 능력치 성장 기록을 보존하기 위해 능력치 초기화를 사용할 없습니다.
</small>
</div>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@@ -297,6 +308,7 @@ declare const staticValues: {
} }
>; >;
availableTargetGeneral: Record<number, string>; availableTargetGeneral: Record<number, string>;
canResetStat: boolean;
currentStat: { currentStat: {
leadership: number; leadership: number;
strength: number; strength: number;
@@ -447,6 +459,7 @@ const {
availableSpecialWar, availableSpecialWar,
availableUnique, availableUnique,
availableTargetGeneral, availableTargetGeneral,
canResetStat,
currentStat currentStat
} = staticValues; } = staticValues;
@@ -739,4 +752,4 @@ async function getMoreLog(): Promise<void> {
.tnum { .tnum {
font-feature-settings: "tnum"; font-feature-settings: "tnum";
} }
</style> </style>
+2 -1
View File
@@ -105,6 +105,7 @@ $lastInheritPointLogs = $db->query('SELECT id, server_id, year, month, date, tex
'availableUnique' => $availableUnique, 'availableUnique' => $availableUnique,
'lastInheritPointLogs' => $lastInheritPointLogs, 'lastInheritPointLogs' => $lastInheritPointLogs,
'availableTargetGeneral' => $availableTargetGeneral, 'availableTargetGeneral' => $availableTargetGeneral,
'canResetStat' => CentennialAllStarGrowthService::isStatResetAllowed(),
'currentStat' => [ 'currentStat' => [
'leadership' => Util::clamp($me->getVar('leadership'), GameConst::$defaultStatMin, GameConst::$defaultStatMax), 'leadership' => Util::clamp($me->getVar('leadership'), GameConst::$defaultStatMin, GameConst::$defaultStatMax),
'strength' => Util::clamp($me->getVar('strength'), 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
<div id="app"></div> <div id="app"></div>
</body> </body>
</html> </html>
+14
View File
@@ -25,6 +25,20 @@ final class CentennialAllStarGrowthTest extends TestCase
self::assertSame(1.0, CentennialAllStarGrowth::progress(180, 210, 1)); 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 public function testMAndGGeneralsAdvanceAtNinetyPercentProgress(): void
{ {
self::assertSame( self::assertSame(
@@ -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}`);