merge: validate invader resume watchdog fix

This commit is contained in:
2026-08-12 15:35:57 +00:00
37 changed files with 1778 additions and 131 deletions
+10 -1
View File
@@ -800,6 +800,7 @@ function checkEmperior()
$gameStor->isunited = 2;
$gameStor->refreshLimit = $gameStor->refreshLimit * 100;
AutoResetProgress::record($gameStor);
foreach ($db->queryFirstColumn('SELECT no FROM general WHERE npc<2 AND age>=%i', GameConst::$minPushHallAge) as $hallGeneralNo) {
CheckHall($hallGeneralNo);
@@ -811,7 +812,7 @@ function checkEmperior()
$chiefs = Util::convertArrayToDict(
$db->query(
'SELECT no,npc,name,picture,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5',
'SELECT no,npc,name,picture,imgsvr,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5',
$nationID
),
'officer_level'
@@ -924,20 +925,28 @@ function checkEmperior()
'rice' => $nation['rice'],
'l12name' => $chiefs[12]['name'],
'l12pic' => $chiefs[12]['picture'],
'l12imgsvr' => $chiefs[12]['imgsvr'],
'l11name' => $chiefs[11]['name'],
'l11pic' => $chiefs[11]['picture'],
'l11imgsvr' => $chiefs[11]['imgsvr'],
'l10name' => $chiefs[10]['name'],
'l10pic' => $chiefs[10]['picture'],
'l10imgsvr' => $chiefs[10]['imgsvr'],
'l9name' => $chiefs[9]['name'],
'l9pic' => $chiefs[9]['picture'],
'l9imgsvr' => $chiefs[9]['imgsvr'],
'l8name' => $chiefs[8]['name'],
'l8pic' => $chiefs[8]['picture'],
'l8imgsvr' => $chiefs[8]['imgsvr'],
'l7name' => $chiefs[7]['name'],
'l7pic' => $chiefs[7]['picture'],
'l7imgsvr' => $chiefs[7]['imgsvr'],
'l6name' => $chiefs[6]['name'],
'l6pic' => $chiefs[6]['picture'],
'l6imgsvr' => $chiefs[6]['imgsvr'],
'l5name' => $chiefs[5]['name'],
'l5pic' => $chiefs[5]['picture'],
'l5imgsvr' => $chiefs[5]['imgsvr'],
'tiger' => $tigerstr,
'eagle' => $eaglestr,
'gen' => $gen,
+13 -5
View File
@@ -19,7 +19,6 @@ if(!class_exists('\\sammo\\DB')){
$db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env');
$clock = GameClock::fromStorage($gameStor);
if(file_exists(__DIR__.'/.htaccess')){
$reserved = $db->queryFirstRow(
@@ -71,15 +70,24 @@ if(file_exists(__DIR__.'/.htaccess')){
//TODO: 천통시에도 예약 오픈 알림이 필요..?
$usesLogicalClock = GameClock::isInitialized($gameStor);
$admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']);
$admin['maxUserCnt'] = $admin['maxgeneral'];
$admin['npcMode'] = $admin['npcmode'];
$admin['turnTerm'] = $admin['turnterm'];
$admin['isUnited'] = $admin['isunited'];
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
$admin['starttime'] = substr($admin['opentime'], 5, 11);
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
if($usesLogicalClock){
$clock = GameClock::fromStorage($gameStor);
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
$admin['starttime'] = substr($admin['opentime'], 5, 11);
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
}
else{
$admin['isOpen'] = new \DateTimeImmutable((string)$admin['opentime']) <= GameClock::readWallTime();
$admin['starttime'] = substr((string)$admin['opentime'], 5, 11);
$admin['turntime'] = substr((string)$admin['turntime'], 5, 11);
}
unset($admin['npcmode']);
unset($admin['maxgeneral']);
unset($admin['turnterm']);
+27 -15
View File
@@ -8,6 +8,7 @@ use sammo\AppConf;
use sammo\Enums\APIRecoveryType;
use sammo\KVStorage;
use sammo\RootDB;
use sammo\RemoteUserIconUploadClient;
use sammo\TimeUtil;
use sammo\UniqueConst;
use sammo\Validator;
@@ -60,22 +61,33 @@ class UploadImage extends \sammo\BaseAPI
$imgName = hash_final($oMD);
$imgFullName = "{$imgName}.{$extension}";
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
$destPath = "{$destDir}/{$imgFullName}";
$remotePath = null;
if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
try {
RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData);
$remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName);
} catch (\Throwable $error) {
RemoteUserIconUploadClient::logFailure('content-image', $error);
return '원격 이미지 저장소 업로드에 실패했습니다!';
}
} else {
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
$destPath = "{$destDir}/{$imgFullName}";
if (!file_exists($destPath)) {
if (!file_exists($destDir)) {
mkdir($destDir);
}
if (!is_dir($destDir)) {
return '버그! 업로드 경로 확인!';
}
if (!is_writable($destDir)) {
return '버그! 업로드 권한 확인!';
}
if (!file_exists($destPath)) {
if (!file_exists($destDir)) {
mkdir($destDir);
}
if (!is_dir($destDir)) {
return '버그! 업로드 경로 확인!';
}
if (!is_writable($destDir)) {
return '버그! 업로드 권한 확인!';
}
if (!file_put_contents($destPath, $imageData)) {
return '업로드에 실패했습니다!';
if (!file_put_contents($destPath, $imageData)) {
return '업로드에 실패했습니다!';
}
}
}
@@ -96,7 +108,7 @@ class UploadImage extends \sammo\BaseAPI
return [
'result' => true,
'path'=>AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
'path'=>$remotePath ?? AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
];
}
}
+8 -4
View File
@@ -151,6 +151,11 @@ class GeneralList extends \sammo\BaseAPI
$gameStor = \sammo\KVStorage::getStorage($db, 'game_env');
$env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']);
$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;
$formatStoredTime = static fn (mixed $value): string => $clock === null
? (string)$value
: $clock->formatTick(Util::toInt($value));
$env['turntime'] = $formatStoredTime($env['turntime']);
$me = $db->queryFirstRow(
'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general`
@@ -187,7 +192,7 @@ class GeneralList extends \sammo\BaseAPI
if (!key_exists($troopLeaderID, $rawGeneralList)) {
continue;
}
$troopTurnTime = $rawGeneralList[$troopLeaderID]['turntime'];
$troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID]['turntime']);
$troops[$troopLeaderID] = new ArrayObject([
'id' => $troopLeaderID,
'name' => $troopName,
@@ -265,11 +270,10 @@ class GeneralList extends \sammo\BaseAPI
'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']),
'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']),
//'0000-00-00 11:23';
'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor)
->formatTick(Util::toInt($rawGeneral['turntime'])),
'turntime' => fn ($rawGeneral) => substr($formatStoredTime($rawGeneral['turntime']), 0, 19),
'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null
? null
: GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])),
: substr($formatStoredTime($rawGeneral['recent_war']), 0, 19),
'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']),
'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null,
'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0,
+3 -1
View File
@@ -2,6 +2,7 @@
namespace sammo\Event\Action;
use sammo\ActionLogger;
use sammo\AutoResetProgress;
use sammo\CityConst;
use sammo\DB;
use sammo\KVStorage;
@@ -61,6 +62,7 @@ class InvaderEnding extends \sammo\Event\Action{
$logger->pushGlobalHistoryLog("<L><b>【이벤트】</b></>백성은 언젠가 영웅이 나타나길 기다립니다.");
}
$gameStor->setValue('isunited', 3);
AutoResetProgress::record($gameStor);
$logger->flush();
$gameStor->refreshLimit = $gameStor->refreshLimit * 100;
@@ -71,4 +73,4 @@ class InvaderEnding extends \sammo\Event\Action{
return [__CLASS__, 'Deleted'];
}
}
}
+2
View File
@@ -4,6 +4,7 @@ namespace sammo\Event\Action;
use Ds\Set;
use sammo\ActionLogger;
use sammo\AutoResetProgress;
use sammo\CityConst;
use sammo\DB;
use sammo\Json;
@@ -119,6 +120,7 @@ class RaiseInvader extends \sammo\Event\Action
$gameStor = KVStorage::getStorage($db, 'game_env');
$gameStor->setValue('isunited', 1);
AutoResetProgress::record($gameStor);
$turnterm = $gameStor->turnterm;
$generalCnt = $db->queryFirstField('SELECT count(*) FROM general');
+23 -13
View File
@@ -152,19 +152,7 @@ class InheritancePointManager
};
switch ($key) {
case InheritanceKey::dex:
$extractFn = function () use ($general, $multiplier) {
$dexLimit = Util::array_last(getDexLevelList())[0];
$totalDex = 0;
foreach (array_keys(GameUnitConst::allType()) as $armType) {
$subDex = $general->getVar("dex{$armType}");
if ($subDex > $dexLimit) {
$totalDex += ($subDex - $dexLimit) / 3;
$subDex = $dexLimit;
}
$totalDex += $subDex;
}
return [$totalDex * $multiplier, null];
};
$extractFn = fn () => [$this->getDexInheritancePoint($general), null];
break;
case InheritanceKey::betting:
$extractFn = function () use ($general, $multiplier) {
@@ -189,6 +177,28 @@ class InheritancePointManager
return $value;
}
/** @param null|int[] $armTypes */
public function getDexInheritancePoint(
General $general,
?int $dexLimit = null,
?array $armTypes = null
): float
{
$dexLimit ??= Util::array_last(getDexLevelList())[0];
$armTypes ??= array_keys(GameUnitConst::allType());
$multiplier = $this->getInheritancePointType(InheritanceKey::dex)->pointCoeff;
$totalDex = 0;
foreach ($armTypes as $armType) {
$subDex = CentennialAllStarGrowthService::recordableValue($general, "dex{$armType}");
if ($subDex > $dexLimit) {
$totalDex += ($subDex - $dexLimit) / 3;
$subDex = $dexLimit;
}
$totalDex += $subDex;
}
return $totalDex * $multiplier;
}
public function setInheritancePoint(General $general, InheritanceKey $key, $value, $aux = null)
{
+16
View File
@@ -39,6 +39,18 @@ class TurnExecutionHelper
{
return max($completedTick, $candidateTick);
}
private static function recordInvaderProgress(KVStorage $gameStorage, int $beforeTick): void
{
if (Util::toInt($gameStorage->isunited) !== 1) {
return;
}
AutoResetProgress::recordIfAdvanced(
$gameStorage,
$beforeTick,
Util::toInt($gameStorage->turntime),
);
}
public function __destruct()
{
@@ -637,6 +649,8 @@ class TurnExecutionHelper
$locked = true;
return $gameStor->turntime;
}
$completionTickBeforeRun = Util::toInt($gameStor->turntime);
$gameStor->cacheAll();
// 1턴이상 갱신 없었으면 서버 지연
@@ -683,6 +697,7 @@ class TurnExecutionHelper
$currentTurn,
);
}
self::recordInvaderProgress($gameStor, $completionTickBeforeRun);
unlock();
return $gameStor->turntime;
}
@@ -768,6 +783,7 @@ class TurnExecutionHelper
processTournament();
//거래 처리
processAuction();
self::recordInvaderProgress($gameStor, $completionTickBeforeRun);
// 잠금 해제
$turntime = $gameStor->turntime;
+17 -9
View File
@@ -11,7 +11,7 @@ CREATE TABLE `general` (
`bornyear` INT(3) NULL DEFAULT '180',
`deadyear` INT(3) NULL DEFAULT '300',
`newmsg` INT(1) NULL DEFAULT '0',
`picture` VARCHAR(40) NOT NULL,
`picture` VARCHAR(64) NOT NULL,
`imgsvr` INT(1) NOT NULL DEFAULT '0',
`name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin',
`owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin',
@@ -346,21 +346,29 @@ CREATE TABLE IF NOT EXISTS `emperior` (
`gold` INT(9) NULL DEFAULT '0',
`rice` INT(9) NULL DEFAULT '0',
`l12name` VARCHAR(64) NULL DEFAULT '',
`l12pic` VARCHAR(32) NULL DEFAULT '',
`l12pic` VARCHAR(64) NULL DEFAULT '',
`l12imgsvr` INT(1) NULL DEFAULT NULL,
`l11name` VARCHAR(64) NULL DEFAULT '',
`l11pic` VARCHAR(32) NULL DEFAULT '',
`l11pic` VARCHAR(64) NULL DEFAULT '',
`l11imgsvr` INT(1) NULL DEFAULT NULL,
`l10name` VARCHAR(64) NULL DEFAULT '',
`l10pic` VARCHAR(32) NULL DEFAULT '',
`l10pic` VARCHAR(64) NULL DEFAULT '',
`l10imgsvr` INT(1) NULL DEFAULT NULL,
`l9name` VARCHAR(64) NULL DEFAULT '',
`l9pic` VARCHAR(32) NULL DEFAULT '',
`l9pic` VARCHAR(64) NULL DEFAULT '',
`l9imgsvr` INT(1) NULL DEFAULT NULL,
`l8name` VARCHAR(64) NULL DEFAULT '',
`l8pic` VARCHAR(32) NULL DEFAULT '',
`l8pic` VARCHAR(64) NULL DEFAULT '',
`l8imgsvr` INT(1) NULL DEFAULT NULL,
`l7name` VARCHAR(64) NULL DEFAULT '',
`l7pic` VARCHAR(32) NULL DEFAULT '',
`l7pic` VARCHAR(64) NULL DEFAULT '',
`l7imgsvr` INT(1) NULL DEFAULT NULL,
`l6name` VARCHAR(64) NULL DEFAULT '',
`l6pic` VARCHAR(32) NULL DEFAULT '',
`l6pic` VARCHAR(64) NULL DEFAULT '',
`l6imgsvr` INT(1) NULL DEFAULT NULL,
`l5name` VARCHAR(64) NULL DEFAULT '',
`l5pic` VARCHAR(32) NULL DEFAULT '',
`l5pic` VARCHAR(64) NULL DEFAULT '',
`l5imgsvr` INT(1) NULL DEFAULT NULL,
`tiger` VARCHAR(128) NULL DEFAULT '',
`eagle` VARCHAR(128) NULL DEFAULT '',
`gen` TEXT NULL DEFAULT '',
+44
View File
@@ -0,0 +1,44 @@
import { assert } from 'chai';
import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '../ts/gateway/loginFlow';
describe('automatic login failure flow', () => {
it('prompts for OTP without treating the challenge as a terminal login failure', () => {
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: false,
reqOTP: true,
reason: '인증 코드를 입력해주세요',
}, 0),
'prompt_otp'
);
assert.equal(OTP_REQUIRED_MESSAGE, '인증 코드 입력이 필요합니다.');
});
it('keeps the existing retry and alert behavior for non-OTP failures', () => {
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: false,
reason: '자동 로그인: 절차 오류',
}, 0),
'retry'
);
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: false,
reason: '로그인할 수 없습니다.',
}, 1),
'alert'
);
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: true,
reason: 'failed',
}, 1),
'silent'
);
});
});
+2 -1
View File
@@ -36,5 +36,6 @@ export type AutoLoginResponse = {
export type AutoLoginFailed = {
result: false,
silent: boolean,
reqOTP?: boolean,
reason: string,
}
}
+10 -7
View File
@@ -69,11 +69,8 @@ function setupDBForm() {
$('#btn_random_generate_key').on('click', function (e) {
e.preventDefault();
let token = '';
while (token.length < 24) {
token += (Math.random() + 1).toString(36).substring(7);
}
token = token.substr(0, 24);
const bytes = crypto.getRandomValues(new Uint8Array(32));
const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
$('#image_request_key').val(token);
});
@@ -86,6 +83,7 @@ function setupDBForm() {
serv_host: string,
shared_icon_path: string,
game_image_path: string,
image_request_path: string,
image_request_key: string,
kakao_rest_key: string,
kakao_admin_key: string,
@@ -124,10 +122,14 @@ function setupDBForm() {
required: true,
type: 'string',
},
image_request_path: {
required: true,
type: 'string',
},
image_request_key: {
required: false,
type: 'string',
min: 16,
min: 32,
},
kakao_rest_key: {
required: false,
@@ -163,6 +165,7 @@ function setupDBForm() {
serv_host: values.serv_host,
shared_icon_path: values.shared_icon_path,
game_image_path: values.game_image_path,
image_request_path: values.image_request_path,
image_request_key: values.image_request_key,
kakao_rest_key: values.kakao_rest_key,
kakao_admin_key: values.kakao_admin_key,
@@ -313,4 +316,4 @@ $(function () {
});
});
+28 -25
View File
@@ -12,8 +12,9 @@ import { delay } from '@util/delay';
import { Modal } from 'bootstrap';
import '@/gateway/common';
import { isString } from 'lodash-es';
import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI';
import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login';
import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI';
import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login';
import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '@/gateway/loginFlow';
declare global {
interface Window {
getOAuthToken: (mode: string, scope_list: string[]) => void;
@@ -51,9 +52,20 @@ function getToken(): [number, string] | undefined {
return token;
}
function resetToken() {
localStorage.removeItem(LOGIN_TOKEN_KEY);
}
function resetToken() {
localStorage.removeItem(LOGIN_TOKEN_KEY);
}
function showOTPModal() {
const modalEl = unwrap(document.querySelector('#modalOTP'));
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
}
async function tryAutoLogin() {
try {
@@ -93,13 +105,18 @@ async function tryAutoLogin() {
}, true);
if (!loginResult.result) {
if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) {
const failureAction = classifyAutoLoginFailure(loginResult, attempt);
if (failureAction === 'retry') {
console.warn('auto login failed by procedure error. retrying once.');
await delay(150);
continue;
}
if (!loginResult.silent) {
if (failureAction === 'prompt_otp') {
alert(OTP_REQUIRED_MESSAGE);
showOTPModal();
return;
}
if (failureAction === 'alert') {
alert(loginResult.reason);
}
console.error(loginResult.reason);
@@ -208,14 +225,7 @@ async function doLoginUsingOAuth() {
return;
}
const modalEl = unwrap(document.querySelector('#modalOTP'))
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
showOTPModal();
}
function postOAuthResult(mode: string) {
@@ -314,14 +324,7 @@ $(async function ($) {
return;
}
const modalEl = unwrap(document.querySelector('#modalOTP'))
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
showOTPModal();
});
$('#otp_form').on('submit', async function (e) {
@@ -393,4 +396,4 @@ window.fitIframe = function () {
const iframe = unwrap(document.querySelector<HTMLIFrameElement>('#running_map'));//TODO: 근황 여러개 볼 수 있도록?
const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight;
iframe.style.height = `${scrollHeight}px`;
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { AutoLoginFailed } from '@/defs/API/Login';
export const OTP_REQUIRED_MESSAGE = '인증 코드 입력이 필요합니다.';
export type AutoLoginFailureAction = 'retry' | 'prompt_otp' | 'alert' | 'silent';
export function classifyAutoLoginFailure(result: AutoLoginFailed, attempt: number): AutoLoginFailureAction {
if (result.reason === '자동 로그인: 절차 오류' && attempt === 0) {
return 'retry';
}
if (result.reqOTP) {
return 'prompt_otp';
}
if (result.silent) {
return 'silent';
}
return 'alert';
}