fix: stabilize S100 cooldowns and generated NPC growth
This commit is contained in:
+22
-8
@@ -22,8 +22,6 @@ $db = DB::db();
|
|||||||
$gameStor = KVStorage::getStorage($db, 'game_env');
|
$gameStor = KVStorage::getStorage($db, 'game_env');
|
||||||
$gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']);
|
$gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']);
|
||||||
|
|
||||||
increaseRefresh("내정보", 1);
|
|
||||||
|
|
||||||
$me = General::createObjFromDB($generalID, null, GeneralQueryMode::FullWithAccessLog);
|
$me = General::createObjFromDB($generalID, null, GeneralQueryMode::FullWithAccessLog);
|
||||||
|
|
||||||
$myset = $me->getVar('myset');
|
$myset = $me->getVar('myset');
|
||||||
@@ -41,10 +39,26 @@ $lastRefresh = $db->queryFirstField(
|
|||||||
$generalID
|
$generalID
|
||||||
);
|
);
|
||||||
|
|
||||||
$targetTime = addTurn($lastRefresh, $gameStor->turnterm, GameConst::$minTurnDieOnPrestart);
|
$nextChange = $me->getAuxVar('next_change');
|
||||||
if ($gameStor->turntime <= $gameStor->opentime) {
|
if (!is_string($nextChange) || $nextChange === '') {
|
||||||
//서버 가오픈시 할 수 있는 행동
|
$nextChange = null;
|
||||||
if ($me->getNPCType() == 0 && $me->getNationID() == 0) {
|
}
|
||||||
|
|
||||||
|
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;
|
$showDieOnPrestartBtn = true;
|
||||||
if ($targetTime <= TimeUtil::now()) {
|
if ($targetTime <= TimeUtil::now()) {
|
||||||
$availableDieOnPrestart = true;
|
$availableDieOnPrestart = true;
|
||||||
@@ -174,7 +188,7 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($gameStor->npcmode == 2 && $me->getNPCType() == 0) : ?>
|
<?php if ($gameStor->npcmode == 2 && $me->getNPCType() == 0) : ?>
|
||||||
다른 장수 선택 (<?= substr($me->getAuxVar('next_change') ?? TimeUtil::now(), 0, 19) ?> 부터)<br>
|
다른 장수 선택 (<?= $nextChange ? substr($nextChange, 0, 19) : '지금' ?>부터)<br>
|
||||||
<a href="select_general_from_pool.php" id='select_general_from_pool'><button type="button" style=background-color:<?= GameConst::$basecolor2 ?>;color:white;width:160px;height:30px;font-size:14px;>다른 장수 선택</button></a><br><br>
|
<a href="select_general_from_pool.php" id='select_general_from_pool'><button type="button" style=background-color:<?= GameConst::$basecolor2 ?>;color:white;width:160px;height:30px;font-size:14px;>다른 장수 선택</button></a><br><br>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -272,4 +286,4 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ $builder->setOwnerName($userNick);
|
|||||||
$builder->setKillturn(5);
|
$builder->setKillturn(5);
|
||||||
$builder->setNPCType(0);
|
$builder->setNPCType(0);
|
||||||
$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm']));
|
$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm']));
|
||||||
|
$builder->setAuxVar(
|
||||||
|
'prestart_delete_after',
|
||||||
|
addTurn($now, $env['turnterm'], GameConst::$minTurnDieOnPrestart)
|
||||||
|
);
|
||||||
$builder->fillRemainSpecAsZero($env);
|
$builder->fillRemainSpecAsZero($env);
|
||||||
if ($isCentennialAllStar) {
|
if ($isCentennialAllStar) {
|
||||||
$candidateCities = $db->queryFirstColumn(
|
$candidateCities = $db->queryFirstColumn(
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class DieOnPrestart extends \sammo\BaseAPI
|
|||||||
$gameStor->cacheValues(['turnterm', 'opentime', 'turntime', 'year', 'month']);
|
$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);
|
$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(
|
$lastRefresh = $db->queryFirstField(
|
||||||
'SELECT %b FROM general_access_log WHERE %b = %i',
|
'SELECT %b FROM general_access_log WHERE %b = %i',
|
||||||
GeneralAccessLogColumn::lastRefresh->value,
|
GeneralAccessLogColumn::lastRefresh->value,
|
||||||
@@ -46,8 +50,9 @@ class DieOnPrestart extends \sammo\BaseAPI
|
|||||||
$general['no']
|
$general['no']
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$general) {
|
$generalObj = General::createObjFromDB($general['no']);
|
||||||
return '장수가 없습니다';
|
if ($generalObj instanceof DummyGeneral) {
|
||||||
|
trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING);
|
||||||
}
|
}
|
||||||
|
|
||||||
increaseRefresh("장수 삭제", 1);
|
increaseRefresh("장수 삭제", 1);
|
||||||
@@ -61,18 +66,23 @@ class DieOnPrestart extends \sammo\BaseAPI
|
|||||||
return '이미 국가에 소속되어있습니다.';
|
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()) {
|
if ($targetTime > TimeUtil::now()) {
|
||||||
$targetTimeShort = substr($targetTime, 0, 19);
|
$targetTimeShort = substr($targetTime, 0, 19);
|
||||||
return "아직 삭제할 수 없습니다. {$targetTimeShort} 부터 가능합니다.";
|
return "아직 삭제할 수 없습니다. {$targetTimeShort} 부터 가능합니다.";
|
||||||
}
|
}
|
||||||
|
|
||||||
$generalObj = General::createObjFromDB($general['no']);
|
|
||||||
if ($generalObj instanceof DummyGeneral) {
|
|
||||||
trigger_error("올바르지 않은 삭제 프로세스 $userID", E_USER_WARNING);
|
|
||||||
}
|
|
||||||
|
|
||||||
$generalName = $generalObj->getName();
|
$generalName = $generalObj->getName();
|
||||||
$josaYi = JosaUtil::pick($generalName, '이');
|
$josaYi = JosaUtil::pick($generalName, '이');
|
||||||
$generalObj->kill($db, true, "<Y>{$generalName}</>{$josaYi} 홀연히 모습을 <R>감추었습니다</>");
|
$generalObj->kill($db, true, "<Y>{$generalName}</>{$josaYi} 홀연히 모습을 <R>감추었습니다</>");
|
||||||
|
|||||||
@@ -435,6 +435,13 @@ class Join extends \sammo\BaseAPI
|
|||||||
'specage2' => $specage2,
|
'specage2' => $specage2,
|
||||||
'special2' => $special2,
|
'special2' => $special2,
|
||||||
'penalty' => Json::encode($penalty),
|
'penalty' => Json::encode($penalty),
|
||||||
|
'aux' => Json::encode([
|
||||||
|
'prestart_delete_after' => addTurn(
|
||||||
|
$now,
|
||||||
|
$admin['turnterm'],
|
||||||
|
GameConst::$minTurnDieOnPrestart
|
||||||
|
),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
$generalID = $db->insertId();
|
$generalID = $db->insertId();
|
||||||
$db->insert('general_access_log', [
|
$db->insert('general_access_log', [
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ final class CentennialAllStarGrowthService
|
|||||||
if (!in_array($general->getNPCType(), [3, 4], true)) {
|
if (!in_array($general->getNPCType(), [3, 4], true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
self::initializeGeneratedNPC($general, $targetInfo);
|
||||||
$result = self::applyTarget(
|
$result = self::applyTarget(
|
||||||
$general,
|
$general,
|
||||||
$targetInfo,
|
$targetInfo,
|
||||||
@@ -462,6 +463,36 @@ final class CentennialAllStarGrowthService
|
|||||||
return $result;
|
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
|
* Moves the event-backed part of a dex conversion with the converted
|
||||||
* value and consumes any guaranteed floor crossed by the source value.
|
* value and consumes any guaranteed floor crossed by the source value.
|
||||||
|
|||||||
@@ -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
|
public function testProgressMultiplierMustStayWithinUnitInterval(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
Reference in New Issue
Block a user