From 136acadd0428a632415314183e428c3d836d0122 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 01:55:13 +0000 Subject: [PATCH 1/5] fix: keep scenario war specials in war slot --- hwe/sammo/Scenario/GeneralBuilder.php | 10 +-- tests/ScenarioGeneralBuilderSpecialTest.php | 80 +++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tests/ScenarioGeneralBuilderSpecialTest.php diff --git a/hwe/sammo/Scenario/GeneralBuilder.php b/hwe/sammo/Scenario/GeneralBuilder.php index 31ddb4a1..2ed6e8f7 100644 --- a/hwe/sammo/Scenario/GeneralBuilder.php +++ b/hwe/sammo/Scenario/GeneralBuilder.php @@ -136,13 +136,13 @@ class GeneralBuilder{ $this->specialWar = GameConst::$defaultSpecialWar; } try{ - $this->specialDomestic = SpecialityHelper::getDomesticClassByName($special); - $this->specialWar = GameConst::$defaultSpecialWar; - } - catch (\Exception $e){ $this->specialDomestic = GameConst::$defaultSpecialDomestic; $this->specialWar = SpecialityHelper::getWarClassByName($special); } + catch (\Exception $e){ + $this->specialDomestic = SpecialityHelper::getDomesticClassByName($special); + $this->specialWar = GameConst::$defaultSpecialWar; + } return $this; } @@ -735,4 +735,4 @@ class GeneralBuilder{ return true; //생성되었다. } -} \ No newline at end of file +} diff --git a/tests/ScenarioGeneralBuilderSpecialTest.php b/tests/ScenarioGeneralBuilderSpecialTest.php new file mode 100644 index 00000000..fe37ca8e --- /dev/null +++ b/tests/ScenarioGeneralBuilderSpecialTest.php @@ -0,0 +1,80 @@ +addPsr4('sammo\\', __DIR__ . '/../hwe/sammo', true); + +require_once __DIR__ . '/../hwe/func_converter.php'; +require_once __DIR__ . '/../hwe/sammo/ActionLogger.php'; +require_once __DIR__ . '/../hwe/sammo/GameConstBase.php'; +require_once __DIR__ . '/../hwe/d_setting/GameConst.php'; +require_once __DIR__ . '/../hwe/sammo/Scenario/GeneralBuilder.php'; + +final class ScenarioGeneralBuilderSpecialTest extends TestCase +{ + public function testAsiaPossessionScenarioWarSpecialsUseWarSlot(): void + { + $scenario = json_decode( + file_get_contents(__DIR__ . '/../hwe/scenario/scenario_2702.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $specials = array_values(array_unique(array_filter(array_column($scenario['general'], 12)))); + + self::assertNotEmpty($specials); + foreach ($specials as $special) { + $builder = $this->newBuilder()->setSpecialSingle($special); + + self::assertSame( + GameConst::$defaultSpecialDomestic, + $this->readProperty($builder, 'specialDomestic'), + "{$special} must not occupy the domestic-special slot" + ); + self::assertSame( + "che_{$special}", + $this->readProperty($builder, 'specialWar'), + "{$special} must occupy the war-special slot" + ); + } + } + + public function testScenarioDomesticSpecialStillUsesDomesticSlot(): void + { + $builder = $this->newBuilder()->setSpecialSingle('경작'); + + self::assertSame('che_경작', $this->readProperty($builder, 'specialDomestic')); + self::assertSame(GameConst::$defaultSpecialWar, $this->readProperty($builder, 'specialWar')); + } + + public function testCentennialEventWarSpecialCanStillBeAssignedToDomesticSlotExplicitly(): void + { + $builder = $this->newBuilder()->setSpecial('che_event_위압', GameConst::$defaultSpecialWar); + + self::assertSame('che_event_위압', $this->readProperty($builder, 'specialDomestic')); + self::assertSame(GameConst::$defaultSpecialWar, $this->readProperty($builder, 'specialWar')); + } + + private function newBuilder(): GeneralBuilder + { + return new GeneralBuilder( + new RandUtil(new LiteHashDRBG(Util::simpleSerialize(self::class))), + '특기 슬롯 검사', + false, + null, + 0 + ); + } + + private function readProperty(GeneralBuilder $builder, string $property): mixed + { + $reflection = new ReflectionProperty($builder, $property); + return $reflection->getValue($builder); + } +} From 5d9512ced77b1ce32850eb249de0698f41c40d68 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 3 Aug 2026 05:13:47 +0000 Subject: [PATCH 2/5] feat: replace game schedules with logical ticks --- hwe/_119.php | 7 +- hwe/_119_b.php | 45 +-- hwe/_admin1.php | 4 +- hwe/_admin1_submit.php | 9 +- hwe/_admin2_submit.php | 2 +- hwe/_admin7.php | 5 +- hwe/b_currentCity.php | 4 +- hwe/b_genList.php | 4 +- hwe/b_myPage.php | 20 +- hwe/c_tournament.php | 6 +- hwe/func.php | 212 ++++++----- hwe/func_auction.php | 7 +- hwe/func_gamerule.php | 20 +- hwe/func_legacy.php | 4 + hwe/func_template.php | 8 +- hwe/func_tournament.php | 19 +- hwe/j_get_select_npc_token.php | 42 +-- hwe/j_get_select_pool.php | 14 +- hwe/j_install.php | 4 +- hwe/j_select_npc.php | 6 +- hwe/j_select_picked_general.php | 6 +- hwe/j_server_basic_info.php | 7 +- hwe/j_simulate_battle.php | 3 +- hwe/j_update_picked_general.php | 6 +- hwe/proc.php | 6 +- hwe/sammo/API/Auction/BidUniqueAuction.php | 4 +- .../Auction/GetActiveResourceAuctionList.php | 9 +- .../Auction/GetUniqueItemAuctionDetail.php | 9 +- .../API/Auction/GetUniqueItemAuctionList.php | 11 +- hwe/sammo/API/Command/GetReservedCommand.php | 7 +- hwe/sammo/API/General/DieOnPrestart.php | 13 +- hwe/sammo/API/General/GetFrontInfo.php | 20 +- hwe/sammo/API/General/Join.php | 17 +- hwe/sammo/API/Global/ExecuteEngine.php | 6 +- hwe/sammo/API/Global/GeneralList.php | 4 +- hwe/sammo/API/InheritAction/ResetTurnTime.php | 4 +- hwe/sammo/API/Nation/GeneralList.php | 8 +- .../API/NationCommand/GetReservedCommand.php | 4 +- hwe/sammo/AbsFromUserPool.php | 10 +- hwe/sammo/AbsGeneralPool.php | 6 +- hwe/sammo/Auction.php | 109 +++--- hwe/sammo/AuctionBasicResource.php | 16 +- hwe/sammo/AuctionUniqueItem.php | 53 +-- hwe/sammo/Command/Nation/che_발령.php | 2 +- hwe/sammo/Command/Nation/che_천도.php | 2 +- hwe/sammo/DTO/AuctionInfo.php | 12 +- hwe/sammo/DTO/AuctionInfoDetail.php | 5 +- hwe/sammo/DTO/GeneralAccessLog.php | 7 +- hwe/sammo/DiplomaticMessage.php | 4 +- hwe/sammo/General.php | 17 +- hwe/sammo/GeneralAI.php | 27 +- hwe/sammo/GeneralBase.php | 43 ++- hwe/sammo/GeneralPool/RandomNameGeneral.php | 15 +- hwe/sammo/GeneralPool/SPoolUnderU100.php | 2 +- hwe/sammo/Message.php | 47 ++- hwe/sammo/ResetHelper.php | 50 ++- hwe/sammo/Scenario/GeneralBuilder.php | 2 +- hwe/sammo/ScoutMessage.php | 6 +- hwe/sammo/ServerTool.php | 30 +- hwe/sammo/TurnExecutionHelper.php | 22 +- hwe/sammo/WarUnitGeneral.php | 10 +- hwe/sql/schema.sql | 24 +- scripts/README-game-clock.md | 41 +++ scripts/game-clock.php | 129 +++++++ scripts/migrate-game-clock.php | 340 ++++++++++++++++++ scripts/verify-game-clock-engine.php | 76 ++++ src/sammo/GameClock.php | 284 +++++++++++++++ tests/GameClockBoundaryTest.php | 59 +++ tests/GameClockTest.php | 99 +++++ 69 files changed, 1616 insertions(+), 519 deletions(-) create mode 100644 scripts/README-game-clock.md create mode 100644 scripts/game-clock.php create mode 100644 scripts/migrate-game-clock.php create mode 100644 scripts/verify-game-clock-engine.php create mode 100644 src/sammo/GameClock.php create mode 100644 tests/GameClockBoundaryTest.php create mode 100644 tests/GameClockTest.php diff --git a/hwe/_119.php b/hwe/_119.php index 576e1b1c..65de2e5a 100644 --- a/hwe/_119.php +++ b/hwe/_119.php @@ -13,6 +13,9 @@ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); [$turntime, $tnmt_time] = $gameStor->getValuesAsArray(['turntime','tnmt_time']); +$clock = GameClock::fromStorage($gameStor); +$turntimeDisplay = $clock->formatTick(Util::toInt($turntime), true); +$tnmtTimeDisplay = $tnmt_time === null ? '-' : $clock->formatTick(Util::toInt($tnmt_time), true); $plock = $db->queryFirstField('SELECT plock FROM plock WHERE `type` ="GAME"'); ?> @@ -30,8 +33,8 @@ $plock = $db->queryFirstField('SELECT plock FROM plock WHERE `type` ="GAME"');
- 시간조정 : 최종갱신 :
- 시간조정 : 토너먼트 :
+ 시간조정 : 최종갱신 : (tick )
+ 시간조정 : 토너먼트 : (tick )
봉급지급 :
락 풀 기 : 현재 : 0?"동결중":"가동중"?>
diff --git a/hwe/_119_b.php b/hwe/_119_b.php index 84158b73..18472c12 100644 --- a/hwe/_119_b.php +++ b/hwe/_119_b.php @@ -38,21 +38,9 @@ switch ($btn) { usleep(500000); } - $gameStor->cacheValues(['turntime', 'starttime', 'tnmt_time']); - $turntime = (new \DateTimeImmutable($gameStor->turntime))->sub(new \DateInterval("PT{$minute}M")); - $starttime = (new \DateTimeImmutable($gameStor->starttime))->sub(new \DateInterval("PT{$minute}M")); - $tnmt_time = (new \DateTimeImmutable($gameStor->tnmt_time))->sub(new \DateInterval("PT{$minute}M")); - - $gameStor->turntime = $turntime->format('Y-m-d H:i:s.u'); - $gameStor->starttime = $starttime->format('Y-m-d H:i:s'); - $gameStor->tnmt_time = $tnmt_time->format('Y-m-d H:i:s'); - - $db->update('general', [ - 'turntime' => $db->sqleval('DATE_SUB(turntime, INTERVAL %i MINUTE)', $minute) - ], true); - $db->update('ng_auction', [ - 'close_date' => $db->sqleval('DATE_SUB(close_date, INTERVAL %i MINUTE)', $minute) - ], 'finished = 0'); + $clock = GameClock::fromStorage($gameStor); + // 스케줄 전체를 벽시계에서 빼지 않고 논리 현재 tick만 앞으로 이동합니다. + $clock->advance($gameStor, $clock->ticksFromMinutes($minute)); if ($locked) { unlock(); } @@ -66,34 +54,19 @@ switch ($btn) { } usleep(500000); } - $gameStor->cacheValues(['turntime', 'starttime', 'tnmt_time']); - $turntime = (new \DateTimeImmutable($gameStor->turntime))->add(new \DateInterval("PT{$minute}M")); - $starttime = (new \DateTimeImmutable($gameStor->starttime))->add(new \DateInterval("PT{$minute}M")); - $tnmt_time = (new \DateTimeImmutable($gameStor->tnmt_time))->add(new \DateInterval("PT{$minute}M")); - - $gameStor->turntime = $turntime->format('Y-m-d H:i:s.u'); - $gameStor->starttime = $starttime->format('Y-m-d H:i:s'); - $gameStor->tnmt_time = $tnmt_time->format('Y-m-d H:i:s'); - - $db->update('general', [ - 'turntime' => $db->sqleval('DATE_ADD(turntime, INTERVAL %i MINUTE)', $minute) - ], true); - $db->update('ng_auction', [ - 'close_date' => $db->sqleval('DATE_ADD(close_date, INTERVAL %i MINUTE)', $minute) - ], 'finished = 0'); + $clock = GameClock::fromStorage($gameStor); + $clock->advance($gameStor, -$clock->ticksFromMinutes($minute)); if ($locked) { unlock(); } break; case "토너분당김": - $tnmt_time = new \DateTime($gameStor->tnmt_time); - $tnmt_time->sub(new \DateInterval("PT{$minute2}M")); - $gameStor->tnmt_time = $tnmt_time->format('Y-m-d H:i:s'); + $clock = GameClock::fromStorage($gameStor); + $gameStor->tnmt_time = Util::toInt($gameStor->tnmt_time) - $clock->ticksFromMinutes($minute2); break; case "토너분지연": - $tnmt_time = new \DateTimeImmutable($gameStor->tnmt_time); - $tnmt_time->add(new \DateInterval("PT{$minute2}M")); - $gameStor->tnmt_time = $tnmt_time->format('Y-m-d H:i:s'); + $clock = GameClock::fromStorage($gameStor); + $gameStor->tnmt_time = Util::toInt($gameStor->tnmt_time) + $clock->ticksFromMinutes($minute2); break; case "금지급": processGoldIncome(); diff --git a/hwe/_admin1.php b/hwe/_admin1.php index 069a5ab9..e2a80b8a 100644 --- a/hwe/_admin1.php +++ b/hwe/_admin1.php @@ -40,7 +40,7 @@ $admin = getAdmin(); 시작시간변경 - '> + '> 최대 장수 @@ -52,7 +52,7 @@ $admin = getAdmin(); 시작 년도 '> 최근 갱신 시간 -   +   (tick ) 턴시간 diff --git a/hwe/_admin1_submit.php b/hwe/_admin1_submit.php index 48e66e6a..51827d42 100644 --- a/hwe/_admin1_submit.php +++ b/hwe/_admin1_submit.php @@ -43,7 +43,12 @@ switch ($btn) { pushGlobalHistoryLog(["{$log}"]); break; case "변경1": - $gameStor->starttime = (new \DateTime($starttime))->format('Y-m-d H:i:s'); + $clock = GameClock::fromStorage($gameStor); + $gameStor->clock_base_time = TimeUtil::format(GameClock::baseTimeForProjection( + new \DateTimeImmutable($starttime), + Util::toInt($gameStor->starttime), + $clock->getTurnTermMinutes(), + ), true); break; case "변경2": $gameStor->maxgeneral = $maxgeneral; @@ -78,4 +83,4 @@ switch ($btn) { break; } -header('location:_admin1.php'); \ No newline at end of file +header('location:_admin1.php'); diff --git a/hwe/_admin2_submit.php b/hwe/_admin2_submit.php index 54d63b35..e3ad0652 100644 --- a/hwe/_admin2_submit.php +++ b/hwe/_admin2_submit.php @@ -99,7 +99,7 @@ switch ($btn) { ], '`no` IN %li', $genlist); break; case "강제 사망": - $date = TimeUtil::now(true); + $date = GameClock::fromStorage($gameStor)->nowTick(); $db->update('general', [ 'killturn' => 0, 'turntime' => $date, diff --git a/hwe/_admin7.php b/hwe/_admin7.php index 8623f9c2..c335ee49 100644 --- a/hwe/_admin7.php +++ b/hwe/_admin7.php @@ -42,6 +42,7 @@ if ($session->userGrade < 6) { } $db = DB::db(); +$clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); if ($btn == '정렬하기') { $gen = 0; @@ -111,7 +112,7 @@ $generalObj = General::createObjFromDB($gen, null, GeneralQueryMode::FullWithAcc 대상장수 : @@ -179,4 +180,4 @@ $generalObj = General::createObjFromDB($gen, null, GeneralQueryMode::FullWithAcc - \ No newline at end of file + diff --git a/hwe/b_currentCity.php b/hwe/b_currentCity.php index 8df28d80..36615d43 100644 --- a/hwe/b_currentCity.php +++ b/hwe/b_currentCity.php @@ -16,7 +16,7 @@ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); increaseRefresh("현재도시", 1); -$lastExecute = substr($gameStor->turntime, 5, 14); +$lastExecute = substr(GameClock::fromStorage($gameStor)->formatTick(Util::toInt($gameStor->turntime)), 5, 14); $me = $db->queryFirstRow('SELECT no,nation,officer_level,city from general where owner=%i', $userID); $myNation = $db->queryFirstRow('SELECT nation,level,spy FROM nation WHERE nation=%i', $me['nation']) ?? [ @@ -537,4 +537,4 @@ $templates = new \League\Plates\Engine('templates'); - \ No newline at end of file + diff --git a/hwe/b_genList.php b/hwe/b_genList.php index 0cbae607..59edbd71 100644 --- a/hwe/b_genList.php +++ b/hwe/b_genList.php @@ -129,6 +129,7 @@ $templates = new \League\Plates\Engine('templates'); ); $generalTurnList = []; + $clock = GameClock::fromStorage($gameStor); foreach ($db->queryAllLists( 'SELECT general_id, turn_idx, brief FROM general_turn WHERE general_id IN %li AND turn_idx < 5 ORDER BY general_id ASC, turn_idx ASC', @@ -142,6 +143,7 @@ $templates = new \League\Plates\Engine('templates'); $genCntEff = 0; foreach ($generals as &$general) { + $general['turntime'] = $clock->formatTick(Util::toInt($general['turntime']), true); $general['cityText'] = CityConst::byID($general['city'])->name; $general['troopText'] = $troopName[$general['troop']] ?? '-'; @@ -284,4 +286,4 @@ $templates = new \League\Plates\Engine('templates'); - \ No newline at end of file + diff --git a/hwe/b_myPage.php b/hwe/b_myPage.php index ea3d488d..bbe8a708 100644 --- a/hwe/b_myPage.php +++ b/hwe/b_myPage.php @@ -19,8 +19,10 @@ $userID = Session::getUserID(); $generalID = $session->generalID; $db = DB::db(); -$gameStor = KVStorage::getStorage($db, 'game_env'); -$gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']); +$gameStor = KVStorage::getStorage($db, 'game_env'); +$gameStor->cacheValues(['turntime', 'opentime', 'autorun_user', 'npcmode']); +$clock = GameClock::fromStorage($gameStor); +$nowTick = $clock->nowTick(); $me = General::createObjFromDB($generalID, null, GeneralQueryMode::FullWithAccessLog); @@ -40,27 +42,29 @@ $lastRefresh = $db->queryFirstField( ); $nextChange = $me->getAuxVar('next_change'); -if (!is_string($nextChange) || $nextChange === '') { +if (!is_int($nextChange)) { $nextChange = null; } +$nextChangeDisplay = $nextChange === null ? null : $clock->formatTick($nextChange); increaseRefresh("내정보", 1); if ($gameStor->turntime <= $gameStor->opentime) { $targetTime = $me->getAuxVar('prestart_delete_after'); - if (!is_string($targetTime) || $targetTime === '') { + if (!is_int($targetTime)) { $targetTime = addTurn( - $lastRefresh ?: TimeUtil::now(), + $lastRefresh === null ? $nowTick : Util::toInt($lastRefresh), $gameStor->turnterm, GameConst::$minTurnDieOnPrestart ); $me->setAuxVar('prestart_delete_after', $targetTime); $me->applyDB($db); } + $targetTimeDisplay = $clock->formatTick($targetTime); //서버 가오픈시 할 수 있는 행동 if ($me->getNPCType() == 0 && $me->getNationID() == 0) { $showDieOnPrestartBtn = true; - if ($targetTime <= TimeUtil::now()) { + if ($targetTime <= $nowTick) { $availableDieOnPrestart = true; } } @@ -173,7 +177,7 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $ --> - 가오픈 기간 내 장수 삭제 ( 부터)
+ 가오픈 기간 내 장수 삭제 ( 부터)


@@ -188,7 +192,7 @@ $changeDefence999Atmos = $me->onCalcDomestic('changeDefenceTrain', "atmos999", $ npcmode == 2 && $me->getNPCType() == 0) : ?> - 다른 장수 선택 (부터)
+ 다른 장수 선택 (부터)


diff --git a/hwe/c_tournament.php b/hwe/c_tournament.php index 9538f778..2c9f8f0e 100644 --- a/hwe/c_tournament.php +++ b/hwe/c_tournament.php @@ -228,10 +228,10 @@ if($btn == "자동개최설정") { $phase = 0; startBetting($admin['tnmt_type'], 720); } elseif($btn == "베팅마감") { - $dt = date("Y-m-d H:i:s", time() + 60); + $clock = GameClock::fromStorage($gameStor); $gameStor->tournament=7; $gameStor->phase=0; - $gameStor->tnmt_time = $dt; + $gameStor->tnmt_time = $clock->nowTick() + $clock->ticksFromSeconds(60); } elseif($btn == "16강") { finalFight($admin['tnmt_type'], $admin['tournament'], $admin['phase'], 16); } elseif($btn == "8강") { @@ -248,4 +248,4 @@ if($btn == "자동개최설정") { $gameStor->tnmt_msg = $msg; } -header('location:b_tournament.php'); \ No newline at end of file +header('location:b_tournament.php'); diff --git a/hwe/func.php b/hwe/func.php index 026c4b69..d72cad9b 100644 --- a/hwe/func.php +++ b/hwe/func.php @@ -630,7 +630,11 @@ function generalInfo(General $generalObj) $injury = "건강"; } - $remaining = (new \DateTimeImmutable($generalObj->getTurnTime()))->diff(new \DateTimeImmutable())->i; + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + $remaining = max(0, intdiv( + $generalObj->getTurnTick() - $clock->nowTick(), + $clock->ticksFromMinutes(1), + )); if ($nation['color'] == "") { $nation['color'] = "#000000"; @@ -921,45 +925,37 @@ function banner() ); } -function addTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true) -{ - $date = new \DateTime($date); - $target = $turnterm * $turn; - $date->add(new \DateInterval("PT{$target}M")); - if ($withFraction) { - return $date->format('Y-m-d H:i:s.u'); - } - return $date->format('Y-m-d H:i:s'); -} - -function subTurn($date, int $turnterm, int $turn = 1, bool $withFraction = true) -{ - $date = new \DateTime($date); - $target = $turnterm * $turn; - $date->sub(new \DateInterval("PT{$target}M")); - if ($withFraction) { - return $date->format('Y-m-d H:i:s.u'); - } - return $date->format('Y-m-d H:i:s'); -} - -function cutTurn($date, int $turnterm, bool $withFraction = true) -{ - $date = new \DateTime($date); - - $baseDate = new \DateTime($date->format('Y-m-d')); - $baseDate->sub(new \DateInterval("P1D")); - $baseDate->add(new \DateInterval("PT1H")); - - $diffMin = intdiv($date->getTimeStamp() - $baseDate->getTimeStamp(), 60); - $diffMin -= $diffMin % $turnterm; - - $baseDate->add(new \DateInterval("PT{$diffMin}M")); - if ($withFraction) { - return $baseDate->format('Y-m-d H:i:s.u'); - } - return $baseDate->format('Y-m-d H:i:s'); -} +function addTurn(int $tick, int $turnterm, int $turn = 1, bool $withFraction = true): int +{ + return $tick + GameClock::TICKS_PER_TURN * $turn; +} + +function subTurn(int $tick, int $turnterm, int $turn = 1, bool $withFraction = true): int +{ + return $tick - GameClock::TICKS_PER_TURN * $turn; +} + +function cutTurn(int $tick, int $turnterm, bool $withFraction = true): int +{ + $remainder = $tick % GameClock::TICKS_PER_TURN; + if ($remainder < 0) { + $remainder += GameClock::TICKS_PER_TURN; + } + return $tick - $remainder; +} + +/** 시나리오 초기화 입력인 벽시계를 기존 01:00 기준 월 경계로 정렬합니다. */ +function cutTurnDateTime(string $date, int $turnterm, bool $withFraction = true): string +{ + $dateObj = new \DateTime($date); + $baseDate = new \DateTime($dateObj->format('Y-m-d')); + $baseDate->sub(new \DateInterval('P1D')); + $baseDate->add(new \DateInterval('PT1H')); + $diffMin = intdiv($dateObj->getTimestamp() - $baseDate->getTimestamp(), 60); + $diffMin -= $diffMin % $turnterm; + $baseDate->add(new \DateInterval("PT{$diffMin}M")); + return $baseDate->format($withFraction ? 'Y-m-d H:i:s.u' : 'Y-m-d H:i:s'); +} function cutDay($date, int $turnterm, bool $withFraction = true) { @@ -1001,11 +997,9 @@ function increaseRefresh($type = "", $cnt = 1) $generalID = $session->generalID; $userGrade = $session->userGrade; - $dateObj = new \DateTimeImmutable(); - $date = TimeUtil::format($dateObj, false); - - $db = DB::db(); - $gameStor = KVStorage::getStorage($db, 'game_env'); + $db = DB::db(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $date = GameClock::fromStorage($gameStor)->nowTick(); $isunited = $gameStor->isunited; $opentime = $gameStor->opentime; @@ -1145,18 +1139,19 @@ function unlock(): bool return $db->affectedRows() > 0; } -function timeover(): bool +function timeover(): bool { $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); - list($turnterm, $turntime) = $gameStor->getValuesAsArray(['turnterm', 'turntime']); - $diff = (new \DateTime())->getTimestamp() - (new \DateTime($turntime))->getTimestamp(); + list($turnterm, $turntime) = $gameStor->getValuesAsArray(['turnterm', 'turntime']); + $clock = GameClock::fromStorage($gameStor); + $diff = $clock->nowTick() - Util::toInt($turntime); $t = min($turnterm, 5); - $term = $diff; - if ($term >= $t || $term < 0) { + $term = $clock->ticksFromSeconds($t); + if ($diff >= $term || $diff < 0) { return true; } else { return false; @@ -1169,9 +1164,8 @@ function checkDelay() $gameStor = KVStorage::getStorage($db, 'game_env'); //서버정보 - $now = new \DateTimeImmutable(); - $turntime = new \DateTimeImmutable($gameStor->turntime); - $timeMinDiff = intdiv($now->getTimestamp() - $turntime->getTimestamp(), 60); + $clock = GameClock::fromStorage($gameStor); + $timeMinDiff = intdiv($clock->nowTick() - Util::toInt($gameStor->turntime), $clock->ticksFromMinutes(1)); // 1턴이상 갱신 없었으면 서버 지연 $term = $gameStor->turnterm; @@ -1185,20 +1179,17 @@ function checkDelay() //지연 해야할 밀린 턴 횟수 $iter = intdiv($timeMinDiff, $term); if ($iter > $threshold) { - $minute = $iter * $term; - $newTurntime = $turntime->add(new \DateInterval("PT{$minute}M")); - $newNextTurntime = $turntime->add(new \DateInterval("PT{$term}M")); - $gameStor->turntime = $newTurntime->format('Y-m-d H:i:s'); - $gameStor->starttime = (new \DateTimeImmutable($gameStor->starttime)) - ->add(new \DateInterval("PT{$minute}M")) - ->format('Y-m-d H:i:s'); - - $db->update('general', [ - 'turntime' => $db->sqleval('DATE_ADD(turntime, INTERVAL %i MINUTE)', $minute) - ], 'turntime<=DATE_ADD(turntime, INTERVAL %i MINUTE)', $term); - $db->update('ng_auction', [ - 'close_date' => $db->sqleval('DATE_ADD(close_date, INTERVAL %i MINUTE)', $minute) - ], 'finished = 0'); + $minute = $iter * $term; + $delayTick = $clock->ticksFromMinutes($minute); + $gameStor->turntime = Util::toInt($gameStor->turntime) + $delayTick; + $gameStor->starttime = Util::toInt($gameStor->starttime) + $delayTick; + + $db->update('general', [ + 'turntime' => $db->sqleval('turntime + %i', $delayTick) + ], true); + $db->update('ng_auction', [ + 'close_tick' => $db->sqleval('close_tick + %i', $delayTick) + ], 'finished = 0'); } } @@ -1247,17 +1238,15 @@ function updateOnline() $gameStor->online_nation = join(', ', $onlineNation); } -function turnDate($curtime) +function turnDate($curtime) { $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $admin = $gameStor->getValues(['startyear', 'starttime', 'turnterm', 'year', 'month']); $turn = $admin['starttime']; - $curturn = cutTurn($curtime, $admin['turnterm']); - $term = $admin['turnterm']; - - $num = intdiv((strtotime($curturn) - strtotime($turn)), $term * 60); + $curturn = cutTurn(Util::toInt($curtime), $admin['turnterm']); + $num = intdiv($curturn - Util::toInt($turn), GameClock::TICKS_PER_TURN); $date = $admin['startyear'] * 12; $date += $num; @@ -1705,12 +1694,21 @@ function tryUniqueItemLottery(RandUtil $rng, General $general, string $acquireTy return giveRandomUniqueItem($rng, $general, $acquireType); } -function getAdmin() -{ - $db = DB::db(); - $gameStor = KVStorage::getStorage($db, 'game_env'); - return $gameStor->getAll(); -} +function getAdmin() +{ + $db = DB::db(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $admin = $gameStor->getAll(); + $clock = GameClock::fromStorage($gameStor); + foreach (['turntime', 'starttime', 'opentime', 'tnmt_time'] as $key) { + if (($admin[$key] ?? null) !== null) { + $admin["{$key}_display"] = $clock->formatTick(Util::toInt($admin[$key]), true); + } + } + $admin['clock_now_tick'] = $clock->nowTick(); + $admin['clock_now_display'] = $clock->formatTick($admin['clock_now_tick'], true); + return $admin; +} /** @return General[] */ function deleteNation(General $lord, bool $applyDB): array @@ -2199,35 +2197,29 @@ function SabotageInjury(RandUtil $rng, array $cityGeneralList, string $reason): return $injuryCount; } -function getRandTurn(RandUtil $rng, $term, ?\DateTimeInterface $baseDateTime = null) -{ - if ($baseDateTime === null) { - $baseDateTime = new \DateTimeImmutable(); - } else if ($baseDateTime instanceof \DateTime) { - $baseDateTime = \DateTimeImmutable::createFromMutable($baseDateTime); - } else if ($baseDateTime instanceof \DateTimeImmutable) { - //do Nothing - } else { - throw new MustNotBeReachedException(); - } - - $randSecond = $rng->nextRangeInt(0, 60 * $term - 1); - $randFraction = $rng->nextRangeInt(0, 999999) / 1000000; //6자리 소수 - - return TimeUtil::format($baseDateTime->add(TimeUtil::secondsToDateInterval($randSecond + $randFraction)), true); -} - -function getRandTurn2(RandUtil $rng, $term, ?\DateTimeInterface $baseDateTime = null) -{ - if ($baseDateTime === null) { - $baseDateTime = new \DateTimeImmutable(); - } else if ($baseDateTime instanceof \DateTime) { - $baseDateTime = \DateTimeImmutable::createFromMutable($baseDateTime); - } else { - throw new MustNotBeReachedException(); - } - $randSecond = $rng->nextRangeInt(0, 60 * $term - 1); - $randFraction = $rng->nextRangeInt(0, 999999) / 1000000; //6자리 소수 - - return $baseDateTime->sub(TimeUtil::secondsToDateInterval($randSecond + $randFraction))->format('Y-m-d H:i:s.u'); -} +function getRandTurn(RandUtil $rng, int $term, ?int $baseTick = null): int +{ + $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $baseTick ??= $clock->nowTick(); + + $randSecond = $rng->nextRangeInt(0, 60 * $term - 1); + $randMicrosecond = $rng->nextRangeInt(0, 999999); // 레거시 RNG 소비 6자리 유지 + + return $baseTick + + $clock->ticksFromSeconds($randSecond) + + intdiv($randMicrosecond * $clock->ticksPerSecond(), 1_000_000); +} + +function getRandTurn2(RandUtil $rng, int $term, ?int $baseTick = null): int +{ + $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $baseTick ??= $clock->nowTick(); + $randSecond = $rng->nextRangeInt(0, 60 * $term - 1); + $randMicrosecond = $rng->nextRangeInt(0, 999999); // 레거시 RNG 소비 6자리 유지 + + return $baseTick + - $clock->ticksFromSeconds($randSecond) + - intdiv($randMicrosecond * $clock->ticksPerSecond(), 1_000_000); +} diff --git a/hwe/func_auction.php b/hwe/func_auction.php index c5196952..8064122c 100644 --- a/hwe/func_auction.php +++ b/hwe/func_auction.php @@ -62,11 +62,12 @@ function processAuction() { $db = DB::db(); - $now = TimeUtil::now(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $nowTick = GameClock::fromStorage($gameStor)->nowTick(); $auctionList = $db->queryAllLists( - 'SELECT id, `type` FROM ng_auction WHERE `close_date` <= %s AND finished = 0', - $now + 'SELECT id, `type` FROM ng_auction WHERE `close_tick` <= %i AND finished = 0', + $nowTick ); if (!$auctionList) { diff --git a/hwe/func_gamerule.php b/hwe/func_gamerule.php index 56883c7f..707d3406 100644 --- a/hwe/func_gamerule.php +++ b/hwe/func_gamerule.php @@ -651,16 +651,24 @@ function checkStatistic() } -function convForOldGeneral(array $general, int $year, int $month) -{ - $general['history'] = getGeneralHistoryLogAll($general['no']); - return [ +function convForOldGeneral(array $general, int $year, int $month) +{ + $general['history'] = getGeneralHistoryLogAll($general['no']); + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + $turnTimeDisplay = $clock->formatTick(Util::toInt($general['turntime']), true); + $general['turntime_tick'] = Util::toInt($general['turntime']); + $general['turntime'] = $turnTimeDisplay; + if ($general['recent_war'] !== null) { + $general['recent_war_tick'] = Util::toInt($general['recent_war']); + $general['recent_war'] = $clock->formatTick(Util::toInt($general['recent_war']), true); + } + return [ 'server_id' => UniqueConst::$serverID, 'general_no' => $general['no'], 'owner' => $general['owner'], 'name' => $general['name'], 'last_yearmonth' => $year * 100 + $month, - 'turntime' => $general['turntime'], + 'turntime' => $turnTimeDisplay, 'data' => Json::encode($general) ]; } @@ -734,7 +742,7 @@ function checkEmperior() /** @var int[] */ $auctionList = $db->queryFirstColumn( - 'SELECT `id` FROM `ng_auction` WHERE `type` = %s AND `finished` = 0 ORDER BY `close_date` ASC', + 'SELECT `id` FROM `ng_auction` WHERE `type` = %s AND `finished` = 0 ORDER BY `close_tick` ASC', AuctionType::UniqueItem->value ); foreach($auctionList as $auctionID){ diff --git a/hwe/func_legacy.php b/hwe/func_legacy.php index deaf55c3..d7f5ff61 100644 --- a/hwe/func_legacy.php +++ b/hwe/func_legacy.php @@ -4,6 +4,10 @@ namespace sammo; function printLimitMsg($turntime) { + if (is_int($turntime) || (is_string($turntime) && ctype_digit(ltrim($turntime, '-')))) { + $gameStor = KVStorage::getStorage(DB::db(), 'game_env'); + $turntime = GameClock::fromStorage($gameStor)->formatTick(Util::toInt($turntime), true); + } //FIXME: template로 이동. ?> diff --git a/hwe/func_template.php b/hwe/func_template.php index dcb3dacb..b4e02b58 100644 --- a/hwe/func_template.php +++ b/hwe/func_template.php @@ -61,8 +61,12 @@ function chiefTurnTable() "; } -function templateLimitMsg(string $turntime): string -{ +function templateLimitMsg(string $turntime): string +{ + if (ctype_digit(ltrim($turntime, '-'))) { + $gameStor = KVStorage::getStorage(DB::db(), 'game_env'); + $turntime = GameClock::fromStorage($gameStor)->formatTick(Util::toInt($turntime), true); + } return "이미 너무 많은 접속을 하셨습니다. 다음 턴에 다시 시도해주세요. (턴시간: {$turntime})"; } diff --git a/hwe/func_tournament.php b/hwe/func_tournament.php index c8046516..4de5c973 100644 --- a/hwe/func_tournament.php +++ b/hwe/func_tournament.php @@ -17,10 +17,10 @@ function processTournament() { $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); $admin = $gameStor->getValues(['tournament', 'phase', 'tnmt_type', 'tnmt_auto', 'tnmt_time', 'turnterm', 'last_tournament_betting_id']); - $now = new \DateTime(); - $offset = $now->getTimestamp() - (new \DateTime($admin['tnmt_time']))->getTimestamp(); + $offset = intdiv($clock->nowTick() - Util::toInt($admin['tnmt_time']), $clock->ticksPerSecond()); //수동일땐 무시 if (!$admin['tnmt_auto']) { @@ -122,10 +122,10 @@ function processTournament() if ($tnmt == 6) { $betTerm = Util::valueFit($unit * 60, null, 3600); //처리 초 더한 날짜 - $dt = date("Y-m-d H:i:s", strtotime($admin['tnmt_time']) + $unit * $i + $betTerm); $gameStor->tournament = $tnmt; $gameStor->phase = $phase; - $gameStor->tnmt_time = $dt; + $gameStor->tnmt_time = Util::toInt($admin['tnmt_time']) + + $clock->ticksFromSeconds($unit * $i + $betTerm); return; } } @@ -133,7 +133,7 @@ function processTournament() $second = $unit * $iter; $gameStor->tournament = $tnmt; $gameStor->phase = $phase; - $gameStor->tnmt_time = (new \DateTimeImmutable($admin['tnmt_time']))->add(new \DateInterval("PT{$second}S"))->format('Y-m-d H:i:s'); + $gameStor->tnmt_time = Util::toInt($admin['tnmt_time']) + $clock->ticksFromSeconds($second); } function getTournamentTermText(int $turnTerm) @@ -160,7 +160,8 @@ function getTournamentTime() $gameStor = KVStorage::getStorage($db, 'game_env'); list($tnmt, $tnmt_time) = $gameStor->getValuesAsArray(['tournament', 'tnmt_time']); - $dt = substr($tnmt_time, 11, 5); + $clock = GameClock::fromStorage($gameStor); + $dt = substr($clock->formatTick(Util::toInt($tnmt_time)), 11, 5); switch ($tnmt) { case 1: $tnmt = "개막시간 {$dt}"; @@ -284,9 +285,11 @@ function startTournament($type) $admin = $gameStor->getValues(['year', 'month', 'turnterm']); $turnTerm = $admin['turnterm']; $unit = calcTournamentTerm($turnTerm); + $clock = GameClock::fromStorage($gameStor); $gameStor->tnmt_auto = true; - $gameStor->tnmt_time = (new \DateTimeImmutable())->add(new \DateInterval("PT{$unit}M"))->format('Y-m-d H:i:s'); + // 기존 startTournament은 unit을 분으로 더하므로 그 계약을 유지합니다. + $gameStor->tnmt_time = $clock->nowTick() + $clock->ticksFromMinutes($unit); $gameStor->tournament = 1; $gameStor->tnmt_type = $type; $gameStor->last_tournament_betting_id = 0; @@ -1171,7 +1174,7 @@ function fight($tnmt_type, $tnmt, $phs, $group, $g1, $g2, $type) } $damage1 *= $factor1; $damage2 *= $factor2; - + //1합 승부 if ($phase == 1) { diff --git a/hwe/j_get_select_npc_token.php b/hwe/j_get_select_npc_token.php index 0227114d..ccbcd081 100644 --- a/hwe/j_get_select_npc_token.php +++ b/hwe/j_get_select_npc_token.php @@ -16,12 +16,10 @@ $session = Session::requireLogin([])->setReadOnly(); $userID = Session::getUserID(); -$oNow = new \DateTimeImmutable(); - -$now = $oNow->format('Y-m-d H:i:s'); - $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); +$clock = GameClock::fromStorage($gameStor); +$now = $clock->nowTick(); $oldGeneral = $db->queryFirstField('SELECT `no` FROM general WHERE `owner`=%i', $userID); if($oldGeneral !== null){ @@ -44,15 +42,14 @@ if($npcmode!=1){ ]); } -$token = $db->queryFirstRow('SELECT * FROM select_npc_token WHERE `owner`=%i AND `valid_until`>=%s', $userID, $now); +$token = $db->queryFirstRow('SELECT * FROM select_npc_token WHERE `owner`=%i AND `valid_until`>=%i', $userID, $now); $pickResult = []; if($token && $refresh){ - $pickMoreFrom = (new \DateTime($token['pick_more_from']))->getTimestamp(); - $nowT = $oNow->getTimestamp(); + $pickMoreFrom = Util::toInt($token['pick_more_from']); - if($nowT >= $pickMoreFrom){ + if($now >= $pickMoreFrom){ $oldPickResult = Json::decode($token['pick_result']); foreach($keepResult as $keepId){ @@ -75,15 +72,14 @@ if($token && $refresh){ } if($token && !$refresh){ - $pickMoreFrom = (new \DateTime($token['pick_more_from']))->getTimestamp(); - $nowT = $oNow->getTimestamp(); + $pickMoreFrom = Util::toInt($token['pick_more_from']); Json::die([ 'result'=>true, 'pick'=>Json::decode($token['pick_result']), - 'pickMoreFrom'=>$token['pick_more_from'], - 'pickMoreSeconds'=>$pickMoreFrom-$nowT, - 'validUntil'=>$token['valid_until'] + 'pickMoreFrom'=>$clock->formatTick($pickMoreFrom), + 'pickMoreSeconds'=>intdiv($pickMoreFrom - $now, $clock->ticksPerSecond()), + 'validUntil'=>$clock->formatTick(Util::toInt($token['valid_until'])) ]); } @@ -100,7 +96,7 @@ foreach($db->query('SELECT `no`, `name`, leadership, strength, intel, nation, im $weight[$general['no']] = pow($allStat, 1.5); } -foreach($db->queryFirstColumn('SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%s', $userID, $now) as $reserved){ +foreach($db->queryFirstColumn('SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%i', $userID, $now) as $reserved){ $reserved = Json::decode($reserved); foreach(array_keys($reserved) as $reservedNPC){ if(key_exists($reservedNPC, $weight)){ @@ -131,8 +127,8 @@ $newNonce = random_int(0, 0xfffffff); $validSecond = max(VALID_SECOND, $turnterm*40); $pickMoreSecond = max(PICK_MORE_SECOND, Util::round(pow($turnterm, 0.672)*8)); -$validUntil = $oNow->add(new \DateInterval(sprintf('PT%dS', $validSecond))); -$pickMoreFrom = $oNow->add(new \DateInterval(sprintf('PT%dS', $pickMoreSecond))); +$validUntil = $now + $clock->ticksFromSeconds($validSecond); +$pickMoreFrom = $now + $clock->ticksFromSeconds($pickMoreSecond); $db->delete('select_npc_token', 'valid_until < %s', $now); @@ -140,8 +136,8 @@ $inserted = 0; if($token){ $db->update('select_npc_token', [ - 'valid_until'=>$validUntil->format('Y-m-d H:i:s'), - 'pick_more_from'=>$pickMoreFrom->format('Y-m-d H:i:s'), + 'valid_until'=>$validUntil, + 'pick_more_from'=>$pickMoreFrom, 'pick_result'=>Json::encode($pickResult), 'nonce'=>$newNonce ], 'owner = %i AND nonce = %i', $userID, $token['nonce']); @@ -152,8 +148,8 @@ if($token){ else{ $db->insertIgnore('select_npc_token', [ 'owner'=>$userID, - 'valid_until'=>$validUntil->format('Y-m-d H:i:s'), - 'pick_more_from'=>'2000-01-01 01:00:00', + 'valid_until'=>$validUntil, + 'pick_more_from'=>$now, 'pick_result'=>Json::encode($pickResult), 'nonce'=>$newNonce ]); @@ -173,7 +169,7 @@ if($inserted === 0){ Json::die([ 'result'=>true, 'pick'=>$pickResult, - 'pickMoreFrom'=>($inserted===-1)?$pickMoreFrom->format('Y-m-d H:i:s'):'2000-01-01 01:00:00', + 'pickMoreFrom'=>$clock->formatTick(($inserted===-1)?$pickMoreFrom:$now), 'pickMoreSeconds'=>($inserted===-1)?$pickMoreSecond:0, - 'validUntil'=>$validUntil->format('Y-m-d H:i:s') -]); \ No newline at end of file + 'validUntil'=>$clock->formatTick($validUntil) +]); diff --git a/hwe/j_get_select_pool.php b/hwe/j_get_select_pool.php index d5c9a07e..9e3d9c0b 100644 --- a/hwe/j_get_select_pool.php +++ b/hwe/j_get_select_pool.php @@ -43,12 +43,10 @@ function putInfoText(&$info, ?array $currentTargetEnv){ $session = Session::requireLogin([])->setReadOnly(); $userID = Session::getUserID(); -$oNow = new \DateTimeImmutable(); - -$now = $oNow->format('Y-m-d H:i:s'); - -$db = DB::db(); -$gameStor = KVStorage::getStorage($db, 'game_env'); +$db = DB::db(); +$gameStor = KVStorage::getStorage($db, 'game_env'); +$clock = GameClock::fromStorage($gameStor); +$now = $clock->nowTick(); $eventEnv = $gameStor->getValues(['npcmode', 'startyear', 'year', 'month']); $npcmode = $eventEnv['npcmode']; @@ -88,7 +86,7 @@ if($tokens){ Json::die([ 'result'=>true, 'pick'=>$pick, - 'validUntil'=>$valid_until + 'validUntil'=>$clock->formatTick(Util::toInt($valid_until)) ]); } @@ -108,5 +106,5 @@ sortTokens($pick);//좀 무식하지만.. Json::die([ 'result'=>true, 'pick'=>$pick, - 'validUntil'=>$valid_until + 'validUntil'=>$valid_until === null ? null : $clock->formatTick(Util::toInt($valid_until)) ]); diff --git a/hwe/j_install.php b/hwe/j_install.php index 6aa8f724..ef6dbbdd 100644 --- a/hwe/j_install.php +++ b/hwe/j_install.php @@ -211,7 +211,7 @@ try{ $show_img_level, !!$tournament_trig, $join_mode, - TimeUtil::now(), + TimeUtil::format(GameClock::readWallTime(), false), $autorun_user )); } @@ -220,4 +220,4 @@ catch(\Exception $e){ 'result'=>false, 'reason'=>$e->getMessage() ]); -} \ No newline at end of file +} diff --git a/hwe/j_select_npc.php b/hwe/j_select_npc.php index 61cbcc01..261fa46f 100644 --- a/hwe/j_select_npc.php +++ b/hwe/j_select_npc.php @@ -24,8 +24,8 @@ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $rootDB = RootDB::db(); -$oNow = new \DateTimeImmutable(); -$now = $oNow->format('Y-m-d H:i:s'); +$clock = GameClock::fromStorage($gameStor); +$now = $clock->nowTick(); $member = $rootDB->queryFirstRow('SELECT `name`, `penalty` FROM member WHERE `NO`=%i', $userID); if(!$member){ @@ -139,4 +139,4 @@ $rootDB->insert('member_log', [ Json::die([ 'result'=>true, 'reason'=>'success' -]); \ No newline at end of file +]); diff --git a/hwe/j_select_picked_general.php b/hwe/j_select_picked_general.php index 0b014069..f64d440b 100644 --- a/hwe/j_select_picked_general.php +++ b/hwe/j_select_picked_general.php @@ -37,8 +37,8 @@ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $rootDB = RootDB::db(); -$oNow = new \DateTimeImmutable(); -$now = $oNow->format('Y-m-d H:i:s'); +$clock = GameClock::fromStorage($gameStor); +$now = $clock->nowTick(); $hasGeneralID = $db->queryFirstField('SELECT no FROM general WHERE owner = %i', $userID); if($hasGeneralID){ @@ -176,7 +176,7 @@ $builder->setOwner($userID); $builder->setOwnerName($userNick); $builder->setKillturn(5); $builder->setNPCType(0); -$builder->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $env['turnterm'])); +$builder->setAuxVar('next_change', $now + GameClock::TICKS_PER_TURN * 12); $builder->setAuxVar( 'prestart_delete_after', addTurn($now, $env['turnterm'], GameConst::$minTurnDieOnPrestart) diff --git a/hwe/j_server_basic_info.php b/hwe/j_server_basic_info.php index 47b95a88..d7868a56 100644 --- a/hwe/j_server_basic_info.php +++ b/hwe/j_server_basic_info.php @@ -19,6 +19,7 @@ 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( @@ -75,8 +76,8 @@ $admin['maxUserCnt'] = $admin['maxgeneral']; $admin['npcMode'] = $admin['npcmode']; $admin['turnTerm'] = $admin['turnterm']; $admin['isUnited'] = $admin['isunited']; -$admin['starttime'] = substr($admin['opentime'], 5, 11); -$admin['turntime'] = substr($admin['turntime'], 5, 11); +$admin['starttime'] = substr($clock->formatTick(Util::toInt($admin['opentime'])), 5, 11); +$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); unset($admin['npcmode']); unset($admin['maxgeneral']); unset($admin['turnterm']); @@ -131,4 +132,4 @@ if($general){ Json::die([ 'game'=>$admin, 'me'=>$me?:null -]); \ No newline at end of file +]); diff --git a/hwe/j_simulate_battle.php b/hwe/j_simulate_battle.php index a43d1ee1..94d6cee8 100644 --- a/hwe/j_simulate_battle.php +++ b/hwe/j_simulate_battle.php @@ -77,7 +77,8 @@ $month = $query['month']; $repeatCnt = $query['repeatCnt']; $rawAttacker = $query['attackerGeneral']; -$rawAttacker['turntime'] = TimeUtil::now(); +$battleClock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); +$rawAttacker['turntime'] = $battleClock->formatTick($battleClock->nowTick()); $rawAttackerCity = $query['attackerCity']; $rawAttackerNation = $query['attackerNation']; diff --git a/hwe/j_update_picked_general.php b/hwe/j_update_picked_general.php index dc6d1ece..9cad78eb 100644 --- a/hwe/j_update_picked_general.php +++ b/hwe/j_update_picked_general.php @@ -22,8 +22,8 @@ $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $rootDB = RootDB::db(); -$oNow = new \DateTimeImmutable(); -$now = $oNow->format('Y-m-d H:i:s'); +$clock = GameClock::fromStorage($gameStor); +$now = $clock->nowTick(); $generalID = $db->queryFirstField('SELECT no FROM general WHERE owner = %i', $userID); if(!$generalID){ @@ -181,7 +181,7 @@ if ($isCentennialAllStar) { if(key_exists('generalName', $info)){ $generalObj->updateVar('name', $info['generalName']); } -$generalObj->setAuxVar('next_change', TimeUtil::nowAddMinutes(12 * $turnterm)); +$generalObj->setAuxVar('next_change', $now + GameClock::TICKS_PER_TURN * 12); $userNick = $ownerInfo['name']; $generalObj->setVar('owner_name', $userNick); diff --git a/hwe/proc.php b/hwe/proc.php index 5e1e2ac7..c4e92cba 100644 --- a/hwe/proc.php +++ b/hwe/proc.php @@ -11,9 +11,11 @@ $db = DB::db(); $updated = false; $locked = false; $lastExecuted = TurnExecutionHelper::executeAllCommand($updated, $locked); +$clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); Json::die([ 'result' => true, 'updated' => $updated, 'locked' => $locked, - 'lastExecuted' => $lastExecuted, -]); \ No newline at end of file + 'lastExecutedTick' => $lastExecuted, + 'lastExecuted' => $clock->formatTick($lastExecuted, true), +]); diff --git a/hwe/sammo/API/Auction/BidUniqueAuction.php b/hwe/sammo/API/Auction/BidUniqueAuction.php index d026ad3a..6877c09c 100644 --- a/hwe/sammo/API/Auction/BidUniqueAuction.php +++ b/hwe/sammo/API/Auction/BidUniqueAuction.php @@ -21,7 +21,7 @@ class BidUniqueAuction extends \sammo\BaseAPI ]) ->rule('int', 'amount') ->rule('int', 'auctionID') - ->rule('boolean', 'extendCloseDate'); + ->rule('boolean', 'extendCloseTick'); if (!$v->validate()) { return $v->errorStr(); @@ -38,7 +38,7 @@ class BidUniqueAuction extends \sammo\BaseAPI { $auctionID = $this->args['auctionID']; $amount = $this->args['amount']; - $tryExtendCloseDate = $this->args['extendCloseDate'] ?? false; + $tryExtendCloseDate = $this->args['extendCloseTick'] ?? false; $generalID = $session->generalID; $general = General::createObjFromDB($generalID); diff --git a/hwe/sammo/API/Auction/GetActiveResourceAuctionList.php b/hwe/sammo/API/Auction/GetActiveResourceAuctionList.php index f891d00e..4e03e87a 100644 --- a/hwe/sammo/API/Auction/GetActiveResourceAuctionList.php +++ b/hwe/sammo/API/Auction/GetActiveResourceAuctionList.php @@ -15,6 +15,8 @@ use sammo\General; use sammo\Json; use sammo\TimeUtil; use sammo\Util; +use sammo\GameClock; +use sammo\KVStorage; use function sammo\getAuctionLogRecent; @@ -33,12 +35,13 @@ class GetActiveResourceAuctionList extends \sammo\BaseAPI public function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag): null | string | array | APIRecoveryType { $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $buyRiceList = []; $sellRiceList = []; /** @var AuctionInfo[] */ $auctions = array_map(fn ($raw) => AuctionInfo::fromArray($raw), $db->query( - 'SELECT * FROM `ng_auction` WHERE `type` IN %ls AND `finished` = 0 ORDER BY `close_date` ASC', + 'SELECT * FROM `ng_auction` WHERE `type` IN %ls AND `finished` = 0 ORDER BY `close_tick` ASC', [ AuctionType::BuyRice->value, AuctionType::SellRice->value, @@ -87,8 +90,8 @@ class GetActiveResourceAuctionList extends \sammo\BaseAPI 'type' => $auction->type->value, 'hostGeneralID' => $auction->hostGeneralID, 'hostName' => $auction->detail->hostName, - 'openDate' => TimeUtil::format($auction->openDate, false), - 'closeDate' => TimeUtil::format($auction->closeDate, false), + 'openDate' => $clock->formatTick($auction->openTick), + 'closeDate' => $clock->formatTick($auction->closeTick), 'amount' => $auction->detail->amount, 'startBidAmount' => $auction->detail->startBidAmount, 'finishBidAmount' => $auction->detail->finishBidAmount, diff --git a/hwe/sammo/API/Auction/GetUniqueItemAuctionDetail.php b/hwe/sammo/API/Auction/GetUniqueItemAuctionDetail.php index 782995f3..d5bc702a 100644 --- a/hwe/sammo/API/Auction/GetUniqueItemAuctionDetail.php +++ b/hwe/sammo/API/Auction/GetUniqueItemAuctionDetail.php @@ -14,6 +14,8 @@ use sammo\Enums\GeneralQueryMode; use sammo\Enums\InheritanceKey; use sammo\InheritancePointManager; use sammo\TimeUtil; +use sammo\GameClock; +use sammo\KVStorage; use sammo\Validator; use sammo\General; @@ -42,6 +44,7 @@ class GetUniqueItemAuctionDetail extends \sammo\BaseAPI public function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag): null | string | array | APIRecoveryType { $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $generalID = $session->generalID; $auctionID = $this->args['auctionID']; @@ -92,9 +95,11 @@ class GetUniqueItemAuctionDetail extends \sammo\BaseAPI 'target' => $auction->target, 'isCallerHost' => $auction->hostGeneralID === $generalID, 'hostName' => $auction->detail->hostName, - 'closeDate' => TimeUtil::format($auction->closeDate, false), + 'closeDate' => $clock->formatTick($auction->closeTick), 'remainCloseDateExtensionCnt' => $auction->detail->remainCloseDateExtensionCnt, - 'availableLatestBidCloseDate' => TimeUtil::format($auction->detail->availableLatestBidCloseDate, false), + 'availableLatestBidCloseDate' => $auction->detail->availableLatestBidCloseTick === null + ? null + : $clock->formatTick($auction->detail->availableLatestBidCloseTick), ], 'bidList' => $responseBid, 'obfuscatedName' => $obfuscatedName, diff --git a/hwe/sammo/API/Auction/GetUniqueItemAuctionList.php b/hwe/sammo/API/Auction/GetUniqueItemAuctionList.php index d7ff0704..c69b7230 100644 --- a/hwe/sammo/API/Auction/GetUniqueItemAuctionList.php +++ b/hwe/sammo/API/Auction/GetUniqueItemAuctionList.php @@ -12,6 +12,8 @@ use sammo\Enums\APIRecoveryType; use sammo\Enums\AuctionType; use sammo\TimeUtil; use sammo\Util; +use sammo\GameClock; +use sammo\KVStorage; class GetUniqueItemAuctionList extends \sammo\BaseAPI { @@ -28,12 +30,13 @@ class GetUniqueItemAuctionList extends \sammo\BaseAPI public function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag): null | string | array | APIRecoveryType { $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $generalID = $session->generalID; /** @var AuctionInfo[] */ $auctions = array_map(fn($raw)=>AuctionInfo::fromArray($raw), $db->query( - 'SELECT * FROM `ng_auction` WHERE `type` = %s ORDER BY `close_date` ASC', + 'SELECT * FROM `ng_auction` WHERE `type` = %s ORDER BY `close_tick` ASC', AuctionType::UniqueItem->value ) ?? []); @@ -85,9 +88,11 @@ class GetUniqueItemAuctionList extends \sammo\BaseAPI 'target' => $auction->target, 'isCallerHost' => $auction->hostGeneralID === $generalID, 'hostName' => $auction->detail->hostName, - 'closeDate' => TimeUtil::format($auction->closeDate, false), + 'closeDate' => $clock->formatTick($auction->closeTick), 'remainCloseDateExtensionCnt' => $auction->detail->remainCloseDateExtensionCnt, - 'availableLatestBidCloseDate' => TimeUtil::format($auction->detail->availableLatestBidCloseDate, false), + 'availableLatestBidCloseDate' => $auction->detail->availableLatestBidCloseTick === null + ? null + : $clock->formatTick($auction->detail->availableLatestBidCloseTick), 'highestBid' => [ 'generalName' => $highestBid->aux->generalName, 'amount' => $highestBid->amount, diff --git a/hwe/sammo/API/Command/GetReservedCommand.php b/hwe/sammo/API/Command/GetReservedCommand.php index 8bec74a0..195376ed 100644 --- a/hwe/sammo/API/Command/GetReservedCommand.php +++ b/hwe/sammo/API/Command/GetReservedCommand.php @@ -7,9 +7,11 @@ use DateTimeInterface; use sammo\DB; use sammo\Enums\APIRecoveryType; use sammo\GameConst; +use sammo\GameClock; use sammo\Json; use sammo\KVStorage; use sammo\TimeUtil; +use sammo\Util; use function sammo\cutTurn; @@ -82,11 +84,12 @@ class GetReservedCommand extends \sammo\BaseAPI return [ 'result' => true, - 'turnTime' => $turnTime, + 'turnTimeTick' => Util::toInt($turnTime), + 'turnTime' => GameClock::fromStorage($gameStor)->formatTick(Util::toInt($turnTime)), 'turnTerm' => $turnTerm, 'year' => $year, 'month' => $month, - 'date' => TimeUtil::now(true), + 'date' => GameClock::fromStorage($gameStor)->formatTick(GameClock::fromStorage($gameStor)->nowTick(), true), 'turn' => $commandList, 'autorun_limit' => $generalAux['autorun_limit'] ?? null, ]; diff --git a/hwe/sammo/API/General/DieOnPrestart.php b/hwe/sammo/API/General/DieOnPrestart.php index c38a8673..1e20e86e 100644 --- a/hwe/sammo/API/General/DieOnPrestart.php +++ b/hwe/sammo/API/General/DieOnPrestart.php @@ -11,7 +11,8 @@ use sammo\Session; use sammo\General; use sammo\JosaUtil; use sammo\KVStorage; -use sammo\TimeUtil; +use sammo\GameClock; +use sammo\Util; use function sammo\addTurn; use function sammo\increaseRefresh; @@ -37,6 +38,8 @@ class DieOnPrestart extends \sammo\BaseAPI $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor->cacheValues(['turnterm', 'opentime', 'turntime', 'year', 'month']); + $clock = GameClock::fromStorage($gameStor); + $nowTick = $clock->nowTick(); $general = $db->queryFirstRow('SELECT no,name,nation,owner_name,npc FROM general WHERE owner=%i AND npc = 0', $userID); if (!$general) { @@ -67,9 +70,9 @@ class DieOnPrestart extends \sammo\BaseAPI } $targetTime = $generalObj->getAuxVar('prestart_delete_after'); - if (!is_string($targetTime) || $targetTime === '') { + if (!is_int($targetTime)) { $targetTime = addTurn( - $lastRefresh ?: TimeUtil::now(), + $lastRefresh === null ? $nowTick : Util::toInt($lastRefresh), $gameStor->turnterm, GameConst::$minTurnDieOnPrestart ); @@ -78,8 +81,8 @@ class DieOnPrestart extends \sammo\BaseAPI } //서버 가오픈시 할 수 있는 행동 - if ($targetTime > TimeUtil::now()) { - $targetTimeShort = substr($targetTime, 0, 19); + if ($targetTime > $nowTick) { + $targetTimeShort = $clock->formatTick($targetTime); return "아직 삭제할 수 없습니다. {$targetTimeShort} 부터 가능합니다."; } diff --git a/hwe/sammo/API/General/GetFrontInfo.php b/hwe/sammo/API/General/GetFrontInfo.php index c36cd570..8fc44ef7 100644 --- a/hwe/sammo/API/General/GetFrontInfo.php +++ b/hwe/sammo/API/General/GetFrontInfo.php @@ -14,6 +14,7 @@ use sammo\Enums\GeneralColumn; use sammo\Enums\GeneralQueryMode; use sammo\Enums\RankColumn; use sammo\GameConst; +use sammo\GameClock; use sammo\General; use sammo\KVStorage; use sammo\LastTurn; @@ -105,6 +106,7 @@ class GetFrontInfo extends \sammo\BaseAPI $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); $gameStor->cacheValues(['isunited', 'opentime', 'refresh']); $lastHistoryID = $this->args['lastWorldHistoryID']; @@ -158,6 +160,7 @@ class GetFrontInfo extends \sammo\BaseAPI private function generateGlobalInfo(MeekroDB $db): array { $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); [ $scenarioText, $extendedGeneral, $isFiction, $npcMode, @@ -210,7 +213,8 @@ class GetFrontInfo extends \sammo\BaseAPI 'month' => $month, 'autorunUser' => $autorunUser, 'turnterm' => $turnterm, - 'lastExecuted' => $lastExecuted, + 'lastExecutedTick' => $lastExecuted, + 'lastExecuted' => $clock->formatTick(Util::toInt($lastExecuted), true), 'lastVoteID' => $lastVoteID, 'develCost' => $develCost, 'noticeMsg' => $noticeMsg, @@ -224,7 +228,8 @@ class GetFrontInfo extends \sammo\BaseAPI 'isLocked' => $isLocked, 'tournamentType' => $tournamentType, 'tournamentState' => $tournamentState, - 'tournamentTime' => $tournamentTime, + 'tournamentTimeTick' => $tournamentTime, + 'tournamentTime' => $tournamentTime === null ? null : $clock->formatTick(Util::toInt($tournamentTime)), 'genCount' => $globalGenCount, 'generalCntLimit' => $generalCntLimit, 'serverCnt' => $serverCnt, @@ -361,6 +366,7 @@ class GetFrontInfo extends \sammo\BaseAPI public function generateGeneralInfo(MeekroDB $db, General $general, array $rawNation): array { + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $permission = checkSecretPermission($general->getRaw()); @@ -425,8 +431,12 @@ class GetFrontInfo extends \sammo\BaseAPI 'crew' => $general->getVar(GeneralColumn::crew), // number; 'train' => $general->getVar(GeneralColumn::train), // number; 'atmos' => $general->getVar(GeneralColumn::atmos), // number; - 'turntime' => $general->getVar(GeneralColumn::turntime), // string; - 'recent_war' => $general->getVar(GeneralColumn::recent_war), // string; + 'turntimeTick' => $general->getTurnTick(), // number; + 'turntime' => $general->getTurnTime(), // string; + 'recent_war_tick' => $general->getVar(GeneralColumn::recent_war), // number|null; + 'recent_war' => $general->getVar(GeneralColumn::recent_war) === null + ? null + : $clock->formatTick(Util::toInt($general->getVar(GeneralColumn::recent_war)), true), // string|null; 'horse' => $general->getVar(GeneralColumn::horse), // GameObjClassKey; 'weapon' => $general->getVar(GeneralColumn::weapon), // GameObjClassKey; 'book' => $general->getVar(GeneralColumn::book), // GameObjClassKey; @@ -544,7 +554,7 @@ class GetFrontInfo extends \sammo\BaseAPI 'result' => false, 'reason' => '접속 제한중입니다.', 'recovery' => APIRecoveryType::GameQuota, - 'recovery_arg' => $general->getVar('turntime'), + 'recovery_arg' => $general->getTurnTime(), ]; } diff --git a/hwe/sammo/API/General/Join.php b/hwe/sammo/API/General/Join.php index 43b5a2cc..eae1a354 100644 --- a/hwe/sammo/API/General/Join.php +++ b/hwe/sammo/API/General/Join.php @@ -11,6 +11,7 @@ use sammo\Enums\APIRecoveryType; use sammo\Enums\GeneralAccessLogColumn; use sammo\Enums\RankColumn; use sammo\GameConst; +use sammo\GameClock; use sammo\GameUnitConst; use sammo\General; use sammo\InheritancePointManager; @@ -165,6 +166,7 @@ class Join extends \sammo\BaseAPI $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor->cacheValues(['year', 'month', 'maxgeneral', 'scenario', 'show_img_level', 'block_general_create', 'turnterm', 'turntime', 'genius', 'npcmode']); + $clock = GameClock::fromStorage($gameStor); ########## 동일 정보 존재여부 확인. ########## $block_general_create = $gameStor->getValue('block_general_create'); @@ -222,7 +224,7 @@ class Join extends \sammo\BaseAPI $userLogger = new UserLogger($userID, $admin['year'], $admin['month'], false); - $now = TimeUtil::now(false); + $now = $clock->nowTick(); $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( UniqueConst::$hiddenSeed, 'MakeGeneral', @@ -360,17 +362,14 @@ class Join extends \sammo\BaseAPI $userLogger->push(sprintf("턴 시간 %02d:%02d 로 지정", intdiv($inheritTurntime, 60), $inheritTurntime % 60), "inheritPoint"); - $inheritTurntime += $rng->nextRangeInt(0, 999999) / 1000000; - - $turntime = new \DateTimeImmutable(cutTurn($admin['turntime'], $admin['turnterm'])); - $turntime = $turntime->add(TimeUtil::secondsToDateInterval($inheritTurntime)); - $turntime = TimeUtil::format($turntime, true); + $inheritTurnMicrosecond = $rng->nextRangeInt(0, 999999); + $turntime = cutTurn(Util::toInt($admin['turntime']), $admin['turnterm']) + + $clock->ticksFromSeconds($inheritTurntime) + + intdiv($inheritTurnMicrosecond * $clock->ticksPerSecond(), 1_000_000); } else { - $turntime = getRandTurn($rng, $admin['turnterm'], new \DateTimeImmutable($admin['turntime'])); + $turntime = getRandTurn($rng, $admin['turnterm'], Util::toInt($admin['turntime'])); } - - $now = TimeUtil::now(true); if ($now >= $turntime) { $turntime = addTurn($turntime, $admin['turnterm']); } diff --git a/hwe/sammo/API/Global/ExecuteEngine.php b/hwe/sammo/API/Global/ExecuteEngine.php index 192d1cd3..de554acf 100644 --- a/hwe/sammo/API/Global/ExecuteEngine.php +++ b/hwe/sammo/API/Global/ExecuteEngine.php @@ -8,6 +8,8 @@ use DateTimeInterface; use sammo\Enums\APIRecoveryType; use sammo\TurnExecutionHelper; use sammo\UniqueConst; +use sammo\GameClock; +use sammo\KVStorage; class ExecuteEngine extends \sammo\BaseAPI { @@ -35,11 +37,13 @@ class ExecuteEngine extends \sammo\BaseAPI $updated = false; $locked = false; $lastExecuted = TurnExecutionHelper::executeAllCommand($updated, $locked); + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); return [ 'result' => true, 'updated' => $updated, 'locked' => $locked, - 'lastExecuted' => $lastExecuted, + 'lastExecutedTick' => $lastExecuted, + 'lastExecuted' => $clock->formatTick($lastExecuted, true), ]; } } diff --git a/hwe/sammo/API/Global/GeneralList.php b/hwe/sammo/API/Global/GeneralList.php index 1b5ca061..85a77db8 100644 --- a/hwe/sammo/API/Global/GeneralList.php +++ b/hwe/sammo/API/Global/GeneralList.php @@ -3,6 +3,7 @@ namespace sammo\API\Global; use sammo\DB; +use sammo\GameClock; use sammo\Enums\APIRecoveryType; use sammo\Json; use sammo\KVStorage; @@ -145,7 +146,8 @@ class GeneralList extends \sammo\BaseAPI if (static::$withToken) { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $now = GameClock::fromStorage($gameStor)->nowTick(); $tokens = []; foreach ($db->query('SELECT * FROM select_npc_token WHERE `valid_until`>=%s', $now) as $token) { $validUntil = $token['valid_until']; diff --git a/hwe/sammo/API/InheritAction/ResetTurnTime.php b/hwe/sammo/API/InheritAction/ResetTurnTime.php index 3f8e7a20..1419ba13 100644 --- a/hwe/sammo/API/InheritAction/ResetTurnTime.php +++ b/hwe/sammo/API/InheritAction/ResetTurnTime.php @@ -64,13 +64,11 @@ class ResetTurnTime extends \sammo\BaseAPI $gameStor = KVStorage::getStorage($db, 'game_env'); $turnTerm = $gameStor->getValue('turnterm'); - $currTurnTime = new DateTimeImmutable($general->getTurnTime()); - $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( UniqueConst::$hiddenSeed, 'ResetTurnTime', $userID, - $general->getAuxVar('nextTurnTimeBase') ?? $general->getTurnTime() + $general->getAuxVar('nextTurnTimeBase') ?? $general->getTurnTick() ))); $afterTurn = $rng->nextFloat1() * $turnTerm * 60; diff --git a/hwe/sammo/API/Nation/GeneralList.php b/hwe/sammo/API/Nation/GeneralList.php index 928340ce..26001519 100644 --- a/hwe/sammo/API/Nation/GeneralList.php +++ b/hwe/sammo/API/Nation/GeneralList.php @@ -8,6 +8,7 @@ use sammo\Enums\APIRecoveryType; use sammo\Enums\GeneralLiteQueryMode; use sammo\Enums\GeneralQueryMode; use sammo\General; +use sammo\GameClock; use sammo\GeneralLite; use sammo\Session; use sammo\Util; @@ -264,8 +265,11 @@ 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) => substr($rawGeneral['turntime'], 0, 19), - 'recent_war' => fn ($rawGeneral) => substr($rawGeneral['recent_war'], 0, 19), + 'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor) + ->formatTick(Util::toInt($rawGeneral['turntime'])), + 'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null + ? null + : GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])), 'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']), 'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null, 'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0, diff --git a/hwe/sammo/API/NationCommand/GetReservedCommand.php b/hwe/sammo/API/NationCommand/GetReservedCommand.php index 8930329b..78af94eb 100644 --- a/hwe/sammo/API/NationCommand/GetReservedCommand.php +++ b/hwe/sammo/API/NationCommand/GetReservedCommand.php @@ -8,6 +8,7 @@ use sammo\DB; use sammo\Enums\APIRecoveryType; use sammo\Enums\GeneralQueryMode; use sammo\GameConst; +use sammo\GameClock; use sammo\General; use sammo\Json; use sammo\KVStorage; @@ -51,7 +52,8 @@ class GetReservedCommand extends \sammo\BaseAPI $nationID = $me['nation']; $limitState = checkLimit($me['refresh_score']); if ($limitState >= 2) { - return "접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. (다음 갱신 가능 시각 : {$me['turntime']})"; + $limitTime = GameClock::fromStorage($gameStor)->formatTick(Util::toInt($me['turntime']), true); + return "접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. (다음 갱신 가능 시각 : {$limitTime})"; } $permission = checkSecretPermission($me); diff --git a/hwe/sammo/AbsFromUserPool.php b/hwe/sammo/AbsFromUserPool.php index 274b79b0..eec33d99 100644 --- a/hwe/sammo/AbsFromUserPool.php +++ b/hwe/sammo/AbsFromUserPool.php @@ -30,8 +30,9 @@ abstract class AbsFromUserPool extends AbsGeneralPool{ } static public function pickGeneralFromPool(\MeekroDB $db, RandUtil $rng, int $owner, int $pickCnt, ?string $prefix=null):array{ - $oNow = new \DateTimeImmutable(); - $now = $oNow->format('Y-m-d H:i:s'); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); + $now = $clock->nowTick(); $db->update('select_pool', [ 'reserved_until'=>null, @@ -48,9 +49,8 @@ abstract class AbsFromUserPool extends AbsGeneralPool{ throw new \RuntimeException('pool 부족'); } - $gameStor = KVStorage::getStorage($db, 'game_env'); - $result = []; - $validUntil = TimeUtil::nowAddMinutes(2 * $gameStor->turnterm); + $result = []; + $validUntil = $now + GameClock::TICKS_PER_TURN * 2; while(count($result) < $pickCnt){ $cand = $rng->choiceUsingWeightPair($pool); $poolID = $cand['id']; diff --git a/hwe/sammo/AbsGeneralPool.php b/hwe/sammo/AbsGeneralPool.php index 64252ed3..049b13b2 100644 --- a/hwe/sammo/AbsGeneralPool.php +++ b/hwe/sammo/AbsGeneralPool.php @@ -34,7 +34,7 @@ abstract class AbsGeneralPool{ * specialWar */ - public function __construct(\MeekroDB $db, RandUtil $rng, array $info, string $validUntil) + public function __construct(\MeekroDB $db, RandUtil $rng, array $info, int $validUntil) { $this->db = $db; $this->info = $info; @@ -92,7 +92,7 @@ abstract class AbsGeneralPool{ return $this->builder; } - public function getValidUntil():string{ + public function getValidUntil():int{ return $this->validUntil; } @@ -109,4 +109,4 @@ abstract class AbsGeneralPool{ abstract public static function getPoolName():string; abstract public static function initPool(\MeekroDB $db); -} \ No newline at end of file +} diff --git a/hwe/sammo/Auction.php b/hwe/sammo/Auction.php index ce274efa..8f412b7e 100644 --- a/hwe/sammo/Auction.php +++ b/hwe/sammo/Auction.php @@ -146,40 +146,35 @@ abstract class Auction return $this->info; } - public function shrinkCloseDate(?DateTimeInterface $date): ?string + public function shrinkCloseTick(?int $tick): ?string { - if ($date === null) { - $date = new DateTimeImmutable(); - } - - $this->info->closeDate = $date; $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $this->info->closeTick = $tick ?? $clock->nowTick(); $db->update('ng_auction', $this->info->toArray('id'), 'id = %i', $this->info->id); return null; } - public function extendLatestBidCloseDate(?DateTimeInterface $date): ?string + public function extendLatestBidCloseTick(?int $tick): ?string { - if ($date === null) { + if ($tick === null) { $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); $turnTerm = $gameStor->getValue('turnterm'); - $date = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60 - )); + $tick = $this->info->closeTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) + ); } - else{ - $date = DateTimeImmutable::createFromInterface($date); - } - if ($this->info->detail->availableLatestBidCloseDate !== null && $date < $this->info->detail->availableLatestBidCloseDate) { + if ($this->info->detail->availableLatestBidCloseTick !== null && $tick < $this->info->detail->availableLatestBidCloseTick) { return '기간보다 짧습니다.'; } - $this->info->detail->availableLatestBidCloseDate = $date; + $this->info->detail->availableLatestBidCloseTick = $tick; return null; } - public function extendCloseDate(DateTimeInterface $date, bool $force = false): ?string + public function extendCloseTick(int $tick, bool $force = false): ?string { if (!$force) { if ($this->info->detail->remainCloseDateExtensionCnt === null) { @@ -193,12 +188,11 @@ abstract class Auction } } - if ($date < $this->info->closeDate) { + if ($tick < $this->info->closeTick) { return '종료 기간보다 짧습니다.'; } - $closeDate = DateTimeImmutable::createFromInterface($date); - $this->info->closeDate = $closeDate; + $this->info->closeTick = $tick; return null; } @@ -245,12 +239,13 @@ abstract class Auction //TODO: 전역 알림이 나타나야한다. 일반 메시지보다는 중요하고, 메시지보단 약하게.. //TODO: 바로가기를 제공하는 편이 좋을 것 같다. + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $msg = new Message( MessageType::private, $src, $dest, $reason, - new DateTime(), + DateTime::createFromImmutable($clock->tickToDateTime($clock->nowTick())), new DateTime('9999-12-31'), [] ); @@ -275,7 +270,12 @@ abstract class Auction $db->update('ng_auction', $this->info->toArray('id'), 'id = %i', $this->info->id); } - private function bidInheritPoint(int $amount, \DateTimeImmutable $now, bool $tryExtendCloseDate): ?string + private function bidInheritPoint( + int $amount, + int $nowTick, + \DateTimeImmutable $nowDate, + bool $tryExtendCloseDate, + ): ?string { $db = DB::db(); @@ -311,7 +311,7 @@ abstract class Auction $general->getVar('owner'), $general->getID(), $amount, - $now, + $nowDate, new AuctionBidItemData( $general->getVar('owner_name'), $obfuscatedName, @@ -324,15 +324,16 @@ abstract class Auction } $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); $turnTerm = $gameStor->getValue('turnterm'); - if ($this->info->detail->availableLatestBidCloseDate !== null) { - $extendedCloseDate = $now->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60 - )); + if ($this->info->detail->availableLatestBidCloseTick !== null) { + $extendedCloseTick = $nowTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) + ); - if ($extendedCloseDate > $this->info->closeDate && $this->info->closeDate < $this->info->detail->availableLatestBidCloseDate) { - $this->extendCloseDate(min($extendedCloseDate, $this->info->detail->availableLatestBidCloseDate), true); + if ($extendedCloseTick > $this->info->closeTick && $this->info->closeTick < $this->info->detail->availableLatestBidCloseTick) { + $this->extendCloseTick(min($extendedCloseTick, $this->info->detail->availableLatestBidCloseTick), true); $this->applyDB(); } } @@ -356,12 +357,16 @@ abstract class Auction return '경매가 이미 끝났습니다.'; } - $now = new \DateTimeImmutable(); + $db = DB::db(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); + $nowTick = $clock->nowTick(); + $nowDate = $clock->tickToDateTime($nowTick); - if ($auctionInfo->closeDate < $now) { + if ($auctionInfo->closeTick < $nowTick) { return '경매가 이미 끝났습니다.'; } - if ($auctionInfo->openDate > $now) { + if ($auctionInfo->openTick > $nowTick) { return '경매가 아직 시작되지 않았습니다.'; } @@ -377,13 +382,11 @@ abstract class Auction if ($auctionInfo->reqResource === ResourceType::inheritancePoint) { - return $this->bidInheritPoint($amount, $now, $tryExtendCloseDate); + return $this->bidInheritPoint($amount, $nowTick, $nowDate, $tryExtendCloseDate); } //reqResource는 말 그대로 '구매자가 내야하는 자원'이다. - $db = DB::db(); - $highestBid = $this->getHighestBid(); if (!$auctionInfo->detail->isReverse) { if ($highestBid !== null && $amount <= $highestBid->amount) { @@ -421,7 +424,7 @@ abstract class Auction $general->getVar('owner'), $general->getID(), $amount, - $now, + $nowDate, new AuctionBidItemData( $general->getVar('owner_name'), $general->getName(), @@ -436,14 +439,13 @@ abstract class Auction $general->increaseVar($resType->value, -$morePoint); - $gameStor = KVStorage::getStorage($db, 'game_env'); $turnTerm = $gameStor->getValue('turnterm'); - $extendedCloseDate = $now->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) * 60 - )); + $extendedCloseTick = $nowTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_PER_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_PER_BID) + ); - if ($extendedCloseDate > $this->info->closeDate) { - $this->extendCloseDate($extendedCloseDate, true); + if ($extendedCloseTick > $this->info->closeTick) { + $this->extendCloseTick($extendedCloseTick, true); $this->applyDB(); } @@ -456,10 +458,10 @@ abstract class Auction public function tryFinish(): ?bool { - $now = new DateTimeImmutable(); - if ($now < $this->info->closeDate) { - return null; - } + $db = DB::db(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); + if ($clock->nowTick() < $this->info->closeTick) return null; //경매를 닫아야한다. $highestBid = $this->getHighestBid(); @@ -469,17 +471,15 @@ abstract class Auction } if ($highestBid->aux->tryExtendCloseDate) { - $db = DB::db(); - $gameStor = KVStorage::getStorage($db, 'game_env'); $turnTerm = $gameStor->getValue('turnterm'); //연장 요청이 있었다. - $extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, $turnTerm * static::COEFF_EXTENSION_MINUTES_BY_EXTENSION_QUERY) * 60 - )); + $extendedCloseTick = $this->info->closeTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, $turnTerm * static::COEFF_EXTENSION_MINUTES_BY_EXTENSION_QUERY) + ); - if ($this->extendCloseDate($extendedCloseDate) === null) { - $this->extendLatestBidCloseDate(null); + if ($this->extendCloseTick($extendedCloseTick) === null) { + $this->extendLatestBidCloseTick(null); $this->applyDB(); return false; } @@ -509,12 +509,13 @@ abstract class Auction //TODO: 전역 알림이 나타나야한다. 일반 메시지보다는 중요하고, 메시지보단 약하게.. //TODO: 바로가기를 제공하는 편이 좋을 것 같다. + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $msg = new Message( MessageType::private, $src, $dest, $failReason, - new \DateTime(), + DateTime::createFromImmutable($clock->tickToDateTime($clock->nowTick())), new \DateTime('9999-12-31'), [] ); diff --git a/hwe/sammo/AuctionBasicResource.php b/hwe/sammo/AuctionBasicResource.php index 4302a1b8..fc6cb427 100644 --- a/hwe/sammo/AuctionBasicResource.php +++ b/hwe/sammo/AuctionBasicResource.php @@ -56,10 +56,11 @@ abstract class AuctionBasicResource extends Auction } - $now = new \DateTimeImmutable(); $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); + $nowTick = $clock->nowTick(); $turnTerm = $gameStor->getValue('turnterm'); - $closeDate = $now->add(TimeUtil::secondsToDateInterval($closeTurnCnt * $turnTerm * 60)); + $closeTick = $nowTick + GameClock::TICKS_PER_TURN * $closeTurnCnt; $openResult = static::openAuction(new AuctionInfo( null, @@ -68,8 +69,8 @@ abstract class AuctionBasicResource extends Auction "$amount", $general->getId(), $bidderRes, - $now, - $closeDate, + $nowTick, + $closeTick, new AuctionInfoDetail( "{$hostResName} {$amount} 경매", $general->getName(), @@ -145,12 +146,13 @@ abstract class AuctionBasicResource extends Auction //TODO: 전역 알림이 나타나야한다. 일반 메시지보다는 중요하고, 메시지보단 약하게.. //TODO: 바로가기를 제공하는 편이 좋을 것 같다. + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); $msg = new Message( MessageType::private, $src, $dest, "{$this->auctionID}번 {$hostResName} 경매에 입찰이 없어 취소되었습니다.", - new \DateTime(), + \DateTime::createFromImmutable($clock->tickToDateTime($clock->nowTick())), new \DateTime('9999-12-31'), [] ); @@ -246,8 +248,8 @@ abstract class AuctionBasicResource extends Auction $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $turnTerm = $gameStor->getValue('turnterm'); - $date = (new DateTimeImmutable())->add(TimeUtil::secondsToDateInterval($turnTerm * 60)); - $this->shrinkCloseDate($date); + $clock = GameClock::fromStorage($gameStor); + $this->shrinkCloseTick($clock->nowTick() + GameClock::TICKS_PER_TURN); } return null; diff --git a/hwe/sammo/AuctionUniqueItem.php b/hwe/sammo/AuctionUniqueItem.php index 069bd346..74231ad4 100644 --- a/hwe/sammo/AuctionUniqueItem.php +++ b/hwe/sammo/AuctionUniqueItem.php @@ -72,16 +72,15 @@ class AuctionUniqueItem extends Auction $gameStor = KVStorage::getStorage($db, 'game_env'); - $now = new DateTimeImmutable(); - [$turnTerm, $year, $month] = $gameStor->getValuesAsArray(['turnterm', 'year', 'month']); - - $closeDate = $now->add(TimeUtil::secondsToDateInterval( - max(static::MIN_AUCTION_CLOSE_MINUTES, $turnTerm * static::COEFF_AUCTION_CLOSE_MINUTES) * 60 - )); - $availableLatestBidCloseDate = $closeDate->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_LIMIT_BY_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_BY_BID) * 60 - )); + $clock = GameClock::fromStorage($gameStor); + $nowTick = $clock->nowTick(); + $closeTick = $nowTick + $clock->ticksFromMinutes( + max(static::MIN_AUCTION_CLOSE_MINUTES, $turnTerm * static::COEFF_AUCTION_CLOSE_MINUTES) + ); + $availableLatestBidCloseTick = $closeTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_LIMIT_BY_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_BY_BID) + ); $info = new AuctionInfo( null, @@ -90,8 +89,8 @@ class AuctionUniqueItem extends Auction $itemKey, $general->getID(), ResourceType::inheritancePoint, - $now, - $closeDate, + $nowTick, + $closeTick, new AuctionInfoDetail( "{$item->getName()} 경매", static::genObfuscatedName($general->getID()), @@ -100,7 +99,7 @@ class AuctionUniqueItem extends Auction $startAmount, null, 1, - $availableLatestBidCloseDate, + $availableLatestBidCloseTick, ) ); @@ -267,21 +266,22 @@ class AuctionUniqueItem extends Auction if ($availableEquipUniqueCnt <= 0) { $turnTerm = $gameStor->getValue('turnterm'); + $clock = GameClock::fromStorage($gameStor); //제한에 걸렸다면 자동 연장 - $extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_CNT) * 60 - )); + $extendedCloseTick = $this->info->closeTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_CNT) + ); if($bidder->getID() != $this->info->hostGeneralID){ $this->setHostAsNeutral(); } - $this->extendCloseDate($extendedCloseDate, true); - $this->extendLatestBidCloseDate(null); + $this->extendCloseTick($extendedCloseTick, true); + $this->extendLatestBidCloseTick(null); $this->applyDB(); return '유니크 아이템 소유 제한 상태입니다. 종료 시간이 연장됩니다.'; } - $isExtendCloseDateRequired = false; + $isExtendCloseTickRequired = false; foreach (GameConst::$allItems as $itemType => $itemList) { //아직은 그런 경우는 없지만 동일 유니크를 여러 부위에 장착할 수 있을지도 모름 if (!key_exists($itemKey, $itemList)) { @@ -291,13 +291,13 @@ class AuctionUniqueItem extends Auction $ownItem = $general->getItem($itemType); if ($ownItem->getRawClassName() == $itemKey) { //FIXME: 이 경우에는 환불이 되던가 해야함. - $isExtendCloseDateRequired = true; + $isExtendCloseTickRequired = true; $reasons[] = '이미 그 유니크를 가지고 있습니다.'; continue; } if (!$ownItem->isBuyable()) { - $isExtendCloseDateRequired = true; + $isExtendCloseTickRequired = true; $reasons[] = '이미 다른 유니크를 가지고 있습니다.'; continue; } @@ -313,18 +313,19 @@ class AuctionUniqueItem extends Auction } if (!$availableItemTypes) { - if ($isExtendCloseDateRequired) { + if ($isExtendCloseTickRequired) { $turnTerm = $gameStor->getValue('turnterm'); + $clock = GameClock::fromStorage($gameStor); //동일 부위 제한에 걸렸다면 자동 연장 - $extendedCloseDate = $this->info->closeDate->add(TimeUtil::secondsToDateInterval( - max(static::MIN_EXTENSION_MINUTES_LIMIT_BY_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_BY_BID) * 60 - )); + $extendedCloseTick = $this->info->closeTick + $clock->ticksFromMinutes( + max(static::MIN_EXTENSION_MINUTES_LIMIT_BY_BID, $turnTerm * static::COEFF_EXTENSION_MINUTES_LIMIT_BY_BID) + ); if($bidder->getID() != $this->info->hostGeneralID){ $this->setHostAsNeutral(); } - $this->extendCloseDate($extendedCloseDate, true); - $this->extendLatestBidCloseDate(null); + $this->extendCloseTick($extendedCloseTick, true); + $this->extendLatestBidCloseTick(null); $this->applyDB(); } return join(' ', $reasons); diff --git a/hwe/sammo/Command/Nation/che_발령.php b/hwe/sammo/Command/Nation/che_발령.php index f8598585..3d6af631 100644 --- a/hwe/sammo/Command/Nation/che_발령.php +++ b/hwe/sammo/Command/Nation/che_발령.php @@ -159,7 +159,7 @@ class che_발령 extends Command\NationCommand $destGeneral->getLogger()->pushGeneralActionLog("{$generalName}에 의해 {$destCityName}{$josaRo} 발령됐습니다. <1>$date"); $yearMonth = Util::joinYearMonth($this->env['year'], $this->env['month']); - if (cutTurn($general->getTurnTime(), $this->env['turnterm']) != cutTurn($destGeneral->getTurnTime(), $this->env['turnterm'])) { + if (cutTurn($general->getTurnTick(), $this->env['turnterm']) != cutTurn($destGeneral->getTurnTick(), $this->env['turnterm'])) { $yearMonth += 1; } $destGeneral->setAuxVar('last발령', $yearMonth); diff --git a/hwe/sammo/Command/Nation/che_천도.php b/hwe/sammo/Command/Nation/che_천도.php index ac871db3..1e0ef891 100644 --- a/hwe/sammo/Command/Nation/che_천도.php +++ b/hwe/sammo/Command/Nation/che_천도.php @@ -137,7 +137,7 @@ class che_천도 extends Command\NationCommand $nationID = $general->getNationID(); $nationStor = \sammo\KVStorage::getStorage(DB::db(), $nationID, 'nation_env'); - $nationStor->last천도Trial = [$general->getVar('officer_level'), $general->getTurnTime()]; + $nationStor->last천도Trial = [$general->getVar('officer_level'), $general->getTurnTick()]; if ($lastTurn->getCommand() != $commandName || $lastTurn->getArg() !== $this->arg) { $this->setResultTurn(new LastTurn( diff --git a/hwe/sammo/DTO/AuctionInfo.php b/hwe/sammo/DTO/AuctionInfo.php index dc822137..61aff7c3 100644 --- a/hwe/sammo/DTO/AuctionInfo.php +++ b/hwe/sammo/DTO/AuctionInfo.php @@ -2,11 +2,9 @@ namespace sammo\DTO; -use LDTO\Attr\Convert; use LDTO\Attr\JsonString; use LDTO\Attr\NullIsUndefined; use LDTO\Attr\RawName; -use LDTO\Converter\DateTimeConverter; use sammo\Enums\AuctionType; use sammo\Enums\ResourceType; @@ -23,12 +21,10 @@ class AuctionInfo extends \LDTO\DTO #[RawName('req_resource')] public ResourceType $reqResource, - #[RawName('open_date')] - #[Convert(DateTimeConverter::class)] - public \DateTimeImmutable $openDate, - #[RawName('close_date')] - #[Convert(DateTimeConverter::class)] - public \DateTimeImmutable $closeDate, + #[RawName('open_tick')] + public int $openTick, + #[RawName('close_tick')] + public int $closeTick, #[JsonString] public AuctionInfoDetail $detail, diff --git a/hwe/sammo/DTO/AuctionInfoDetail.php b/hwe/sammo/DTO/AuctionInfoDetail.php index 4f1c9f68..8b27cbd1 100644 --- a/hwe/sammo/DTO/AuctionInfoDetail.php +++ b/hwe/sammo/DTO/AuctionInfoDetail.php @@ -2,9 +2,7 @@ namespace sammo\DTO; -use LDTO\Attr\Convert; use LDTO\Attr\NullIsUndefined; -use LDTO\Converter\DateTimeConverter; class AuctionInfoDetail extends \LDTO\DTO { @@ -21,8 +19,7 @@ class AuctionInfoDetail extends \LDTO\DTO #[NullIsUndefined] public ?int $remainCloseDateExtensionCnt, #[NullIsUndefined] - #[Convert(DateTimeConverter::class)] - public ?\DateTimeImmutable $availableLatestBidCloseDate, + public ?int $availableLatestBidCloseTick, ) { } } diff --git a/hwe/sammo/DTO/GeneralAccessLog.php b/hwe/sammo/DTO/GeneralAccessLog.php index d70731f6..f1210726 100644 --- a/hwe/sammo/DTO/GeneralAccessLog.php +++ b/hwe/sammo/DTO/GeneralAccessLog.php @@ -2,10 +2,8 @@ namespace sammo\DTO; -use LDTO\Attr\Convert; use LDTO\Attr\NullIsUndefined; use LDTO\Attr\RawName; -use LDTO\Converter\DateTimeConverter; class GeneralAccessLog extends \LDTO\DTO { @@ -20,8 +18,7 @@ class GeneralAccessLog extends \LDTO\DTO public ?int $userID, #[RawName('last_refresh')] - #[Convert(DateTimeConverter::class)] - public \DateTimeImmutable $lastRefresh, + public ?int $lastRefresh, public int $refresh, @@ -35,4 +32,4 @@ class GeneralAccessLog extends \LDTO\DTO public int $refreshScoreTotal, ) { } -} \ No newline at end of file +} diff --git a/hwe/sammo/DiplomaticMessage.php b/hwe/sammo/DiplomaticMessage.php index 7d9a2596..a439efa1 100644 --- a/hwe/sammo/DiplomaticMessage.php +++ b/hwe/sammo/DiplomaticMessage.php @@ -49,7 +49,7 @@ class DiplomaticMessage extends Message{ $this->validDiplomacy = false; } - if($this->validUntil < (new \DateTime())){ + if($this->validUntil < $this->date){ $this->validDiplomacy = false; } } @@ -281,4 +281,4 @@ class DiplomaticMessage extends Message{ return self::DECLINED; } -} \ No newline at end of file +} diff --git a/hwe/sammo/General.php b/hwe/sammo/General.php index 43a35816..3d7ec035 100644 --- a/hwe/sammo/General.php +++ b/hwe/sammo/General.php @@ -281,16 +281,13 @@ class General extends GeneralBase implements iAction $this->calcCache[$cacheKey] = $result; return $result; } - $recwar = new \DateTimeImmutable($this->getVar('recent_war')); - $turnNow = new \DateTimeImmutable($this->getVar('turntime')); - $secDiff = TimeUtil::DateIntervalToSeconds($recwar->diff($turnNow)); - - if ($secDiff <= 0) { - $this->calcCache[$cacheKey] = 0; - return 0; - } - - $result = intdiv(Util::toInt($secDiff), 60 * $turnTerm); + $tickDiff = Util::toInt($this->getVar('turntime')) - Util::toInt($this->getVar('recent_war')); + if ($tickDiff <= 0) { + $this->calcCache[$cacheKey] = 0; + return 0; + } + + $result = intdiv($tickDiff, GameClock::TICKS_PER_TURN); $this->calcCache[$cacheKey] = $result; return $result; } diff --git a/hwe/sammo/GeneralAI.php b/hwe/sammo/GeneralAI.php index 5364c7b1..9a54f03d 100644 --- a/hwe/sammo/GeneralAI.php +++ b/hwe/sammo/GeneralAI.php @@ -302,7 +302,7 @@ class GeneralAI $this->calcWarRoute(); $troopCandidate = []; - $chiefTurn = cutTurn($this->general->getTurnTime(), $this->env['turnterm']); + $chiefTurn = cutTurn($this->general->getTurnTick(), $this->env['turnterm']); $yearMonth = Util::joinYearMonth($this->env['year'], $this->env['month']); foreach ($this->troopLeaders as $troopLeader) { @@ -319,7 +319,7 @@ class GeneralAI $last발령 = $troopLeader->getAuxVar('last발령'); if ($last발령) { - $leaderTurn = cutTurn($troopLeader->getTurnTime(), $this->env['turnterm']); + $leaderTurn = cutTurn($troopLeader->getTurnTick(), $this->env['turnterm']); $compYearMonth = $yearMonth; if ($chiefTurn < $leaderTurn) { $compYearMonth += 1; @@ -405,7 +405,7 @@ class GeneralAI return null; } - $chiefTurn = cutTurn($this->general->getTurnTime(), $this->env['turnterm']); + $chiefTurn = cutTurn($this->general->getTurnTick(), $this->env['turnterm']); $yearMonth = Util::joinYearMonth($this->env['year'], $this->env['month']); $troopCandidate = []; @@ -428,7 +428,7 @@ class GeneralAI $last발령 = $troopLeader->getAuxVar('last발령'); if ($last발령) { - $leaderTurn = cutTurn($troopLeader->getTurnTime(), $this->env['turnterm']); + $leaderTurn = cutTurn($troopLeader->getTurnTick(), $this->env['turnterm']); $compYearMonth = $yearMonth; if ($chiefTurn < $leaderTurn) { $compYearMonth += 1; @@ -586,8 +586,8 @@ class GeneralAI continue; } - $generalTurnTime = $userGeneral->getTurnTime(); - $troopTurnTime = $troopLeader->getTurnTime(); + $generalTurnTime = $userGeneral->getTurnTick(); + $troopTurnTime = $troopLeader->getTurnTick(); if ($generalTurnTime < $troopTurnTime) { //NOTE: 어차피 수뇌 턴이 제일 빠르다 $generalCadidates[$generalID] = $userGeneral; @@ -782,7 +782,7 @@ class GeneralAI if ( key_exists($troopLeader->getCityID(), $this->supplyCities) && - $this->troopLeaders[$troopID]->getTurnTime() < $lostGeneral->getTurnTime() + $this->troopLeaders[$troopID]->getTurnTick() < $lostGeneral->getTurnTick() ) { //이미 탈출 가능한 부대를 탔다 continue; @@ -1986,7 +1986,7 @@ class GeneralAI if ($lastTurn->getCommand() === '천도' && $lastTurn->getArg()['destCityID'] != $this->nation['capital']) { $cmd = buildNationCommandClass('che_천도', $this->general, $this->env, $lastTurn, $lastTurn->getArg()); if ($cmd->hasFullConditionMet()) { - $nationStor->last천도Trial = [$general->getVar('officer_level'), $general->getTurnTime()]; + $nationStor->last천도Trial = [$general->getVar('officer_level'), $general->getTurnTick()]; $this->reqUpdateInstance = true; return $cmd; } @@ -1995,12 +1995,9 @@ class GeneralAI $lastTrial = $nationStor->last천도Trial; if ($lastTrial) { [$lastTrialLevel, $lastTrialTurnTime] = $lastTrial; - $timeDiffSeconds = TimeUtil::DateIntervalToSeconds( - date_create_immutable($lastTrialTurnTime)->diff( - date_create_immutable($general->getTurnTime()) - ) - ); - if ($timeDiffSeconds < $turnTerm * 30 && $lastTrialLevel !== $general->getVar('officer_level')) { //0.5Turn + $timeDiffTick = abs($general->getTurnTick() - Util::toInt($lastTrialTurnTime)); + if ($timeDiffTick < intdiv(GameClock::TICKS_PER_TURN, 2) + && $lastTrialLevel !== $general->getVar('officer_level')) { //0.5Turn return null; } } @@ -2109,7 +2106,7 @@ class GeneralAI } - $nationStor->last천도Trial = [$general->getVar('officer_level'), $general->getTurnTime()]; + $nationStor->last천도Trial = [$general->getVar('officer_level'), $general->getTurnTick()]; $this->reqUpdateInstance = true; return $cmd; } diff --git a/hwe/sammo/GeneralBase.php b/hwe/sammo/GeneralBase.php index 9b9c6757..8a3bf621 100644 --- a/hwe/sammo/GeneralBase.php +++ b/hwe/sammo/GeneralBase.php @@ -54,14 +54,24 @@ abstract class GeneralBase ); } - function getTurnTime(int $short = self::TURNTIME_FULL_MS): ?string - { - if(!key_exists('turntime', $this->raw)){ - return null; - } - - return [ - self::TURNTIME_FULL_MS => function ($turntime) { + function getTurnTime(int $short = self::TURNTIME_FULL_MS): ?string + { + if(!key_exists('turntime', $this->raw)){ + return null; + } + + $rawTurnTime = $this->getVar('turntime'); + // 비교용 Dummy와 과거 fixture는 문자열을 잠시 허용하되 제품 DB는 tick만 사용합니다. + if (is_string($rawTurnTime) && !ctype_digit(ltrim($rawTurnTime, '-'))) { + $formattedTurnTime = $rawTurnTime; + } else { + $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $formattedTurnTime = $clock->formatTick(Util::toInt($rawTurnTime), true); + } + + return [ + self::TURNTIME_FULL_MS => function ($turntime) { return $turntime; }, self::TURNTIME_FULL => function ($turntime) { @@ -73,8 +83,21 @@ abstract class GeneralBase self::TURNTIME_HM => function ($turntime) { return substr($turntime, 11, 5); }, - ][$short]($this->getVar('turntime')); - } + ][$short]($formattedTurnTime); + } + + function getTurnTick(): ?int + { + if (!key_exists('turntime', $this->raw)) { + return null; + } + $rawTurnTime = $this->getVar('turntime'); + if (is_string($rawTurnTime) && !ctype_digit(ltrim($rawTurnTime, '-'))) { + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + return $clock->dateTimeToTick(new \DateTimeImmutable($rawTurnTime)); + } + return Util::toInt($rawTurnTime); + } function getNPCType(): int { diff --git a/hwe/sammo/GeneralPool/RandomNameGeneral.php b/hwe/sammo/GeneralPool/RandomNameGeneral.php index ffdd7012..0afc852e 100644 --- a/hwe/sammo/GeneralPool/RandomNameGeneral.php +++ b/hwe/sammo/GeneralPool/RandomNameGeneral.php @@ -59,7 +59,7 @@ class RandomNameGeneral extends AbsGeneralPool{ 'generalName'=>$generalName, 'imgsvr'=>0, 'picture'=>null - ], '9999-12-31 12:00:00'); + ], PHP_INT_MAX); } static public function pickGeneralFromPool(MeekroDB $db, RandUtil $rng, int $owner, int $pickCnt, ?string $prefix = null): array @@ -68,26 +68,25 @@ class RandomNameGeneral extends AbsGeneralPool{ $result = []; $dbInsert = []; - $oNow = new \DateTimeImmutable(); - - for($i=0;$i<$pickCnt;$i++){ $result[] = static::pickGeneral1FromPool($db, $rng, $owner, $prefix); } if($owner){ - $now = $oNow->format('Y-m-d H:i:s'); + $gameStor = \sammo\KVStorage::getStorage($db, 'game_env'); + $clock = \sammo\GameClock::fromStorage($gameStor); + $now = $clock->nowTick(); $db->delete('select_pool', [ 'reserved_until'=>null, 'owner'=>null, ],'(reserved_until < %s OR reserved_until IS NULL) AND general_id IS null', $now); - $validUntil = $oNow->add(new \DateInterval(sprintf('PT%dS', 30))); + $validUntil = $now + $clock->ticksFromSeconds(30); foreach($result as $pickedGeneral){ $dbInsert[] = [ 'owner'=>$owner, 'uniqueName'=>$pickedGeneral->getUniqueName(), 'info'=>$pickedGeneral->getInfo(), - 'reserved_until'=>$validUntil->format(('Y-m-d H:i:s')) + 'reserved_until'=>$validUntil ]; } $db->insert('select_pool', $dbInsert); @@ -99,4 +98,4 @@ class RandomNameGeneral extends AbsGeneralPool{ public static function initPool(\MeekroDB $db){ //do Nothing } -} \ No newline at end of file +} diff --git a/hwe/sammo/GeneralPool/SPoolUnderU100.php b/hwe/sammo/GeneralPool/SPoolUnderU100.php index 6a4becfe..3953acf6 100644 --- a/hwe/sammo/GeneralPool/SPoolUnderU100.php +++ b/hwe/sammo/GeneralPool/SPoolUnderU100.php @@ -14,7 +14,7 @@ class SPoolUnderU100 extends AbsFromUserPool private const STAT_BONUS_MAX_TOTAL = 190; private const STAT_BONUS_MAX_MULTIPLIER = 1.5; - public function __construct(\MeekroDB $db, RandUtil $rng, array $info, string $validUntil) + public function __construct(\MeekroDB $db, RandUtil $rng, array $info, int $validUntil) { $targetInfo = $info; $initialInfo = $info; diff --git a/hwe/sammo/Message.php b/hwe/sammo/Message.php index c83ad34b..169e4103 100644 --- a/hwe/sammo/Message.php +++ b/hwe/sammo/Message.php @@ -104,6 +104,7 @@ class Message public static function buildFromArray(array $row) : Message { + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); $dbMessage = Json::decode($row['message']); $msgType = MessageType::from($row['type']); @@ -116,8 +117,8 @@ class Message $src, $dest, $dbMessage['text'], - new \DateTime($row['time']), - new \DateTime($row['valid_until']), + \DateTime::createFromImmutable($clock->tickToDateTime(Util::toInt($row['time']))), + \DateTime::createFromImmutable($clock->tickToDateTime(Util::toInt($row['valid_until']))), $option ]; @@ -151,9 +152,12 @@ class Message public static function getMessageByID(int $messageID) : ?Message { $db = DB::db(); - $now = new \DateTime(); - $row = $db->queryFirstRow('SELECT * FROM `message` WHERE `id` = %i AND valid_until', $messageID); - //FIXME: $now가 들어가야 하는데 안 들어가있는데? + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $row = $db->queryFirstRow( + 'SELECT * FROM `message` WHERE `id` = %i AND valid_until > %i', + $messageID, + $clock->nowTick(), + ); if (!$row) { return null; } @@ -171,12 +175,12 @@ class Message { $db = DB::db(); - $date = (new \DateTime())->format('Y-m-d H:i:s'); + $date = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->nowTick(); $where = new \WhereClause('and'); $where->add('mailbox = %i', $mailbox); $where->add('type = %s', $msgType->value); - $where->add('valid_until > %s', $date); + $where->add('valid_until > %i', $date); if ($fromSeq > 0) { $where->add('id >= %i', $fromSeq); } @@ -203,12 +207,12 @@ class Message { $db = DB::db(); - $date = (new \DateTime())->format('Y-m-d H:i:s'); + $date = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->nowTick(); $where = new \WhereClause('and'); $where->add('mailbox = %i', $mailbox); $where->add('type = %s', $msgType->value); - $where->add('valid_until > %s', $date); + $where->add('valid_until > %i', $date); $where->add('id < %i', $toSeq); if ($limit > 0) { @@ -236,7 +240,8 @@ class Message return '시스템 외교 메시지는 삭제할 수 없습니다.'; } - $prev5min = new \DateTime(); + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + $prev5min = \DateTime::createFromImmutable($clock->tickToDateTime($clock->nowTick())); $prev5min->sub(new \DateInterval('PT5M')); if($msgObj->date < $prev5min){ @@ -265,14 +270,15 @@ class Message } - $in1min = new \DateTime(); + $now = \DateTime::createFromImmutable($clock->tickToDateTime($clock->nowTick())); + $in1min = clone $now; $in1min->add(new \DateInterval('PT1M')); $newMsg = new Message( $msgObj->msgType, $msgObj->src, $msgObj->dest, "req_del_msg", - new \DateTime(), + $now, $in1min, $msgOption ); @@ -300,13 +306,23 @@ class Message $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $timeTick = $clock->nowTick(); + if (Util::toInt($this->validUntil->format('Y')) >= 9000) { + $validUntilTick = GameClock::MAX_SAFE_TICK; + } else { + $validitySeconds = $this->validUntil->getTimestamp() - $this->date->getTimestamp(); + $validUntilTick = $timeTick + $clock->ticksFromSeconds($validitySeconds); + } + $this->date = \DateTime::createFromImmutable($clock->tickToDateTime($timeTick)); + $this->validUntil = \DateTime::createFromImmutable($clock->tickToDateTime($validUntilTick)); $db->insert('message', [ 'mailbox' => $mailbox, 'type' => $this->msgType->value, 'src' => $src_id, 'dest' => $dest_id, - 'time' => $this->date->format('Y-m-d H:i:s'), - 'valid_until' => $this->validUntil->format('Y-m-d H:i:s'), + 'time' => $timeTick, + 'valid_until' => $validUntilTick, 'message' => Json::encode([ 'src'=>($this->src)?($this->src->toArray()):[], 'dest'=>($this->dest)?($this->dest->toArray()):[], @@ -489,7 +505,8 @@ class Message 'text' => $this->msg, 'option' => $this->msgOption ]), - 'valid_until'=>$this->validUntil->format('Y-m-d H:i:s'), + 'valid_until'=>GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')) + ->dateTimeToTick($this->validUntil), ], 'id=%i', $this->id); } diff --git a/hwe/sammo/ResetHelper.php b/hwe/sammo/ResetHelper.php index 3842718f..905c6d2f 100644 --- a/hwe/sammo/ResetHelper.php +++ b/hwe/sammo/ResetHelper.php @@ -245,21 +245,35 @@ class ResetHelper{ true ); - if($sync == 0) { - // 현재 시간을 1월로 맞춤 - $starttime = cutTurn($turntime, $turnterm); - $month = 1; - $year = $startyear; - } else { - // 현재 시간과 동기화 - [$starttime, $yearPulled, $month] = cutDay($turntime, $turnterm); + $requestedTime = new \DateTimeImmutable($turntime); + if($sync == 0) { + // 현재 시간을 1월로 맞춤 + $baseTime = new \DateTimeImmutable(cutTurnDateTime($turntime, $turnterm)); + $month = 1; + $year = $startyear; + } else { + // 현재 시간과 동기화 + [$baseTimeString, $yearPulled, $month] = cutDay($turntime, $turnterm); + $baseTime = new \DateTimeImmutable($baseTimeString); if($yearPulled){ $year = $startyear-1; } else{ $year = $startyear; } - } + } + + $wallNow = GameClock::readWallTime(); + $initialClock = new GameClock( + $baseTime, + $turnterm, + 0, + GameClock::MODE_REALTIME, + $wallNow, + fn (): \DateTimeImmutable => $wallNow, + ); + $requestedTick = $initialClock->dateTimeToTick($requestedTime); + $currentTick = $initialClock->dateTimeToTick($wallNow); $killturn = 4800 / $turnterm; if($npcmode == 1) { $killturn = intdiv($killturn, 3); } @@ -283,10 +297,14 @@ class ResetHelper{ 'maxnation'=>GameConst::$defaultMaxNation, 'refreshLimit'=>30000, 'develcost'=>$develcost, - 'turntime'=>$turntime, - 'starttime'=>$starttime, - 'opentime'=>$turntime, - 'turnterm'=>$turnterm, + 'turntime'=>$requestedTick, + 'starttime'=>0, + 'opentime'=>$requestedTick, + 'turnterm'=>$turnterm, + 'clock_base_time'=>TimeUtil::format($baseTime, true), + 'clock_tick'=>$currentTick, + 'clock_mode'=>GameClock::MODE_REALTIME, + 'clock_wall_anchor'=>TimeUtil::format($wallNow, true), 'killturn'=>$killturn, 'genius'=>GameConst::$defaultMaxGenius, 'show_img_level'=>$show_img_level, @@ -308,7 +326,7 @@ class ResetHelper{ 'name'=>$admin['name'], 'picture'=>$admin['picture'], 'imgsvr'=>$admin['imgsvr'], - 'turntime'=>$turntime, + 'turntime'=>$requestedTick, 'killturn'=>9999, 'crewtype'=>GameUnitConst::DEFAULT_CREWTYPE ]); @@ -344,7 +362,7 @@ class ResetHelper{ $db->insert('ng_games', [ 'server_id'=>$serverID, - 'date'=>$turntime, + 'date'=>TimeUtil::format($requestedTime, false), 'winner_nation'=>null, 'map'=>$scenarioObj->getMapTheme(), 'season'=>$seasonIdx, @@ -368,4 +386,4 @@ class ResetHelper{ 'result'=>true ]; } -} \ No newline at end of file +} diff --git a/hwe/sammo/Scenario/GeneralBuilder.php b/hwe/sammo/Scenario/GeneralBuilder.php index 2ed6e8f7..498152bb 100644 --- a/hwe/sammo/Scenario/GeneralBuilder.php +++ b/hwe/sammo/Scenario/GeneralBuilder.php @@ -653,7 +653,7 @@ class GeneralBuilder{ $officerLevel = $nationID?1:0; } - $turntime = \sammo\getRandTurn($this->rng, $env['turnterm'], new \DateTimeImmutable($env['turntime'])); + $turntime = \sammo\getRandTurn($this->rng, $env['turnterm'], Util::toInt($env['turntime'])); if($this->killturn){ $killturn = $this->killturn; diff --git a/hwe/sammo/ScoutMessage.php b/hwe/sammo/ScoutMessage.php index 6b847d64..e70aff45 100644 --- a/hwe/sammo/ScoutMessage.php +++ b/hwe/sammo/ScoutMessage.php @@ -35,7 +35,7 @@ class ScoutMessage extends Message $this->validScout = false; } - if ($this->validUntil <= new \DateTime()) { + if ($this->validUntil <= $this->date) { $this->validScout = false; } } @@ -122,11 +122,11 @@ class ScoutMessage extends Message public static function invalidateAll(int $generalID, ?int $exceptMsgID = null) { $db = DB::db(); - $now = TimeUtil::now(); + $now = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->nowTick(); //XXX: 뭔가 기존 쿼리가 애매하다. invalid 관련해서 다른 옵션이 가능한가? $rawMsgList = Util::convertArrayToDict($db->query( 'SELECT * FROM `message` WHERE - `mailbox` = %i AND `type` = "private" AND `dest` = `mailbox` AND `valid_until` > %s AND + `mailbox` = %i AND `type` = "private" AND `dest` = `mailbox` AND `valid_until` > %i AND JSON_VALUE(message, "$.option.action") = %s', $generalID, $now, diff --git a/hwe/sammo/ServerTool.php b/hwe/sammo/ServerTool.php index 3188b3f7..e4451a98 100644 --- a/hwe/sammo/ServerTool.php +++ b/hwe/sammo/ServerTool.php @@ -40,34 +40,22 @@ final class ServerTool $locked = tryLock(); } - $oldunit = $admin['turnterm'] * 60; - $unit = $turnterm * 60; - - if($unit == $oldunit){ + if($turnterm == $admin['turnterm']){ if($locked){ unlock(); } return null; } - $unitDiff = $unit / $oldunit; - - $servTurnTime = new \DateTimeImmutable($admin['turntime']); - foreach ($db->query('SELECT no,turntime FROM general') as $gen) { - $genTurnTime = new \DateTimeImmutable($gen['turntime']); - $timeDiff = TimeUtil::DateIntervalToSeconds($genTurnTime->diff($servTurnTime)); - $timeDiff *= $unitDiff; - $newGenTurnTime = $servTurnTime->add(TimeUtil::secondsToDateInterval($timeDiff)); - - $db->update('general', [ - 'turntime' => $newGenTurnTime->format('Y-m-d H:i:s.u') - ], 'no=%i', $gen['no']); - } - $turn = ($admin['year'] - $admin['startyear']) * 12 + $admin['month'] - 1; - $starttime = $servTurnTime->sub(TimeUtil::secondsToDateInterval($turn * $unit))->format('Y-m-d H:i:s'); - $starttime = cutTurn($starttime, $turnterm, false); + $oldClock = GameClock::fromStorage($gameStor); + $currentTick = $oldClock->nowTick(); + $currentDisplay = $oldClock->tickToDateTime($currentTick); + $oldClock->persistTick($gameStor, $currentTick); $gameStor->turnterm = $turnterm; - $gameStor->starttime = $starttime; + $gameStor->clock_base_time = TimeUtil::format( + GameClock::baseTimeForProjection($currentDisplay, $currentTick, $turnterm), + true, + ); pushGlobalHistoryLog(["★턴시간이 {$turnterm}분으로 변경됩니다."]); if($locked){ diff --git a/hwe/sammo/TurnExecutionHelper.php b/hwe/sammo/TurnExecutionHelper.php index 5a4a98a8..761af2ef 100644 --- a/hwe/sammo/TurnExecutionHelper.php +++ b/hwe/sammo/TurnExecutionHelper.php @@ -215,14 +215,13 @@ class TurnExecutionHelper $general->rebirth(); } - $turntime = addTurn($general->getTurnTime(), $gameStor->turnterm); + $turntime = addTurn($general->getTurnTick(), $gameStor->turnterm); $nextTurnTimeBase = $general->getAuxVar('nextTurnTimeBase'); if($nextTurnTimeBase !== null){ $turntime = cutTurn($turntime, $gameStor->turnterm); - $turntimeObj = new \DateTimeImmutable($turntime); - $turntimeObj = $turntimeObj->add(TimeUtil::secondsToDateInterval($nextTurnTimeBase)); - $turntime = TimeUtil::format($turntimeObj, true); + $clock = GameClock::fromStorage($gameStor); + $turntime += $clock->ticksFromSeconds($nextTurnTimeBase); $general->setAuxVar('nextTurnTimeBase', null); } @@ -230,7 +229,7 @@ class TurnExecutionHelper } - static public function executeGeneralCommandUntil(string $date, \DateTimeInterface $limitActionTime, int $year, int $month) + static public function executeGeneralCommandUntil(int $date, \DateTimeInterface $limitActionTime, int $year, int $month) { $db = DB::db(); $generalsTodo = $db->query( @@ -244,7 +243,7 @@ class TurnExecutionHelper $autorun_user = $gameStor->autorun_user; foreach ($generalsTodo as $rawGeneral) { - $currActionTime = new \DateTimeImmutable(); + $currActionTime = GameClock::readWallTime(); if ($currActionTime > $limitActionTime) { return [true, $currentTurn]; } @@ -350,7 +349,7 @@ class TurnExecutionHelper pullNationCommand($general->getVar('nation'), $general->getVar('officer_level')); pullGeneralCommand($general->getID()); - $currentTurn = $general->getTurnTime(); + $currentTurn = $general->getTurnTick(); $general->increaseVarWithLimit('myset', GameConst::$incDefSettingChange, null, GameConst::$maxDefSettingChange); if (($autorun_user['limit_minutes'] ?? false) && $general->getNPCType() < 2 && $hasReservedTurn) { @@ -390,13 +389,14 @@ class TurnExecutionHelper return true; } - static public function executeAllCommand(&$executed = false, &$locked = false): string + static public function executeAllCommand(&$executed = false, &$locked = false): int { $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); - if (TimeUtil::now(true) < $gameStor->turntime) { + $clock = GameClock::fromStorage($gameStor); + if ($clock->nowTick() < $gameStor->turntime) { //턴 시각 이전이면 아무것도 하지 않음 return $gameStor->turntime; } @@ -420,7 +420,7 @@ class TurnExecutionHelper //접속자 수 따라서 갱신제한 변경 CheckOverhead(); - $date = TimeUtil::now(true); + $date = $clock->nowTick(); // 최종 처리 월턴의 다음 월턴시간 구함 //$lastExecuted = $gameStor->turntime; $prevTurn = cutTurn($gameStor->turntime, $gameStor->turnterm); @@ -433,7 +433,7 @@ class TurnExecutionHelper $maxActionTime = max($maxActionTime * 2 / 3, $maxActionTime - 10); } - $limitActionTime = (new \DateTimeImmutable())->add(TimeUtil::secondsToDateInterval($maxActionTime)); + $limitActionTime = GameClock::readWallTime()->add(TimeUtil::secondsToDateInterval($maxActionTime)); // 현재 턴 이전 월턴까지 모두처리. //최종 처리 이후 다음 월턴이 현재 시간보다 전이라면 diff --git a/hwe/sammo/WarUnitGeneral.php b/hwe/sammo/WarUnitGeneral.php index c9bb5e16..30cd47b6 100644 --- a/hwe/sammo/WarUnitGeneral.php +++ b/hwe/sammo/WarUnitGeneral.php @@ -57,16 +57,16 @@ class WarUnitGeneral extends WarUnit $this->general->increaseRankVar(RankColumn::warnum, 1); if ($this->isAttacker) { - $semiTurn = $general->getTurnTime(); + $semiTurn = $general->getTurnTick(); } else if ($oppose !== null) { - $semiTurn = $oppose->getGeneral()->getTurnTime(); + $semiTurn = $oppose->getGeneral()->getTurnTick(); } else { LogText("WarUnitGeneral::setOppose", "defender인데 oppose가 null {$general->getID()}, {$general->getTurnTime()}"); - $semiTurn = $general->getTurnTime(); + $semiTurn = $general->getTurnTick(); } $phase = $this->getRealPhase(); - $semiTurn = substr($semiTurn, 0, strlen($semiTurn) - 2); - $semiTurn .= sprintf("%02d", Util::valueFit($phase, 0, 99)); + $semiTurn -= $semiTurn % 100; + $semiTurn += Util::valueFit($phase, 0, 99); $general->setVar('recent_war', $semiTurn); } diff --git a/hwe/sql/schema.sql b/hwe/sql/schema.sql index ebe4ae75..db095f46 100644 --- a/hwe/sql/schema.sql +++ b/hwe/sql/schema.sql @@ -45,8 +45,8 @@ CREATE TABLE `general` ( `book` VARCHAR(20) NOT NULL DEFAULT 'None', `horse` VARCHAR(20) NOT NULL DEFAULT 'None', `item` VARCHAR(20) NOT NULL DEFAULT 'None', - `turntime` DATETIME(6) NOT NULL, - `recent_war` DATETIME(6) NULL DEFAULT NULL, + `turntime` BIGINT NOT NULL, + `recent_war` BIGINT NULL DEFAULT NULL, `makelimit` INT(2) NULL DEFAULT '0', `killturn` INT(3) NULL DEFAULT NULL, `block` INT(1) NULL DEFAULT '0', @@ -97,7 +97,7 @@ CREATE TABLE `general_access_log` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `general_id` INT(11) NOT NULL, `user_id` INT(11) NULL DEFAULT NULL, - `last_refresh` DATETIME NULL DEFAULT NULL, + `last_refresh` BIGINT NULL DEFAULT NULL, `refresh` INT(11) NOT NULL DEFAULT '0', `refresh_total` INT(11) NOT NULL DEFAULT '0', `refresh_score` INT(11) NOT NULL DEFAULT '0', @@ -244,8 +244,8 @@ CREATE TABLE `message` ( `type` ENUM('private', 'national', 'public', 'diplomacy') NOT NULL, `src` INT(11) NOT NULL, `dest` INT(11) NOT NULL, - `time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `valid_until` DATETIME NOT NULL DEFAULT '9999-12-31 23:59:59', + `time` BIGINT NOT NULL, + `valid_until` BIGINT NOT NULL, `message` TEXT NOT NULL COLLATE 'utf8mb4_bin', PRIMARY KEY (`id`), INDEX `by_mailbox` (`mailbox`, `type`, `id`), @@ -536,8 +536,8 @@ CREATE TABLE IF NOT EXISTS `reserved_open` ( CREATE TABLE `select_npc_token` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `owner` INT(11) NOT NULL, - `valid_until` DATETIME NOT NULL, - `pick_more_from` DATETIME NOT NULL, + `valid_until` BIGINT NOT NULL, + `pick_more_from` BIGINT NOT NULL, `pick_result` TEXT NOT NULL COLLATE 'utf8mb4_bin', `nonce` INT(11) NOT NULL, PRIMARY KEY (`id`), @@ -553,7 +553,7 @@ CREATE TABLE `select_pool` ( `unique_name` VARCHAR(20) NOT NULL, `owner` INT(11) NULL DEFAULT NULL, `general_id` INT(11) NULL DEFAULT NULL, - `reserved_until` DATETIME NULL DEFAULT NULL, + `reserved_until` BIGINT NULL DEFAULT NULL, `info` TEXT NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `unique_name` (`unique_name`), @@ -694,11 +694,11 @@ CREATE TABLE `ng_auction` ( `target` VARCHAR(50) NULL DEFAULT NULL COLLATE 'utf8mb4_bin', `host_general_id` INT(11) NOT NULL, `req_resource` ENUM('gold','rice','inheritPoint') NOT NULL COLLATE 'utf8mb4_bin', - `open_date` DATETIME NOT NULL, - `close_date` DATETIME NOT NULL, + `open_tick` BIGINT NOT NULL, + `close_tick` BIGINT NOT NULL, `detail` LONGTEXT NOT NULL COLLATE 'utf8mb4_bin', PRIMARY KEY (`id`) USING BTREE, - INDEX `by_close` (`finished`, `type`, `close_date`) USING BTREE, + INDEX `by_close` (`finished`, `type`, `close_tick`) USING BTREE, INDEX `by_general_id` (`host_general_id`, `type`, `finished`) USING BTREE, CONSTRAINT `detail` CHECK (json_valid(`detail`)) ) @@ -721,4 +721,4 @@ CREATE TABLE `ng_auction_bid` ( CONSTRAINT `aux` CHECK (json_valid(`aux`)) ) COLLATE='utf8mb4_general_ci' -ENGINE = Aria; \ No newline at end of file +ENGINE = Aria; diff --git a/scripts/README-game-clock.md b/scripts/README-game-clock.md new file mode 100644 index 00000000..729f8177 --- /dev/null +++ b/scripts/README-game-clock.md @@ -0,0 +1,41 @@ +# 논리 game clock 운영 + +게임 진행 시각은 `36,000,000 tick × 완료 턴 + 현재 세부 tick`으로 저장합니다. +표시 시각만 `game_env.clock_base_time`을 기준으로 달력 시각으로 투영합니다. + +## 기존 DB migration + +웹과 턴 daemon을 먼저 중지하고 HWE DB SQL dump를 만든 다음 상태를 확인합니다. + +```bash +php scripts/migrate-game-clock.php --status +php scripts/migrate-game-clock.php --apply --backup=/absolute/path/to/hwe-before-clock.sql +``` + +적용 명령은 비어 있지 않은 절대경로 backup과 `GAME` lock을 요구합니다. Aria +DDL은 transaction rollback이 되지 않으므로 기존 날짜 컬럼과 `game_env` 값은 +`*_wall_backup`으로 남깁니다. 새 코드 검증이 끝나기 전에는 이 값을 제거하지 +마세요. 복구할 때는 PHP/daemon을 중지하고 명령에 지정했던 전체 SQL dump를 +복원하는 것이 기준 절차입니다. + +## 시계 조회와 전진 + +```bash +php scripts/game-clock.php --status +php scripts/game-clock.php --advance-turns=12 --apply +php scripts/game-clock.php --advance-ticks=36000000 --apply +php scripts/game-clock.php --mode=realtime --apply +``` + +명시적으로 전진하면 시계는 `manual` 모드가 됩니다. 이 모드의 엔진은 실제 +시각을 읽지 않습니다. `realtime`으로 전환할 때 현재 논리 tick을 새 벽시계 +anchor에 고정하므로 표시 시각이 튀지 않습니다. + +격리 DB에서 manual clock과 엔진 진행을 함께 확인할 수 있습니다. + +```bash +php scripts/verify-game-clock-engine.php --apply --engine-calls=2 +``` + +이 검증기는 manual mode만 허용하고, 엔진 호출 전후 clock tick이 벽시계 때문에 +변하지 않았는지와 마지막 처리 tick이 현재 tick을 넘지 않았는지 검사합니다. diff --git a/scripts/game-clock.php b/scripts/game-clock.php new file mode 100644 index 00000000..fb6d8b13 --- /dev/null +++ b/scripts/game-clock.php @@ -0,0 +1,129 @@ +#!/usr/bin/env php +resetCache(); + $clock = GameClock::fromStorage($gameStor); + $nowTick = $clock->nowTick(); + $state = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm']); + printf( + "mode=%s game=%d-%02d now_tick=%d now=%s last_tick=%d last=%s turnterm=%dm ticks_per_turn=%d ticks_per_second=%d\n", + $clock->getMode(), + Util::toInt($state['year']), + Util::toInt($state['month']), + $nowTick, + $clock->formatTick($nowTick, true), + Util::toInt($state['turntime']), + $clock->formatTick(Util::toInt($state['turntime']), true), + Util::toInt($state['turnterm']), + GameClock::TICKS_PER_TURN, + $clock->ticksPerSecond(), + ); +} + +$options = getopt('', ['help', 'status', 'mode:', 'advance-turns:', 'advance-ticks:', 'apply']); +if (isset($options['help'])) { + gameClockUsage(); +} + +$commands = array_filter([ + 'status' => isset($options['status']), + 'mode' => array_key_exists('mode', $options), + 'advance-turns' => array_key_exists('advance-turns', $options), + 'advance-ticks' => array_key_exists('advance-ticks', $options), +]); +if (count($commands) !== 1) { + gameClockUsage(2); +} + +$db = DB::db(); +$gameStor = KVStorage::getStorage($db, 'game_env'); + +if (isset($commands['status'])) { + printGameClockState($gameStor); + exit(0); +} +if (!isset($options['apply'])) { + fwrite(STDERR, "변경 명령에는 --apply가 필요합니다.\n"); + exit(2); +} +if (!\sammo\tryLock()) { + fwrite(STDERR, "GAME lock을 획득하지 못했습니다.\n"); + exit(3); +} + +try { + $clock = GameClock::fromStorage($gameStor); + $currentTick = $clock->nowTick(); + + if (isset($commands['mode'])) { + $mode = (string)$options['mode']; + if (!in_array($mode, [GameClock::MODE_MANUAL, GameClock::MODE_REALTIME], true)) { + throw new InvalidArgumentException('--mode는 manual 또는 realtime이어야 합니다.'); + } + $clock->persistTick($gameStor, $currentTick, $mode); + } else { + if (isset($commands['advance-turns'])) { + $nextTick = $clock->addTurns($currentTick, signedIntegerOption($options, 'advance-turns')); + } else { + $nextTick = GameClock::addTicks($currentTick, signedIntegerOption($options, 'advance-ticks')); + } + $clock->persistTick($gameStor, $nextTick, GameClock::MODE_MANUAL); + } +} finally { + $gameStor->resetCache(); + \sammo\unlock(); +} + +printGameClockState($gameStor); diff --git a/scripts/migrate-game-clock.php b/scripts/migrate-game-clock.php new file mode 100644 index 00000000..fcca414d --- /dev/null +++ b/scripts/migrate-game-clock.php @@ -0,0 +1,340 @@ +#!/usr/bin/env php + */ +function tableColumnTypes(\MeekroDB $db, string $table): array +{ + $result = []; + foreach ($db->query("SHOW COLUMNS FROM %b", $table) as $column) { + $result[(string)$column['Field']] = strtolower((string)$column['Type']); + } + return $result; +} + +function isDateColumn(?string $type): bool +{ + return $type !== null && str_starts_with($type, 'datetime'); +} + +function isBigIntColumn(?string $type): bool +{ + return $type !== null && str_starts_with($type, 'bigint'); +} + +/** @return array{state:string,details:array>} */ +function inspectMigration(\MeekroDB $db): array +{ + $details = []; + foreach (['general', 'general_access_log', 'message', 'select_npc_token', 'select_pool', 'ng_auction'] as $table) { + $details[$table] = tableColumnTypes($db, $table); + } + + $old = isDateColumn($details['general']['turntime'] ?? null) + && isDateColumn($details['general']['recent_war'] ?? null) + && isDateColumn($details['general_access_log']['last_refresh'] ?? null) + && isDateColumn($details['message']['time'] ?? null) + && isDateColumn($details['message']['valid_until'] ?? null) + && isDateColumn($details['select_npc_token']['valid_until'] ?? null) + && isDateColumn($details['select_npc_token']['pick_more_from'] ?? null) + && isDateColumn($details['select_pool']['reserved_until'] ?? null) + && isDateColumn($details['ng_auction']['open_date'] ?? null) + && isDateColumn($details['ng_auction']['close_date'] ?? null); + $new = isBigIntColumn($details['general']['turntime'] ?? null) + && isBigIntColumn($details['general']['recent_war'] ?? null) + && isBigIntColumn($details['general_access_log']['last_refresh'] ?? null) + && isBigIntColumn($details['message']['time'] ?? null) + && isBigIntColumn($details['message']['valid_until'] ?? null) + && isBigIntColumn($details['select_npc_token']['valid_until'] ?? null) + && isBigIntColumn($details['select_npc_token']['pick_more_from'] ?? null) + && isBigIntColumn($details['select_pool']['reserved_until'] ?? null) + && isBigIntColumn($details['ng_auction']['open_tick'] ?? null) + && isBigIntColumn($details['ng_auction']['close_tick'] ?? null); + + return [ + 'state' => $old ? 'legacy' : ($new ? 'tick' : 'partial-or-unknown'), + 'details' => $details, + ]; +} + +function printMigrationStatus(\MeekroDB $db): string +{ + $inspection = inspectMigration($db); + printf("schema_state=%s\n", $inspection['state']); + foreach ($inspection['details'] as $table => $columns) { + $interesting = array_filter( + $columns, + static fn (string $name): bool => preg_match( + '/^(turntime|recent_war|last_refresh|time|valid_until|pick_more_from|reserved_until|open_date|close_date|open_tick|close_tick|.*_wall_backup)$/', + $name, + ) === 1, + ARRAY_FILTER_USE_KEY, + ); + printf("%s %s\n", $table, Json::encode($interesting)); + } + return $inspection['state']; +} + +/** Convert a DATETIME column into a staged BIGINT tick column. */ +function fillTickColumn( + \MeekroDB $db, + string $table, + string $dateColumn, + string $tickColumn, + string $baseTime, + int $ticksPerSecond, + bool $nullable, +): void { + $nullSql = $nullable ? ' NULL DEFAULT NULL' : ' NOT NULL'; + $db->query("ALTER TABLE %b ADD COLUMN %b BIGINT{$nullSql}", $table, $tickColumn); + $db->query( + "UPDATE %b SET %b = (TIMESTAMPDIFF(MICROSECOND, %s, %b) * %i) DIV 1000000", + $table, + $tickColumn, + $baseTime, + $dateColumn, + $ticksPerSecond, + ); + $missing = Util::toInt($db->queryFirstField( + "SELECT COUNT(*) FROM %b WHERE %b IS NOT NULL AND %b IS NULL", + $table, + $dateColumn, + $tickColumn, + )); + if ($missing !== 0) { + throw new RuntimeException("{$table}.{$tickColumn} 변환 누락: {$missing}"); + } +} + +function assertSafeTickColumn(\MeekroDB $db, string $table, string $column): void +{ + $unsafe = Util::toInt($db->queryFirstField( + 'SELECT COUNT(*) FROM %b WHERE %b > %i OR %b < %i', + $table, + $column, + GameClock::MAX_SAFE_TICK, + $column, + -GameClock::MAX_SAFE_TICK, + )); + if ($unsafe !== 0) { + throw new RuntimeException("{$table}.{$column} JavaScript safe integer 초과: {$unsafe}"); + } +} + +$options = getopt('', ['help', 'status', 'apply', 'backup:']); +if (isset($options['help'])) { + migrationUsage(); +} +if (isset($options['status']) === isset($options['apply'])) { + migrationUsage(2); +} + +$db = DB::db(); +if (isset($options['status'])) { + exit(printMigrationStatus($db) === 'partial-or-unknown' ? 2 : 0); +} + +$backup = $options['backup'] ?? null; +if (!is_string($backup) || $backup === '' || $backup[0] !== '/' || !is_file($backup) || filesize($backup) === 0) { + fwrite(STDERR, "--backup에는 적용 직전에 만든 비어 있지 않은 절대경로 SQL 백업을 지정해야 합니다.\n"); + exit(2); +} +if (inspectMigration($db)['state'] !== 'legacy') { + fwrite(STDERR, "legacy 스키마가 아니므로 적용하지 않습니다. --status 결과를 확인해 주세요.\n"); + exit(2); +} +if (!\sammo\tryLock()) { + fwrite(STDERR, "GAME lock을 획득하지 못했습니다.\n"); + exit(3); +} + +try { + $gameStor = KVStorage::getStorage($db, 'game_env'); + $legacy = $gameStor->getValues(['starttime', 'turntime', 'opentime', 'tnmt_time', 'turnterm']); + $turnTerm = Util::toInt($legacy['turnterm']); + $baseTime = new DateTimeImmutable((string)$legacy['starttime']); + $wallNow = GameClock::readWallTime(); + $conversionClock = new GameClock( + $baseTime, + $turnTerm, + 0, + GameClock::MODE_REALTIME, + $wallNow, + static fn (): DateTimeImmutable => $wallNow, + ); + $baseTimeString = $baseTime->format('Y-m-d H:i:s.u'); + $ticksPerSecond = $conversionClock->ticksPerSecond(); + + fillTickColumn($db, 'general', 'turntime', 'turntime_game_tick', $baseTimeString, $ticksPerSecond, false); + fillTickColumn($db, 'general', 'recent_war', 'recent_war_game_tick', $baseTimeString, $ticksPerSecond, true); + fillTickColumn($db, 'general_access_log', 'last_refresh', 'last_refresh_game_tick', $baseTimeString, $ticksPerSecond, true); + fillTickColumn($db, 'message', 'time', 'time_game_tick', $baseTimeString, $ticksPerSecond, false); + fillTickColumn($db, 'message', 'valid_until', 'valid_until_game_tick', $baseTimeString, $ticksPerSecond, false); + $db->query( + 'UPDATE message SET valid_until_game_tick = %i WHERE YEAR(valid_until) >= 9000', + GameClock::MAX_SAFE_TICK, + ); + fillTickColumn($db, 'select_npc_token', 'valid_until', 'valid_until_game_tick', $baseTimeString, $ticksPerSecond, false); + fillTickColumn($db, 'select_npc_token', 'pick_more_from', 'pick_more_from_game_tick', $baseTimeString, $ticksPerSecond, false); + fillTickColumn($db, 'select_pool', 'reserved_until', 'reserved_until_game_tick', $baseTimeString, $ticksPerSecond, true); + fillTickColumn($db, 'ng_auction', 'open_date', 'open_game_tick', $baseTimeString, $ticksPerSecond, false); + fillTickColumn($db, 'ng_auction', 'close_date', 'close_game_tick', $baseTimeString, $ticksPerSecond, false); + foreach ([ + ['general', 'turntime_game_tick'], + ['general', 'recent_war_game_tick'], + ['general_access_log', 'last_refresh_game_tick'], + ['message', 'time_game_tick'], + ['message', 'valid_until_game_tick'], + ['select_npc_token', 'valid_until_game_tick'], + ['select_npc_token', 'pick_more_from_game_tick'], + ['select_pool', 'reserved_until_game_tick'], + ['ng_auction', 'open_game_tick'], + ['ng_auction', 'close_game_tick'], + ] as [$table, $column]) { + assertSafeTickColumn($db, $table, $column); + } + + foreach ($db->query('SELECT no, aux FROM general') as $row) { + $aux = Json::decode((string)$row['aux']); + $nextChange = $aux['next_change'] ?? null; + if (is_string($nextChange) && !ctype_digit(ltrim($nextChange, '-'))) { + $aux['next_change_wall_backup'] = $nextChange; + $aux['next_change'] = $conversionClock->dateTimeToTick(new DateTimeImmutable($nextChange)); + $db->update('general', ['aux' => Json::encode($aux)], 'no = %i', $row['no']); + } + } + foreach ($db->query('SELECT id, detail FROM ng_auction') as $row) { + $detail = Json::decode((string)$row['detail']); + $legacyLimit = $detail['availableLatestBidCloseDate'] ?? null; + if (is_string($legacyLimit) && $legacyLimit !== '') { + $detail['availableLatestBidCloseDateWallBackup'] = $legacyLimit; + $detail['availableLatestBidCloseTick'] = $conversionClock->dateTimeToTick(new DateTimeImmutable($legacyLimit)); + } else { + $detail['availableLatestBidCloseTick'] = null; + } + unset($detail['availableLatestBidCloseDate']); + $db->update('ng_auction', ['detail' => Json::encode($detail)], 'id = %i', $row['id']); + } + foreach ($db->query('SELECT namespace, value FROM nation_env WHERE `key` = %s', 'last천도Trial') as $row) { + $trial = Json::decode((string)$row['value']); + if (!is_array($trial) || !isset($trial[1]) || !is_string($trial[1]) || ctype_digit(ltrim($trial[1], '-'))) { + continue; + } + $db->insertUpdate('nation_env', [ + 'namespace' => $row['namespace'], + 'key' => 'last천도Trial_wall_backup', + 'value' => Json::encode($trial), + ]); + $trial[1] = $conversionClock->dateTimeToTick(new DateTimeImmutable($trial[1])); + $db->update( + 'nation_env', + ['value' => Json::encode($trial)], + 'namespace = %i AND `key` = %s', + $row['namespace'], + 'last천도Trial', + ); + } + + $db->query( + 'ALTER TABLE general ' + . 'DROP INDEX turntime, DROP INDEX troop, ' + . 'CHANGE turntime turntime_wall_backup DATETIME(6) NULL DEFAULT NULL, ' + . 'CHANGE recent_war recent_war_wall_backup DATETIME(6) NULL DEFAULT NULL, ' + . 'CHANGE turntime_game_tick turntime BIGINT NOT NULL, ' + . 'CHANGE recent_war_game_tick recent_war BIGINT NULL DEFAULT NULL, ' + . 'ADD INDEX turntime (turntime, no), ADD INDEX troop (troop, turntime)' + ); + $db->query( + 'ALTER TABLE general_access_log ' + . 'CHANGE last_refresh last_refresh_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE last_refresh_game_tick last_refresh BIGINT NULL DEFAULT NULL' + ); + $db->query( + 'ALTER TABLE message ' + . 'CHANGE time time_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE valid_until valid_until_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE time_game_tick time BIGINT NOT NULL, ' + . 'CHANGE valid_until_game_tick valid_until BIGINT NOT NULL' + ); + $db->query( + 'ALTER TABLE select_npc_token DROP INDEX valid_until, ' + . 'CHANGE valid_until valid_until_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE pick_more_from pick_more_from_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE valid_until_game_tick valid_until BIGINT NOT NULL, ' + . 'CHANGE pick_more_from_game_tick pick_more_from BIGINT NOT NULL, ' + . 'ADD INDEX valid_until (valid_until)' + ); + $db->query( + 'ALTER TABLE select_pool DROP INDEX reserved_until, ' + . 'CHANGE reserved_until reserved_until_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE reserved_until_game_tick reserved_until BIGINT NULL DEFAULT NULL, ' + . 'ADD INDEX reserved_until (reserved_until, general_id)' + ); + $db->query( + 'ALTER TABLE ng_auction DROP INDEX by_close, ' + . 'CHANGE open_date open_date_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE close_date close_date_wall_backup DATETIME NULL DEFAULT NULL, ' + . 'CHANGE open_game_tick open_tick BIGINT NOT NULL, ' + . 'CHANGE close_game_tick close_tick BIGINT NOT NULL, ' + . 'ADD INDEX by_close (finished, type, close_tick)' + ); + + foreach (['starttime', 'turntime', 'opentime', 'tnmt_time'] as $key) { + $value = $legacy[$key] ?? null; + $gameStor->{"{$key}_wall_backup"} = $value; + $gameStor->{$key} = $value === null || $value === '' + ? null + : $conversionClock->dateTimeToTick(new DateTimeImmutable((string)$value)); + } + GameClock::initializeStorage( + $gameStor, + $baseTime, + $turnTerm, + $conversionClock->dateTimeToTick($wallNow), + GameClock::MODE_REALTIME, + $wallNow, + ); +} finally { + \sammo\unlock(); +} + +if (printMigrationStatus($db) !== 'tick') { + fwrite(STDERR, "마이그레이션 후 스키마 검증에 실패했습니다. 백업과 *_wall_backup을 이용해 복구해 주세요.\n"); + exit(4); +} +fwrite(STDOUT, "게임 시계 migration 완료. *_wall_backup은 별도 검증 후에만 수동 제거해 주세요.\n"); diff --git a/scripts/verify-game-clock-engine.php b/scripts/verify-game-clock-engine.php new file mode 100644 index 00000000..432b76a3 --- /dev/null +++ b/scripts/verify-game-clock-engine.php @@ -0,0 +1,76 @@ +#!/usr/bin/env php + 1000) { + throw new InvalidArgumentException('--engine-calls는 1..1000이어야 합니다.'); +} + +$db = DB::db(); +$gameStor = KVStorage::getStorage($db, 'game_env'); +$clock = GameClock::fromStorage($gameStor); +if ($clock->getMode() !== GameClock::MODE_MANUAL) { + throw new RuntimeException('재현 검증은 manual clock에서만 실행할 수 있습니다.'); +} + +$fixedNowTick = $clock->nowTick(); +$before = $gameStor->getValues(['year', 'month', 'turntime']); +$executedCount = 0; +for ($call = 0; $call < $engineCalls; $call++) { + $executed = false; + $locked = false; + TurnExecutionHelper::executeAllCommand($executed, $locked); + if ($locked) { + throw new RuntimeException('엔진이 GAME lock 또는 동결 상태로 진행하지 못했습니다.'); + } + if ($executed) { + $executedCount++; + } + $gameStor->resetCache(); + $clock = GameClock::fromStorage($gameStor); + if ($clock->nowTick() !== $fixedNowTick) { + throw new RuntimeException('엔진 실행 중 manual clock tick이 실제 시간에 의해 변했습니다.'); + } +} + +$after = $gameStor->getValues(['year', 'month', 'turntime']); +if (Util::toInt($after['turntime']) > $fixedNowTick) { + throw new RuntimeException('마지막 실행 tick이 현재 game tick을 넘어갔습니다.'); +} +printf( + "clock_tick=%d clock=%s calls=%d executed_calls=%d before=%d-%02d/%d after=%d-%02d/%d\n", + $fixedNowTick, + $clock->formatTick($fixedNowTick, true), + $engineCalls, + $executedCount, + Util::toInt($before['year']), + Util::toInt($before['month']), + Util::toInt($before['turntime']), + Util::toInt($after['year']), + Util::toInt($after['month']), + Util::toInt($after['turntime']), +); diff --git a/src/sammo/GameClock.php b/src/sammo/GameClock.php new file mode 100644 index 00000000..0103ff67 --- /dev/null +++ b/src/sammo/GameClock.php @@ -0,0 +1,284 @@ +wallNowProvider = $wallNowProvider; + } + + public static function fromStorage(KVStorage $gameStor, ?callable $wallNowProvider = null): self + { + $values = $gameStor->getValues([ + 'clock_base_time', + 'clock_tick', + 'clock_mode', + 'clock_wall_anchor', + 'turnterm', + ]); + + $baseTime = new \DateTimeImmutable((string)$values['clock_base_time']); + $wallAnchor = new \DateTimeImmutable((string)$values['clock_wall_anchor']); + + return new self( + $baseTime, + Util::toInt($values['turnterm']), + Util::toInt($values['clock_tick']), + (string)$values['clock_mode'], + $wallAnchor, + $wallNowProvider, + ); + } + + public static function initializeStorage( + KVStorage $gameStor, + \DateTimeInterface $baseTime, + int $turnTermMinutes, + int $currentTick, + string $mode = self::MODE_REALTIME, + ?\DateTimeInterface $wallAnchor = null, + ): void { + $wallAnchor ??= self::readWallTime(); + // 생성자 validation을 초기화 경로에도 동일하게 적용합니다. + new self( + \DateTimeImmutable::createFromInterface($baseTime), + $turnTermMinutes, + $currentTick, + $mode, + \DateTimeImmutable::createFromInterface($wallAnchor), + ); + + $gameStor->clock_base_time = TimeUtil::format($baseTime, true); + $gameStor->clock_tick = $currentTick; + $gameStor->clock_mode = $mode; + $gameStor->clock_wall_anchor = TimeUtil::format($wallAnchor, true); + } + + public function getBaseTime(): \DateTimeImmutable + { + return $this->baseTime; + } + + public function getTurnTermMinutes(): int + { + return $this->turnTermMinutes; + } + + public function getMode(): string + { + return $this->mode; + } + + public function ticksPerSecond(): int + { + return intdiv(self::TICKS_PER_TURN, $this->turnTermMinutes * 60); + } + + public function nowTick(): int + { + if ($this->mode === self::MODE_MANUAL) { + return $this->anchorTick; + } + + return self::addTicks($this->anchorTick, $this->ticksBetween($this->wallAnchor, $this->wallNow())); + } + + public function ticksFromSeconds(int|float $seconds): int + { + if (is_int($seconds)) { + return $seconds * $this->ticksPerSecond(); + } + return (int)round($seconds * $this->ticksPerSecond()); + } + + public function ticksFromMinutes(int|float $minutes): int + { + return $this->ticksFromSeconds($minutes * 60); + } + + public function addTurns(int $tick, int $turns = 1): int + { + if (abs($turns) > intdiv(self::MAX_SAFE_TICK, self::TICKS_PER_TURN)) { + throw new \OverflowException('turn 수가 JavaScript safe integer tick 범위를 벗어났습니다.'); + } + return self::addTicks($tick, self::TICKS_PER_TURN * $turns); + } + + public static function addTicks(int $tick, int $deltaTick): int + { + self::requireSafeTick($tick); + if ($deltaTick > self::MAX_SAFE_TICK || $deltaTick < -self::MAX_SAFE_TICK) { + throw new \OverflowException('delta tick이 JavaScript safe integer 범위를 벗어났습니다.'); + } + if (($deltaTick > 0 && $tick > self::MAX_SAFE_TICK - $deltaTick) + || ($deltaTick < 0 && $tick < -self::MAX_SAFE_TICK - $deltaTick)) { + throw new \OverflowException('JavaScript safe integer tick 범위를 벗어났습니다.'); + } + return self::requireSafeTick($tick + $deltaTick); + } + + public static function requireSafeTick(int $tick): int + { + if (abs($tick) > self::MAX_SAFE_TICK) { + throw new \OverflowException("tick {$tick}은 JavaScript safe integer 범위를 벗어났습니다."); + } + return $tick; + } + + public function floorTurn(int $tick): int + { + $remainder = $tick % self::TICKS_PER_TURN; + if ($remainder < 0) { + $remainder += self::TICKS_PER_TURN; + } + return $tick - $remainder; + } + + /** @return array{turn:int, subTick:int} */ + public function splitTick(int $tick): array + { + $turnStart = $this->floorTurn($tick); + return [ + 'turn' => intdiv($turnStart, self::TICKS_PER_TURN), + 'subTick' => $tick - $turnStart, + ]; + } + + public function dateTimeToTick(\DateTimeInterface $dateTime): int + { + return self::requireSafeTick($this->ticksBetween($this->baseTime, $dateTime)); + } + + public function tickToDateTime(int $tick): \DateTimeImmutable + { + self::requireSafeTick($tick); + $ticksPerSecond = $this->ticksPerSecond(); + $seconds = intdiv($tick, $ticksPerSecond); + $remainingTicks = $tick % $ticksPerSecond; + if ($remainingTicks < 0) { + $seconds--; + $remainingTicks += $ticksPerSecond; + } + $microseconds = intdiv($remainingTicks * 1_000_000, $ticksPerSecond); + + $result = $this->baseTime->modify("{$seconds} seconds"); + if ($microseconds !== 0) { + $result = $result->modify("{$microseconds} microseconds"); + } + return $result; + } + + public function formatTick(int $tick, bool $withFraction = false): string + { + return TimeUtil::format($this->tickToDateTime($tick), $withFraction); + } + + public static function baseTimeForProjection( + \DateTimeInterface $projectedTime, + int $tick, + int $turnTermMinutes, + ): \DateTimeImmutable { + $ticksPerSecond = intdiv(self::TICKS_PER_TURN, $turnTermMinutes * 60); + if ($ticksPerSecond <= 0 || self::TICKS_PER_TURN % ($turnTermMinutes * 60) !== 0) { + throw new \InvalidArgumentException('정수 tick/초로 표현할 수 없는 turnterm입니다.'); + } + $seconds = intdiv($tick, $ticksPerSecond); + $remainingTicks = $tick % $ticksPerSecond; + if ($remainingTicks < 0) { + $seconds--; + $remainingTicks += $ticksPerSecond; + } + $microseconds = intdiv($remainingTicks * 1_000_000, $ticksPerSecond); + $negativeSeconds = -$seconds; + $result = \DateTimeImmutable::createFromInterface($projectedTime)->modify("{$negativeSeconds} seconds"); + if ($microseconds !== 0) { + $negativeMicroseconds = -$microseconds; + $result = $result->modify("{$negativeMicroseconds} microseconds"); + } + return $result; + } + + public function persistTick(KVStorage $gameStor, int $tick, ?string $mode = null): void + { + self::requireSafeTick($tick); + $mode ??= $this->mode; + if (!in_array($mode, [self::MODE_REALTIME, self::MODE_MANUAL], true)) { + throw new \InvalidArgumentException("알 수 없는 game clock mode: {$mode}"); + } + $gameStor->clock_tick = $tick; + $gameStor->clock_mode = $mode; + // 수동 시계의 전진은 실제 시간에 의존하지 않아야 합니다. realtime으로 + // 전환하는 순간에만 새 벽시계 anchor를 읽습니다. + $wallAnchor = $mode === self::MODE_REALTIME ? $this->wallNow() : $this->wallAnchor; + $gameStor->clock_wall_anchor = TimeUtil::format($wallAnchor, true); + } + + public function advance(KVStorage $gameStor, int $deltaTick): int + { + $nextTick = $this->nowTick() + $deltaTick; + $this->persistTick($gameStor, $nextTick); + return $nextTick; + } + + private function ticksBetween(\DateTimeInterface $from, \DateTimeInterface $to): int + { + $secondDiff = $to->getTimestamp() - $from->getTimestamp(); + $microsecondDiff = Util::toInt($to->format('u')) - Util::toInt($from->format('u')); + $ticksPerSecond = $this->ticksPerSecond(); + + return $secondDiff * $ticksPerSecond + + intdiv($microsecondDiff * $ticksPerSecond, 1_000_000); + } + + private function wallNow(): \DateTimeImmutable + { + if ($this->wallNowProvider !== null) { + $now = ($this->wallNowProvider)(); + if (!$now instanceof \DateTimeImmutable) { + throw new \UnexpectedValueException('wallNowProvider는 DateTimeImmutable을 반환해야 합니다.'); + } + return $now; + } + return self::readWallTime(); + } + + /** 운영 anchor와 실행 budget에서만 사용하는 실제 벽시계 입력입니다. */ + public static function readWallTime(): \DateTimeImmutable + { + return new \DateTimeImmutable(); + } +} diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php new file mode 100644 index 00000000..033cfb00 --- /dev/null +++ b/tests/GameClockBoundaryTest.php @@ -0,0 +1,59 @@ + [$path], [ + 'hwe/sammo/TurnExecutionHelper.php', + 'hwe/sammo/Auction.php', + 'hwe/sammo/AuctionBasicResource.php', + 'hwe/sammo/AuctionUniqueItem.php', + 'hwe/func_auction.php', + 'hwe/func_tournament.php', + 'hwe/c_tournament.php', + 'hwe/sammo/AbsFromUserPool.php', + 'hwe/sammo/GeneralPool/RandomNameGeneral.php', + 'hwe/sammo/API/General/DieOnPrestart.php', + ]); + } + + public function testTickSchemaDoesNotUseDatabaseDefaultsForGameSchedules(): void + { + $schema = file_get_contents(__DIR__ . '/../hwe/sql/schema.sql'); + self::assertIsString($schema); + foreach ([ + '`turntime` BIGINT', + '`recent_war` BIGINT', + '`last_refresh` BIGINT', + '`time` BIGINT', + '`valid_until` BIGINT', + '`reserved_until` BIGINT', + '`open_tick` BIGINT', + '`close_tick` BIGINT', + ] as $expected) { + self::assertStringContainsString($expected, $schema); + } + } +} diff --git a/tests/GameClockTest.php b/tests/GameClockTest.php new file mode 100644 index 00000000..74a08208 --- /dev/null +++ b/tests/GameClockTest.php @@ -0,0 +1,99 @@ +ticksPerSecond()); + self::assertSame(GameClock::TICKS_PER_TURN, $clock->ticksFromMinutes($turnTerm)); + } + + public static function supportedTurnTerms(): array + { + return [ + '1 minute' => [1, 600_000], + '2 minutes' => [2, 300_000], + '5 minutes' => [5, 120_000], + '10 minutes' => [10, 60_000], + '60 minutes' => [60, 10_000], + '120 minutes' => [120, 5_000], + ]; + } + + public function testTickFormulaAndDisplayProjectionRoundTrip(): void + { + $base = new \DateTimeImmutable('2026-08-03 12:34:56.000000'); + $clock = new GameClock($base, 60, 0, GameClock::MODE_MANUAL, $base); + $tick = GameClock::TICKS_PER_TURN * 7 + 12_345; + + self::assertSame(['turn' => 7, 'subTick' => 12_345], $clock->splitTick($tick)); + self::assertSame($tick, $clock->dateTimeToTick($clock->tickToDateTime($tick))); + self::assertSame('2026-08-03 19:34:57.234500', $clock->formatTick($tick, true)); + } + + public function testManualModeDoesNotReadWallClock(): void + { + $base = new \DateTimeImmutable('2026-08-03 00:00:00.000000'); + $wallRead = false; + $clock = new GameClock( + $base, + 10, + 123_456, + GameClock::MODE_MANUAL, + $base, + function () use (&$wallRead): \DateTimeImmutable { + $wallRead = true; + return new \DateTimeImmutable('2099-01-01 00:00:00.000000'); + }, + ); + + self::assertSame(123_456, $clock->nowTick()); + self::assertFalse($wallRead); + } + + public function testNegativeTickProjectionAndBaseRecalculation(): void + { + $projected = new \DateTimeImmutable('2026-08-03 12:00:00.123400'); + $tick = -36_001_234; + $base = GameClock::baseTimeForProjection($projected, $tick, 60); + $clock = new GameClock($base, 60, $tick, GameClock::MODE_MANUAL, $projected); + + self::assertSame($tick, $clock->dateTimeToTick($clock->tickToDateTime($tick))); + self::assertSame('2026-08-03 12:00:00.123400', $clock->formatTick($tick, true)); + self::assertSame(['turn' => -2, 'subTick' => 35_998_766], $clock->splitTick($tick)); + } + + public function testRealtimeModeAdvancesFromAnchorWithoutDatabaseNow(): void + { + $base = new \DateTimeImmutable('2026-08-03 00:00:00.000000'); + $wallAnchor = new \DateTimeImmutable('2026-08-03 10:00:00.000000'); + $clock = new GameClock( + $base, + 120, + 100, + GameClock::MODE_REALTIME, + $wallAnchor, + fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-08-03 10:00:01.500000'), + ); + + self::assertSame(7_600, $clock->nowTick()); + } + + public function testTickArithmeticRejectsValuesThatJavaScriptCannotRepresentExactly(): void + { + self::assertSame(GameClock::MAX_SAFE_TICK, GameClock::addTicks(GameClock::MAX_SAFE_TICK - 1, 1)); + + $this->expectException(\OverflowException::class); + GameClock::addTicks(GameClock::MAX_SAFE_TICK, 1); + } +} From fc6f84ce23ea92b777b891ee18986c46aaa5d60a Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 3 Aug 2026 16:09:35 +0000 Subject: [PATCH 3/5] fix: complete logical clock wall-time isolation --- hwe/_admin1_submit.php | 5 +- hwe/_admin2_submit.php | 16 +-- hwe/_admin8.php | 4 +- hwe/a_traffic.php | 3 +- hwe/func.php | 22 ++-- hwe/func_gamerule.php | 23 ++-- hwe/index.php | 5 +- hwe/j_autoreset.php | 27 ++-- hwe/j_board_article_add.php | 5 +- hwe/j_board_comment_add.php | 5 +- hwe/j_diplomacy_destroy_letter.php | 4 +- hwe/j_diplomacy_respond_letter.php | 4 +- hwe/j_diplomacy_rollback_letter.php | 4 +- hwe/j_diplomacy_send_letter.php | 8 +- hwe/j_get_select_npc_token.php | 8 +- hwe/j_get_select_pool.php | 10 +- hwe/j_myBossInfo.php | 2 +- hwe/j_server_basic_info.php | 1 + hwe/j_set_npc_control.php | 9 +- hwe/sammo/API/Command/GetReservedCommand.php | 7 +- hwe/sammo/API/General/GetFrontInfo.php | 6 +- .../API/InheritAction/BuyRandomUnique.php | 3 +- hwe/sammo/API/InheritAction/CheckOwner.php | 4 +- hwe/sammo/API/Message/SendMessage.php | 8 +- hwe/sammo/API/Nation/SetNotice.php | 5 +- .../API/NationCommand/GetReservedCommand.php | 5 +- hwe/sammo/API/Vote/AddComment.php | 7 +- hwe/sammo/API/Vote/GetVoteDetail.php | 7 +- hwe/sammo/API/Vote/GetVoteList.php | 8 +- hwe/sammo/API/Vote/NewVote.php | 34 ++--- hwe/sammo/API/Vote/Vote.php | 7 +- hwe/sammo/Command/Nation/che_몰수.php | 2 +- hwe/sammo/Command/Nation/che_불가침제의.php | 5 +- .../Command/Nation/che_불가침파기제의.php | 7 +- hwe/sammo/Command/Nation/che_종전제의.php | 7 +- hwe/sammo/DTO/VoteInfo.php | 33 +++++ hwe/sammo/DiplomaticMessage.php | 4 +- hwe/sammo/Event/Action/OpenNationBetting.php | 2 +- hwe/sammo/GeneralAI.php | 2 +- hwe/sammo/Message.php | 29 ++++- hwe/sammo/RaiseInvaderMessage.php | 2 +- hwe/sammo/ScoutMessage.php | 10 +- hwe/sammo/TurnExecutionHelper.php | 36 ++++-- hwe/sammo/UserLogger.php | 3 +- hwe/sql/schema.sql | 4 +- hwe/ts/PageChiefCenter.vue | 4 +- hwe/ts/PageVote.vue | 10 +- hwe/ts/PartialReservedCommand.vue | 4 +- hwe/ts/components/ChiefReservedCommand.vue | 6 +- hwe/ts/components/MessagePlate.vue | 7 +- hwe/ts/components/SimpleClock.vue | 34 ++++- hwe/ts/defs/API/Command.ts | 1 + hwe/ts/defs/API/Message.ts | 4 +- hwe/ts/defs/API/NationCommand.ts | 3 +- hwe/ts/defs/API/Vote.ts | 3 +- hwe/ts/gateway/entrance.ts | 9 +- hwe/ts/msg.ts | 23 ++-- hwe/ts/select_general_from_pool.ts | 18 ++- hwe/ts/select_npc.ts | 33 +++-- scripts/README-game-clock.md | 5 + scripts/migrate-game-clock.php | 25 ++++ scripts/verify-game-clock-engine.php | 121 +++++++++++++++++- src/sammo/GameClock.php | 12 +- src/sammo/Session.php | 31 +++-- tests/GameClockBoundaryTest.php | 91 ++++++++++++- tests/GameClockTest.php | 8 ++ tests/VoteGameClockTest.php | 56 ++++++++ 67 files changed, 689 insertions(+), 231 deletions(-) create mode 100644 tests/VoteGameClockTest.php diff --git a/hwe/_admin1_submit.php b/hwe/_admin1_submit.php index 51827d42..47732316 100644 --- a/hwe/_admin1_submit.php +++ b/hwe/_admin1_submit.php @@ -25,13 +25,15 @@ if (!$v->validate()) { $msg = Util::getPost('msg'); $btn = Util::getPost('btn'); $log = Util::getPost('log'); -$starttime = Util::getPost('starttime', 'string', (new \DateTime())->format('Y-m-d H:i:s')); +$starttime = Util::getPost('starttime', 'string', null); $maxgeneral = Util::getPost('maxgeneral', 'int', GameConst::$defaultMaxGeneral); $maxnation = Util::getPost('maxnation', 'int', GameConst::$defaultMaxNation); $startyear = Util::getPost('startyear', 'int', GameConst::$defaultStartYear); $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); +$clock = GameClock::fromStorage($gameStor); +$starttime ??= $clock->formatTick($clock->nowTick()); $admin = getAdmin(); @@ -43,7 +45,6 @@ switch ($btn) { pushGlobalHistoryLog(["{$log}"]); break; case "변경1": - $clock = GameClock::fromStorage($gameStor); $gameStor->clock_base_time = TimeUtil::format(GameClock::baseTimeForProjection( new \DateTimeImmutable($starttime), Util::toInt($gameStor->starttime), diff --git a/hwe/_admin2_submit.php b/hwe/_admin2_submit.php index e3ad0652..d2502089 100644 --- a/hwe/_admin2_submit.php +++ b/hwe/_admin2_submit.php @@ -113,7 +113,7 @@ switch ($btn) { case "경험치1000": $text = $btn . " 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -124,7 +124,7 @@ switch ($btn) { case "공헌치1000": $text = $btn . " 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -135,7 +135,7 @@ switch ($btn) { case "보숙10000": $text = "보병숙련도+10000 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -145,7 +145,7 @@ switch ($btn) { case "궁숙10000": $text = "궁병숙련도+10000 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -156,7 +156,7 @@ switch ($btn) { $src = MessageTarget::buildQuick($session->generalID); $text = "기병숙련도+10000 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -167,7 +167,7 @@ switch ($btn) { $src = MessageTarget::buildQuick($session->generalID); $text = "귀병숙련도+10000 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -178,7 +178,7 @@ switch ($btn) { $src = MessageTarget::buildQuick($session->generalID); $text = "차병숙련도+10000 지급!"; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } $db->update('general', [ @@ -198,7 +198,7 @@ switch ($btn) { case "메세지 전달": $text = $msg ?? ''; foreach ($genlist as $generalID) { - $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, new \DateTime(), new \DateTime('9999-12-31'), []); + $msg = new Message(MessageType::private, $src, MessageTarget::buildQuick($generalID), $text, Message::gameNow(), new \DateTime('9999-12-31'), []); $msg->send(true); } break; diff --git a/hwe/_admin8.php b/hwe/_admin8.php index a70018b4..8248b54d 100644 --- a/hwe/_admin8.php +++ b/hwe/_admin8.php @@ -100,8 +100,6 @@ $sel[$type] = "selected"; throw new \Exception("알 수 없는 외교 상태: {$dip['state']}"); } - $date = TimeUtil::now(); - echo " $nationName[$me] @@ -122,4 +120,4 @@ $sel[$type] = "selected"; - \ No newline at end of file + diff --git a/hwe/a_traffic.php b/hwe/a_traffic.php index 483e061e..655020b3 100644 --- a/hwe/a_traffic.php +++ b/hwe/a_traffic.php @@ -9,6 +9,7 @@ include "func.php"; $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); +$clock = GameClock::fromStorage($gameStor); increaseRefresh("갱신정보", 1); @@ -23,7 +24,7 @@ $recentTraffic[] = [ 'month' => $admin['month'], 'refresh' => $admin['refresh'], 'online' => $curonline, - 'date' => TimeUtil::now() + 'date' => $clock->formatNow() ]; if ($admin['maxrefresh'] == 0) { diff --git a/hwe/func.php b/hwe/func.php index d72cad9b..f3ca231d 100644 --- a/hwe/func.php +++ b/hwe/func.php @@ -1082,12 +1082,12 @@ function updateTraffic() if(count($recentTraffic) >= 5){ array_shift($recentTraffic); } - $recentTraffic[] = [ + $recentTraffic[] = [ 'year'=>$admin['year'], 'month' => $admin['month'], 'refresh' => $admin['refresh'], 'online' => $online, - 'date' => TimeUtil::now(), + 'date' => GameClock::fromStorage($gameStor)->formatNow(), ]; $gameStor->recentTraffic = $recentTraffic; @@ -1376,12 +1376,14 @@ function CheckHall($no) return; } - $unitedDate = TimeUtil::now(); + $clock = GameClock::fromStorage($gameStor); + $unitedDate = $clock->formatTick($clock->nowTick()); $nation = $generalObj->getStaticNation(); $serverCnt = $db->queryFirstField('SELECT count(*) FROM ng_games'); - [$scenarioIdx, $scenarioName, $startTime] = $gameStor->getValuesAsArray(['scenario', 'scenario_text', 'starttime']); + [$scenarioIdx, $scenarioName, $startTick] = $gameStor->getValuesAsArray(['scenario', 'scenario_text', 'starttime']); + $startTime = $clock->formatTick(Util::toInt($startTick)); $ownerName = $generalObj->getVar('owner_name'); if ($generalObj->getVar('owner')) { @@ -1786,11 +1788,13 @@ function deleteNation(General $lord, bool $applyDB): array // 부대 삭제 $db->delete('troop', 'nation=%i', $nationID); - // 국가 삭제 - $db->insert('ng_old_nations', [ - 'server_id' => UniqueConst::$serverID, - 'nation' => $nationID, - 'data' => Json::encode($nation) + // 국가 삭제 + $gameDate = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->formatNow(); + $db->insert('ng_old_nations', [ + 'server_id' => UniqueConst::$serverID, + 'nation' => $nationID, + 'date' => $gameDate, + 'data' => Json::encode($nation) ]); $db->delete('nation', 'nation=%i', $nationID); $db->delete('nation_turn', 'nation_id=%i', $nationID); diff --git a/hwe/func_gamerule.php b/hwe/func_gamerule.php index 707d3406..ca448876 100644 --- a/hwe/func_gamerule.php +++ b/hwe/func_gamerule.php @@ -838,20 +838,23 @@ function checkEmperior() $nation['aux'] += $nationStor->max_power ?? []; $nation['history'] = getNationHistoryLogAll($nation['nation']); - storeOldGenerals(0, $admin['year'], $admin['month']); - storeOldGenerals($nation['nation'], $admin['year'], $admin['month']); - - $db->insert('ng_old_nations', [ - 'server_id' => UniqueConst::$serverID, - 'nation' => $nation['nation'], - 'data' => Json::encode($nation) + storeOldGenerals(0, $admin['year'], $admin['month']); + storeOldGenerals($nation['nation'], $admin['year'], $admin['month']); + $gameDate = GameClock::fromStorage($gameStor)->formatNow(); + + $db->insert('ng_old_nations', [ + 'server_id' => UniqueConst::$serverID, + 'nation' => $nation['nation'], + 'date' => $gameDate, + 'data' => Json::encode($nation) ]); $noNationGeneral = $db->queryFirstColumn('SELECT `no` FROM general WHERE nation=0'); $db->insert('ng_old_nations', [ - 'server_id' => UniqueConst::$serverID, - 'nation' => 0, - 'data' => Json::encode([ + 'server_id' => UniqueConst::$serverID, + 'nation' => 0, + 'date' => $gameDate, + 'data' => Json::encode([ 'nation' => 0, 'name' => '재야', 'generals' => $noNationGeneral diff --git a/hwe/index.php b/hwe/index.php index f294d530..15cf758f 100644 --- a/hwe/index.php +++ b/hwe/index.php @@ -7,6 +7,7 @@ include "func.php"; Session::requireLogin()->loginGame()->setReadOnly(); $mapName = GameConst::$mapName; +$frontClock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); ?> @@ -28,7 +29,7 @@ $mapName = GameConst::$mapName; 'maxTurn' => GameConst::$maxTurn, 'maxPushTurn' => 12, - 'serverNow' => TimeUtil::now(false), + 'serverNow' => $frontClock->formatTick($frontClock->nowTick()), ] ], false) ?> @@ -42,4 +43,4 @@ $mapName = GameConst::$mapName;
- \ No newline at end of file + diff --git a/hwe/j_autoreset.php b/hwe/j_autoreset.php index 4a881457..e8ded5c3 100644 --- a/hwe/j_autoreset.php +++ b/hwe/j_autoreset.php @@ -25,21 +25,24 @@ if(!$reserved){ ]); } -$reservedDate = new \DateTime($reserved['date']); -$now = new \DateTime(); +$reservedDate = new \DateTimeImmutable($reserved['date']); +$now = GameClock::readWallTime(); $status = 'not_yet'; -list($isUnited, $lastTurn) = $gameStor->getValuesAsArray(['isunited', 'turntime']); -if($isUnited === null || $lastTurn === null){ - $isUnited = 2; - $lastTurn = '2000-01-01'; -} - -if($lastTurn !== null){ - $lastTurn = new \DateTime($lastTurn); -} +list($isUnited, $unitedWallAnchor) = $gameStor->getValuesAsArray(['isunited', 'autoreset_united_wall_anchor']); +if($isUnited === null){ + $isUnited = 2; +} +$lastTurn = null; +if($isUnited > 0){ + if(!is_string($unitedWallAnchor) || $unitedWallAnchor === ''){ + $unitedWallAnchor = TimeUtil::format($now, true); + $gameStor->autoreset_united_wall_anchor = $unitedWallAnchor; + } + $lastTurn = new \DateTimeImmutable($unitedWallAnchor); +} if($lastTurn === null){ //이미 리셋된 상태임 @@ -99,4 +102,4 @@ $result['affected']=1; $prefix = DB::prefix(); ServConfig::getServerList()[$prefix]->openServer(); -Json::die($result); \ No newline at end of file +Json::die($result); diff --git a/hwe/j_board_article_add.php b/hwe/j_board_article_add.php index 35ea3ae6..be901f1c 100644 --- a/hwe/j_board_article_add.php +++ b/hwe/j_board_article_add.php @@ -10,6 +10,7 @@ $session = Session::requireGameLogin()->setReadOnly(); $userID = Session::getUserID(); $db = DB::db(); +$clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $isSecretBoard = Util::getPost('isSecret', 'bool', false); $title = Util::getPost('title'); @@ -67,7 +68,7 @@ $icon = GetImageURL($me['imgsvr'], $me['picture']); $db->insert('board', [ 'nation_no'=>$me['nation'], 'is_secret'=>$isSecretBoard, - 'date'=>TimeUtil::now(), + 'date'=>$clock->formatNow(), 'general_no'=>$me['no'], 'author'=>$me['name'], 'author_icon'=>$icon, @@ -79,4 +80,4 @@ Json::die([ 'result'=>true, 'reason'=>'success', 'row_id'=>$db->insertId() -]); \ No newline at end of file +]); diff --git a/hwe/j_board_comment_add.php b/hwe/j_board_comment_add.php index 4fc660be..57c41d51 100644 --- a/hwe/j_board_comment_add.php +++ b/hwe/j_board_comment_add.php @@ -10,6 +10,7 @@ $session = Session::requireGameLogin()->setReadOnly(); $userID = Session::getUserID(); $db = DB::db(); +$clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $articleNo = Util::getPost('articleNo', 'int'); $text = Util::getPost('text'); @@ -73,7 +74,7 @@ else if ($isSecretBoard && $permission < 2) { $db->insert('comment', [ 'nation_no'=>$me['nation'], 'is_secret'=>$isSecretBoard, - 'date'=>TimeUtil::now(), + 'date'=>$clock->formatNow(), 'document_no'=>$articleNo, 'general_no'=>$me['no'], 'author'=>$me['name'], @@ -84,4 +85,4 @@ Json::die([ 'result'=>true, 'reason'=>'success', 'row_id'=>$db->insertId() -]); \ No newline at end of file +]); diff --git a/hwe/j_diplomacy_destroy_letter.php b/hwe/j_diplomacy_destroy_letter.php index f7ed149e..dbca577a 100644 --- a/hwe/j_diplomacy_destroy_letter.php +++ b/hwe/j_diplomacy_destroy_letter.php @@ -83,7 +83,7 @@ else{ } -$now = new \DateTime(); +$now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); if(in_array($stateOpt, ['try_destroy_src', 'try_destroy_dest'])){ @@ -138,4 +138,4 @@ Json::die([ 'result'=>true, 'reason'=>'success', 'state'=>$lastState -]); \ No newline at end of file +]); diff --git a/hwe/j_diplomacy_respond_letter.php b/hwe/j_diplomacy_respond_letter.php index ce8c4d8f..06b9d455 100644 --- a/hwe/j_diplomacy_respond_letter.php +++ b/hwe/j_diplomacy_respond_letter.php @@ -69,7 +69,7 @@ $destNation = getNationStaticInfo($letter['dest_nation_id']); $src = new MessageTarget($me['no'], $me['name'], $destNation['nation'], $destNation['name'], $destNation['color'], $me['icon']); $dest = new MessageTarget(0, '', $srcNation['nation'], $srcNation['name'], $srcNation['color']); -$now = new \DateTime(); +$now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); if($isAgree){ @@ -133,4 +133,4 @@ $msgID = $msg->send(); Json::die([ 'result'=>true, 'reason'=>'success' -]); \ No newline at end of file +]); diff --git a/hwe/j_diplomacy_rollback_letter.php b/hwe/j_diplomacy_rollback_letter.php index f8bc1f9b..951bdbc9 100644 --- a/hwe/j_diplomacy_rollback_letter.php +++ b/hwe/j_diplomacy_rollback_letter.php @@ -63,7 +63,7 @@ $destNation = getNationStaticInfo($letter['dest_nation_id']); $src = new MessageTarget($me['no'], $me['name'], $srcNation['nation'], $srcNation['name'], $srcNation['color'], $me['icon']); $dest = new MessageTarget(0, '', $destNation['nation'], $destNation['name'], $destNation['color']); -$now = new \DateTime(); +$now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); $aux['reason'] = [ @@ -91,4 +91,4 @@ $msgID = $msg->send(); Json::die([ 'result'=>true, 'reason'=>'success' -]); \ No newline at end of file +]); diff --git a/hwe/j_diplomacy_send_letter.php b/hwe/j_diplomacy_send_letter.php index ea05671e..f3e9aa85 100644 --- a/hwe/j_diplomacy_send_letter.php +++ b/hwe/j_diplomacy_send_letter.php @@ -140,6 +140,8 @@ else{ $me['icon'] = GetImageURL($me['imgsvr'], $me['picture']); +$clock = GameClock::fromStorage($gameStor); +$gameNow = $clock->nowDateTime(); $db->insert('ng_diplomacy', [ 'src_nation_id'=>$srcNation['nation'], 'dest_nation_id'=>$destNation['nation'], @@ -147,7 +149,7 @@ $db->insert('ng_diplomacy', [ 'state'=>'proposed', 'text_brief'=>$textBrief, 'text_detail'=>$textDetail, - 'date'=>TimeUtil::now(), + 'date'=>TimeUtil::format($gameNow), 'src_signer'=>$me['no'], 'dest_signer'=>null, 'aux'=>Json::encode([ @@ -168,7 +170,7 @@ $newLetterNo = $db->insertId(); $src = new MessageTarget($me['no'], $me['name'], $srcNation['nation'], $srcNation['name'], $srcNation['color'], $me['icon']); $dest = new MessageTarget(0, '', $destNation['nation'], $destNation['name'], $destNation['color']); -$now = new \DateTime(); +$now = \DateTime::createFromImmutable($gameNow); $unlimited = new \DateTime('9999-12-31'); $josaYi = JosaUtil::pick($newLetterNo, '이'); @@ -195,4 +197,4 @@ Json::die([ 'result'=>true, 'reason'=>'success', 'row_id'=>$db->insertId() -]); \ No newline at end of file +]); diff --git a/hwe/j_get_select_npc_token.php b/hwe/j_get_select_npc_token.php index ccbcd081..a62c3868 100644 --- a/hwe/j_get_select_npc_token.php +++ b/hwe/j_get_select_npc_token.php @@ -79,7 +79,9 @@ if($token && !$refresh){ 'pick'=>Json::decode($token['pick_result']), 'pickMoreFrom'=>$clock->formatTick($pickMoreFrom), 'pickMoreSeconds'=>intdiv($pickMoreFrom - $now, $clock->ticksPerSecond()), - 'validUntil'=>$clock->formatTick(Util::toInt($token['valid_until'])) + 'validUntil'=>$clock->formatTick(Util::toInt($token['valid_until'])), + 'validForSeconds'=>max(0, intdiv(Util::toInt($token['valid_until']) - $now, $clock->ticksPerSecond())), + 'clockMode'=>$clock->getMode(), ]); } @@ -171,5 +173,7 @@ Json::die([ 'pick'=>$pickResult, 'pickMoreFrom'=>$clock->formatTick(($inserted===-1)?$pickMoreFrom:$now), 'pickMoreSeconds'=>($inserted===-1)?$pickMoreSecond:0, - 'validUntil'=>$clock->formatTick($validUntil) + 'validUntil'=>$clock->formatTick($validUntil), + 'validForSeconds'=>max(0, intdiv($validUntil - $now, $clock->ticksPerSecond())), + 'clockMode'=>$clock->getMode(), ]); diff --git a/hwe/j_get_select_pool.php b/hwe/j_get_select_pool.php index 9e3d9c0b..8121449d 100644 --- a/hwe/j_get_select_pool.php +++ b/hwe/j_get_select_pool.php @@ -86,7 +86,9 @@ if($tokens){ Json::die([ 'result'=>true, 'pick'=>$pick, - 'validUntil'=>$clock->formatTick(Util::toInt($valid_until)) + 'validUntil'=>$clock->formatTick(Util::toInt($valid_until)), + 'validForSeconds'=>max(0, intdiv(Util::toInt($valid_until) - $now, $clock->ticksPerSecond())), + 'clockMode'=>$clock->getMode(), ]); } @@ -106,5 +108,7 @@ sortTokens($pick);//좀 무식하지만.. Json::die([ 'result'=>true, 'pick'=>$pick, - 'validUntil'=>$valid_until === null ? null : $clock->formatTick(Util::toInt($valid_until)) -]); + 'validUntil'=>$valid_until === null ? null : $clock->formatTick(Util::toInt($valid_until)), + 'validForSeconds'=>$valid_until === null ? 0 : max(0, intdiv(Util::toInt($valid_until) - $now, $clock->ticksPerSecond())), + 'clockMode'=>$clock->getMode(), +]); diff --git a/hwe/j_myBossInfo.php b/hwe/j_myBossInfo.php index e4ff7e3c..9404571d 100644 --- a/hwe/j_myBossInfo.php +++ b/hwe/j_myBossInfo.php @@ -290,7 +290,7 @@ function do추방(General $general, int $myOfficerLevel): ?string $src, $src, $str, - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [] ); diff --git a/hwe/j_server_basic_info.php b/hwe/j_server_basic_info.php index d7868a56..27119356 100644 --- a/hwe/j_server_basic_info.php +++ b/hwe/j_server_basic_info.php @@ -76,6 +76,7 @@ $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['starttime'] = substr($clock->formatTick(Util::toInt($admin['opentime'])), 5, 11); $admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); unset($admin['npcmode']); diff --git a/hwe/j_set_npc_control.php b/hwe/j_set_npc_control.php index bc8d0a96..f60bfa39 100644 --- a/hwe/j_set_npc_control.php +++ b/hwe/j_set_npc_control.php @@ -64,6 +64,7 @@ if ($permission < 3) { function applyNationPolicy($policy, $nationID, $generalName): ?string { $db = DB::db(); + $gameNow = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->formatNow(); $nationStor = KVStorage::getStorage($db, $nationID, 'nation_env'); $defaultPolicy = AutorunNationPolicy::$defaultPolicy; @@ -139,7 +140,7 @@ function applyNationPolicy($policy, $nationID, $generalName): ?string $nationPolicyRoot['values'] = $nationPolicy; $nationPolicyRoot['valueSetter'] = $generalName; - $nationPolicyRoot['valueSetTime'] = TimeUtil::now(); + $nationPolicyRoot['valueSetTime'] = $gameNow; $nationStor->npc_nation_policy = $nationPolicyRoot; return null; } @@ -147,6 +148,7 @@ function applyNationPolicy($policy, $nationID, $generalName): ?string function applyNationPriority($priority, $nationID, $generalName): ?string { $db = DB::db(); + $gameNow = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->formatNow(); $nationStor = KVStorage::getStorage($db, $nationID, 'nation_env'); $nationPolicyRoot = $nationStor->npc_nation_policy; @@ -158,7 +160,7 @@ function applyNationPriority($priority, $nationID, $generalName): ?string } $nationPolicyRoot['priority'] = $priority; $nationPolicyRoot['prioritySetter'] = $generalName; - $nationPolicyRoot['prioritySetTime'] = TimeUtil::now(); + $nationPolicyRoot['prioritySetTime'] = $gameNow; $nationStor->npc_nation_policy = $nationPolicyRoot; return null; } @@ -166,6 +168,7 @@ function applyNationPriority($priority, $nationID, $generalName): ?string function applyGeneralPriority($priority, $nationID, $generalName): ?string { $db = DB::db(); + $gameNow = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->formatNow(); $nationStor = KVStorage::getStorage($db, $nationID, 'nation_env'); $generalPolicyRoot = $nationStor->npc_general_policy; @@ -206,7 +209,7 @@ function applyGeneralPriority($priority, $nationID, $generalName): ?string $generalPolicyRoot['priority'] = $priority; $generalPolicyRoot['prioritySetter'] = $generalName; - $generalPolicyRoot['prioritySetTime'] = TimeUtil::now(); + $generalPolicyRoot['prioritySetTime'] = $gameNow; $nationStor->npc_general_policy = $generalPolicyRoot; return null; } diff --git a/hwe/sammo/API/Command/GetReservedCommand.php b/hwe/sammo/API/Command/GetReservedCommand.php index 195376ed..0a43ddfb 100644 --- a/hwe/sammo/API/Command/GetReservedCommand.php +++ b/hwe/sammo/API/Command/GetReservedCommand.php @@ -10,7 +10,6 @@ use sammo\GameConst; use sammo\GameClock; use sammo\Json; use sammo\KVStorage; -use sammo\TimeUtil; use sammo\Util; use function sammo\cutTurn; @@ -33,6 +32,7 @@ class GetReservedCommand extends \sammo\BaseAPI $commandList = []; $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); $generalID = $session->generalID; $invalidTurnList = 0; @@ -85,11 +85,12 @@ class GetReservedCommand extends \sammo\BaseAPI return [ 'result' => true, 'turnTimeTick' => Util::toInt($turnTime), - 'turnTime' => GameClock::fromStorage($gameStor)->formatTick(Util::toInt($turnTime)), + 'turnTime' => $clock->formatTick(Util::toInt($turnTime)), 'turnTerm' => $turnTerm, 'year' => $year, 'month' => $month, - 'date' => GameClock::fromStorage($gameStor)->formatTick(GameClock::fromStorage($gameStor)->nowTick(), true), + 'date' => $clock->formatTick($clock->nowTick(), true), + 'clockMode' => $clock->getMode(), 'turn' => $commandList, 'autorun_limit' => $generalAux['autorun_limit'] ?? null, ]; diff --git a/hwe/sammo/API/General/GetFrontInfo.php b/hwe/sammo/API/General/GetFrontInfo.php index 8fc44ef7..221a20fe 100644 --- a/hwe/sammo/API/General/GetFrontInfo.php +++ b/hwe/sammo/API/General/GetFrontInfo.php @@ -21,7 +21,6 @@ use sammo\LastTurn; use sammo\Validator; use sammo\Session; -use sammo\TimeUtil; use sammo\Util; use function sammo\buildNationCommandClass; @@ -185,8 +184,9 @@ class GetFrontInfo extends \sammo\BaseAPI $lastVote = null; if ($lastVoteID) { $voteStor = KVStorage::getStorage($db, 'vote'); - $lastVote = VoteInfo::fromArray($voteStor->getValue("vote_{$lastVoteID}")); - if ($lastVote->endDate && $lastVote->endDate < TimeUtil::now()) { + $rawLastVote = VoteInfo::normalizeGameStorage($voteStor->getValue("vote_{$lastVoteID}"), $clock); + $lastVote = VoteInfo::fromGameStorage($rawLastVote, $clock); + if ($rawLastVote['endTick'] !== null && $rawLastVote['endTick'] < $clock->nowTick()) { $lastVote = null; } } diff --git a/hwe/sammo/API/InheritAction/BuyRandomUnique.php b/hwe/sammo/API/InheritAction/BuyRandomUnique.php index 6e907b2e..05055a0b 100644 --- a/hwe/sammo/API/InheritAction/BuyRandomUnique.php +++ b/hwe/sammo/API/InheritAction/BuyRandomUnique.php @@ -10,7 +10,6 @@ use sammo\Enums\RankColumn; use sammo\GameConst; use sammo\General; use sammo\KVStorage; -use sammo\TimeUtil; use sammo\UserLogger; class BuyRandomUnique extends \sammo\BaseAPI @@ -56,7 +55,7 @@ class BuyRandomUnique extends \sammo\BaseAPI $userLogger->push("{$reqAmount} 포인트로 랜덤 유니크 구입", "inheritPoint"); $userLogger->flush(); - $general->setAuxVar('inheritRandomUnique', TimeUtil::now()); + $general->setAuxVar('inheritRandomUnique', true); $inheritStor->setValue('previous', [$previousPoint - $reqAmount, null]); $general->increaseRankVar(RankColumn::inherit_point_spent_dynamic, $reqAmount); $general->applyDB($db); diff --git a/hwe/sammo/API/InheritAction/CheckOwner.php b/hwe/sammo/API/InheritAction/CheckOwner.php index 00efbdbc..6e0f742d 100644 --- a/hwe/sammo/API/InheritAction/CheckOwner.php +++ b/hwe/sammo/API/InheritAction/CheckOwner.php @@ -116,7 +116,7 @@ class CheckOwner extends \sammo\BaseAPI $src, $dest, "{$destGeneralName}의 소유자는 {$destGeneralOwnerName} 입니다.", - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [] ); @@ -142,7 +142,7 @@ class CheckOwner extends \sammo\BaseAPI $src, $dest, "소유자명이 누군가에 의해 확인되었습니다.", - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [] ); diff --git a/hwe/sammo/API/Message/SendMessage.php b/hwe/sammo/API/Message/SendMessage.php index bc44c92d..ed697f93 100644 --- a/hwe/sammo/API/Message/SendMessage.php +++ b/hwe/sammo/API/Message/SendMessage.php @@ -44,7 +44,7 @@ class SendMessage extends \sammo\BaseAPI private function genPublicMessage(MessageTarget $src, string $text): Message { - $now = new \DateTime(); + $now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); $msg = new Message( @@ -62,7 +62,7 @@ class SendMessage extends \sammo\BaseAPI private function genNationalMessage(MessageTarget $src, string $text): Message { - $now = new \DateTime(); + $now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); $dest = new MessageTarget(0, '', $src->nationID, $src->nationName, $src->color); @@ -82,7 +82,7 @@ class SendMessage extends \sammo\BaseAPI private function genDiplomacyMessage(MessageTarget $src, int $destNationID, string $text): Message|string { - $now = new \DateTime(); + $now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); $destNation = getNationStaticInfo($destNationID); @@ -107,7 +107,7 @@ class SendMessage extends \sammo\BaseAPI private function genPrivateMessage(MessageTarget $src, int $destGeneralID, int $permission, string $text): Message|string { - $now = new \DateTime(); + $now = Message::gameNow(); $unlimited = new \DateTime('9999-12-31'); $db = DB::db(); diff --git a/hwe/sammo/API/Nation/SetNotice.php b/hwe/sammo/API/Nation/SetNotice.php index 8c2fa1aa..b0dac3ea 100644 --- a/hwe/sammo/API/Nation/SetNotice.php +++ b/hwe/sammo/API/Nation/SetNotice.php @@ -6,8 +6,8 @@ use sammo\Session; use DateTimeInterface; use sammo\DB; use sammo\Enums\APIRecoveryType; +use sammo\GameClock; use sammo\KVStorage; -use sammo\TimeUtil; use sammo\Validator; use sammo\WebUtil; @@ -51,8 +51,9 @@ class SetNotice extends \sammo\BaseAPI $nationID = $me['nation']; $nationStor = KVStorage::getStorage($db, $nationID, 'nation_env'); + $gameNow = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->formatNow(); $nationStor->nationNotice = [ - 'date'=>TimeUtil::now(), + 'date'=>$gameNow, 'msg'=>WebUtil::htmlPurify($msg), 'author'=>$me['name'], 'authorID'=>$me['no'], diff --git a/hwe/sammo/API/NationCommand/GetReservedCommand.php b/hwe/sammo/API/NationCommand/GetReservedCommand.php index 78af94eb..08e25eaf 100644 --- a/hwe/sammo/API/NationCommand/GetReservedCommand.php +++ b/hwe/sammo/API/NationCommand/GetReservedCommand.php @@ -12,7 +12,6 @@ use sammo\GameClock; use sammo\General; use sammo\Json; use sammo\KVStorage; -use sammo\TimeUtil; use sammo\Util; use function sammo\checkLimit; @@ -40,6 +39,7 @@ class GetReservedCommand extends \sammo\BaseAPI increaseRefresh("사령부", 1); $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); $userID = $session->userID; $me = $db->queryFirstRow( @@ -158,7 +158,8 @@ class GetReservedCommand extends \sammo\BaseAPI 'year' => $year, 'month' => $month, 'turnTerm' => $turnTerm, - 'date' => TimeUtil::now(true), + 'date' => $clock->formatTick($clock->nowTick(), true), + 'clockMode' => $clock->getMode(), 'chiefList' => $nationChiefList, 'troopList' => $troopList, 'isChief' => ($me['officer_level'] > 4), diff --git a/hwe/sammo/API/Vote/AddComment.php b/hwe/sammo/API/Vote/AddComment.php index 40b1899f..1bed91a0 100644 --- a/hwe/sammo/API/Vote/AddComment.php +++ b/hwe/sammo/API/Vote/AddComment.php @@ -10,8 +10,9 @@ use sammo\Enums\GeneralLiteQueryMode; use sammo\Enums\GeneralQueryMode; use sammo\General; use sammo\GeneralLite; +use sammo\GameClock; +use sammo\KVStorage; use sammo\Session; -use sammo\TimeUtil; use sammo\Validator; class AddComment extends \sammo\BaseAPI @@ -47,7 +48,8 @@ class AddComment extends \sammo\BaseAPI $generalName = $general->getName(); $nationID = $general->getNationID(); $nationName = $general->getStaticNation()['name']; - $date = TimeUtil::now(); + $db = DB::db(); + $date = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env'))->formatNow(); $comment = new VoteComment( @@ -61,7 +63,6 @@ class AddComment extends \sammo\BaseAPI date: $date ); - $db = DB::db(); $db->insert('vote_comment', $comment->toArray()); return null; diff --git a/hwe/sammo/API/Vote/GetVoteDetail.php b/hwe/sammo/API/Vote/GetVoteDetail.php index 26496aef..b5636a30 100644 --- a/hwe/sammo/API/Vote/GetVoteDetail.php +++ b/hwe/sammo/API/Vote/GetVoteDetail.php @@ -8,6 +8,7 @@ use sammo\DB; use sammo\DTO\VoteComment; use sammo\DTO\VoteInfo; use sammo\Enums\APIRecoveryType; +use sammo\GameClock; use sammo\Json; use sammo\KVStorage; use sammo\Validator; @@ -35,13 +36,16 @@ class GetVoteDetail extends \sammo\BaseAPI { $voteID = $this->args['voteID']; $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $voteStor = KVStorage::getStorage($db, 'vote'); $rawVote = $voteStor->getValue("vote_{$voteID}"); if (!$rawVote) { return '설문조사가 없습니다.'; } - $voteInfo = VoteInfo::fromArray($rawVote); + $rawVote = VoteInfo::normalizeGameStorage($rawVote, $clock); + $voteInfo = VoteInfo::fromGameStorage($rawVote, $clock); + $isOpen = $rawVote['endTick'] === null || $rawVote['endTick'] >= $clock->nowTick(); $votes = array_map(fn ($arr) => [Json::decode($arr[0]), $arr[1]], $db->queryAllLists( @@ -70,6 +74,7 @@ class GetVoteDetail extends \sammo\BaseAPI 'comments' => $comments, 'myVote' => $myVote, 'userCnt' => $userCnt, + 'isOpen' => $isOpen, ]; } } diff --git a/hwe/sammo/API/Vote/GetVoteList.php b/hwe/sammo/API/Vote/GetVoteList.php index 810e151e..664a4988 100644 --- a/hwe/sammo/API/Vote/GetVoteList.php +++ b/hwe/sammo/API/Vote/GetVoteList.php @@ -6,6 +6,7 @@ use DateTimeInterface; use sammo\DB; use sammo\DTO\VoteInfo; use sammo\Enums\APIRecoveryType; +use sammo\GameClock; use sammo\KVStorage; use sammo\Session; @@ -25,16 +26,17 @@ class GetVoteList extends \sammo\BaseAPI public function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag): null | string | array | APIRecoveryType { $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $voteStor = KVStorage::getStorage($db, 'vote'); $votes = []; foreach($voteStor->getAll() as $voteKey => $rawVote){ - if(!str_starts_with($voteKey, 'vote_')){ + if(preg_match('/^vote_(\d+)$/D', $voteKey, $matches) !== 1){ continue; } - $voteID = (int)substr($voteKey, 5); - $votes[$voteID] = VoteInfo::fromArray($rawVote); + $voteID = (int)$matches[1]; + $votes[$voteID] = VoteInfo::fromGameStorage($rawVote, $clock); } return [ diff --git a/hwe/sammo/API/Vote/NewVote.php b/hwe/sammo/API/Vote/NewVote.php index 507b759e..b12df17a 100644 --- a/hwe/sammo/API/Vote/NewVote.php +++ b/hwe/sammo/API/Vote/NewVote.php @@ -9,7 +9,7 @@ use sammo\Enums\APIRecoveryType; use sammo\KVStorage; use sammo\RootDB; use sammo\Session; -use sammo\TimeUtil; +use sammo\GameClock; use sammo\Util; use sammo\Validator; @@ -37,7 +37,7 @@ class NewVote extends \sammo\BaseAPI return null; } - function closeOldVote(int $voteID, KVStorage $voteStor) + function closeOldVote(int $voteID, KVStorage $voteStor, GameClock $clock) { $db = DB::db(); $voteStor = KVStorage::getStorage($db, 'vote'); @@ -45,13 +45,14 @@ class NewVote extends \sammo\BaseAPI if (!$rawLastVoteInfo) { return; } - $lastVoteInfo = VoteInfo::fromArray($rawLastVoteInfo); - if ($lastVoteInfo->endDate) { + $rawLastVoteInfo = VoteInfo::normalizeGameStorage($rawLastVoteInfo, $clock); + if ($rawLastVoteInfo['endTick'] !== null) { return; } - $lastVoteInfo->endDate = TimeUtil::now(); - $voteStor->setValue("vote_{$voteID}", $lastVoteInfo->toArray()); + $rawLastVoteInfo['endTick'] = $clock->nowTick(); + $rawLastVoteInfo['endDate'] = $clock->formatTick($rawLastVoteInfo['endTick']); + $voteStor->setValue("vote_{$voteID}", $rawLastVoteInfo); } function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag): null | string | array | APIRecoveryType @@ -71,7 +72,11 @@ class NewVote extends \sammo\BaseAPI $multipleOptions = 0; } - $now = TimeUtil::now(); + $db = DB::db(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $clock = GameClock::fromStorage($gameStor); + $nowTick = $clock->nowTick(); + $now = $clock->formatTick($nowTick); /** @var ?string */ $endDate = $this->args['endDate'] ?? null; /** @var string[] */ @@ -83,9 +88,9 @@ class NewVote extends \sammo\BaseAPI if($endDate !== null){ try{ - $oNow = new \DateTimeImmutable($now); $oEndDate = new \DateTimeImmutable($endDate); - if($oEndDate < $oNow){ + $endTick = $clock->dateTimeToTick($oEndDate); + if($endTick < $nowTick){ return '종료일이 이미 지났습니다.'; } } @@ -96,17 +101,13 @@ class NewVote extends \sammo\BaseAPI $userName = $session->userName; - $db = DB::db(); - $gameStor = KVStorage::getStorage($db, 'game_env'); - - $lastVote = $gameStor->getValue('lastVote') ?? 0; $voteID = $lastVote + 1; $voteStor = KVStorage::getStorage($db, 'vote'); if (!($this->args['keepOldVote'] ?? false)) { - $this->closeOldVote($lastVote, $voteStor); + $this->closeOldVote($lastVote, $voteStor, $clock); } $multipleOptions = Util::valueFit($multipleOptions, 0, count($options)); @@ -122,7 +123,10 @@ class NewVote extends \sammo\BaseAPI options: $options, ); - $voteStor->setValue("vote_{$voteID}", $voteInfo->toArray()); + $voteStor->setValue("vote_{$voteID}", $voteInfo->toArray() + [ + 'startTick' => $nowTick, + 'endTick' => $endDate === null ? null : $clock->dateTimeToTick(new \DateTimeImmutable($endDate)), + ]); $gameStor->setValue('lastVote', $voteID); $db->update('general', [ diff --git a/hwe/sammo/API/Vote/Vote.php b/hwe/sammo/API/Vote/Vote.php index fea230c3..885e90e0 100644 --- a/hwe/sammo/API/Vote/Vote.php +++ b/hwe/sammo/API/Vote/Vote.php @@ -8,6 +8,7 @@ use sammo\DTO\VoteInfo; use sammo\Enums\APIRecoveryType; use sammo\Enums\GeneralQueryMode; use sammo\General; +use sammo\GameClock; use sammo\Json; use sammo\KVStorage; use sammo\LiteHashDRBG; @@ -54,15 +55,17 @@ class Vote extends \sammo\BaseAPI return '선택한 항목이 없습니다.'; } $db = DB::db(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); $voteStor = KVStorage::getStorage($db, 'vote'); $rawVoteInfo = $voteStor->getValue("vote_{$voteID}"); if (!$rawVoteInfo) { return '설문조사가 없습니다.'; } - $voteInfo = VoteInfo::fromArray($rawVoteInfo); + $rawVoteInfo = VoteInfo::normalizeGameStorage($rawVoteInfo, $clock); + $voteInfo = VoteInfo::fromGameStorage($rawVoteInfo, $clock); - if ($voteInfo->endDate && $voteInfo->endDate < new \DateTimeImmutable()) { + if ($rawVoteInfo['endTick'] !== null && $rawVoteInfo['endTick'] < $clock->nowTick()) { return '설문조사가 종료되었습니다.'; } diff --git a/hwe/sammo/Command/Nation/che_몰수.php b/hwe/sammo/Command/Nation/che_몰수.php index f63a3cb8..20a429ed 100644 --- a/hwe/sammo/Command/Nation/che_몰수.php +++ b/hwe/sammo/Command/Nation/che_몰수.php @@ -193,7 +193,7 @@ class che_몰수 extends Command\NationCommand $src, $src, $text, - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [] ); diff --git a/hwe/sammo/Command/Nation/che_불가침제의.php b/hwe/sammo/Command/Nation/che_불가침제의.php index d33f76f5..a4057447 100644 --- a/hwe/sammo/Command/Nation/che_불가침제의.php +++ b/hwe/sammo/Command/Nation/che_불가침제의.php @@ -198,8 +198,9 @@ class che_불가침제의 extends Command\NationCommand $destNation['color'] ); - $now = new \DateTime($date); - $validUntil = new \DateTime($date); + $clock = \sammo\GameClock::fromStorage(\sammo\KVStorage::getStorage($db, 'game_env')); + $now = \DateTime::createFromImmutable($clock->tickToDateTime($general->getTurnTick())); + $validUntil = clone $now; $validMinutes = max(30, $env['turnterm'] * 3); $validUntil->add(new \DateInterval("PT{$validMinutes}M")); diff --git a/hwe/sammo/Command/Nation/che_불가침파기제의.php b/hwe/sammo/Command/Nation/che_불가침파기제의.php index 1cf27c35..9c46b8af 100644 --- a/hwe/sammo/Command/Nation/che_불가침파기제의.php +++ b/hwe/sammo/Command/Nation/che_불가침파기제의.php @@ -147,8 +147,9 @@ class che_불가침파기제의 extends Command\NationCommand{ $destNation['color'] ); - $now = new \DateTime($date); - $validUntil = new \DateTime($date); + $clock = \sammo\GameClock::fromStorage(\sammo\KVStorage::getStorage($db, 'game_env')); + $now = \DateTime::createFromImmutable($clock->tickToDateTime($general->getTurnTick())); + $validUntil = clone $now; $validMinutes = max(30, $env['turnterm']*3); $validUntil->add(new \DateInterval("PT{$validMinutes}M")); @@ -217,4 +218,4 @@ class che_불가침파기제의 extends Command\NationCommand{ ], ]; } -} \ No newline at end of file +} diff --git a/hwe/sammo/Command/Nation/che_종전제의.php b/hwe/sammo/Command/Nation/che_종전제의.php index 236cb4c3..49a0a6bc 100644 --- a/hwe/sammo/Command/Nation/che_종전제의.php +++ b/hwe/sammo/Command/Nation/che_종전제의.php @@ -145,8 +145,9 @@ class che_종전제의 extends Command\NationCommand{ $destNation['color'] ); - $now = new \DateTime($date); - $validUntil = new \DateTime($date); + $clock = \sammo\GameClock::fromStorage(\sammo\KVStorage::getStorage($db, 'game_env')); + $now = \DateTime::createFromImmutable($clock->tickToDateTime($general->getTurnTick())); + $validUntil = clone $now; $validMinutes = max(30, $env['turnterm']*3); $validUntil->add(new \DateInterval("PT{$validMinutes}M")); @@ -203,4 +204,4 @@ class che_종전제의 extends Command\NationCommand{ ], ]; } -} \ No newline at end of file +} diff --git a/hwe/sammo/DTO/VoteInfo.php b/hwe/sammo/DTO/VoteInfo.php index e6f817ab..22985a69 100644 --- a/hwe/sammo/DTO/VoteInfo.php +++ b/hwe/sammo/DTO/VoteInfo.php @@ -2,8 +2,41 @@ namespace sammo\DTO; +use sammo\GameClock; +use sammo\Util; + class VoteInfo extends \LDTO\DTO { + /** + * 기존 문자열만 가진 vote도 읽되, 저장 경계에서는 반드시 tick을 함께 둡니다. + * + * @return array + */ + public static function normalizeGameStorage(array $raw, GameClock $clock): array + { + if (!array_key_exists('startTick', $raw)) { + $raw['startTick'] = $clock->dateTimeToTick(new \DateTimeImmutable((string)$raw['startDate'])); + } + if (!array_key_exists('endTick', $raw)) { + $raw['endTick'] = ($raw['endDate'] ?? null) === null + ? null + : $clock->dateTimeToTick(new \DateTimeImmutable((string)$raw['endDate'])); + } + + $raw['startTick'] = Util::toInt($raw['startTick']); + $raw['endTick'] = $raw['endTick'] === null ? null : Util::toInt($raw['endTick']); + $raw['startDate'] = $clock->formatTick($raw['startTick']); + $raw['endDate'] = $raw['endTick'] === null ? null : $clock->formatTick($raw['endTick']); + return $raw; + } + + public static function fromGameStorage(array $raw, GameClock $clock): self + { + $raw = self::normalizeGameStorage($raw, $clock); + unset($raw['startTick'], $raw['endTick']); + return self::fromArray($raw); + } + public function __construct( public int $id, public string $title, diff --git a/hwe/sammo/DiplomaticMessage.php b/hwe/sammo/DiplomaticMessage.php index a439efa1..978747ba 100644 --- a/hwe/sammo/DiplomaticMessage.php +++ b/hwe/sammo/DiplomaticMessage.php @@ -215,7 +215,7 @@ class DiplomaticMessage extends Message{ $this->dest, $this->src, "【외교】{$year}년 {$month}월: {$this->src->nationName}{$josaYi} {$this->dest->nationName}에게 제안한 {$this->diplomacyDetail}", - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [ 'delete'=>$this->id, @@ -231,7 +231,7 @@ class DiplomaticMessage extends Message{ $this->dest, $this->src, "【외교】{$year}년 {$month}월: {$this->src->nationName}{$josaYi} {$this->dest->nationName}에게 제안한 {$this->diplomacyDetail}", - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [ 'delete'=>$this->id, diff --git a/hwe/sammo/Event/Action/OpenNationBetting.php b/hwe/sammo/Event/Action/OpenNationBetting.php index faec3be3..98440600 100644 --- a/hwe/sammo/Event/Action/OpenNationBetting.php +++ b/hwe/sammo/Event/Action/OpenNationBetting.php @@ -123,7 +123,7 @@ class OpenNationBetting extends \sammo\Event\Action } $logger->flush(); - $now = new DateTime(); + $now = Message::gameNow(); $text = "새로운 {$name} 내기가 열렸습니다. 천통국 베팅란을 확인해주세요."; $src = new MessageTarget(0, '', 0, 'System', '#000000'); diff --git a/hwe/sammo/GeneralAI.php b/hwe/sammo/GeneralAI.php index 9a54f03d..cd56768c 100644 --- a/hwe/sammo/GeneralAI.php +++ b/hwe/sammo/GeneralAI.php @@ -3727,7 +3727,7 @@ class GeneralAI $src, $src, $general->getVar('npcmsg'), - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [] ); diff --git a/hwe/sammo/Message.php b/hwe/sammo/Message.php index 169e4103..1a140d4f 100644 --- a/hwe/sammo/Message.php +++ b/hwe/sammo/Message.php @@ -26,6 +26,12 @@ class Message ) { } + public static function gameNow(): \DateTime + { + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + return \DateTime::createFromImmutable($clock->nowDateTime()); + } + public function setSentInfo(int $mailbox, int $messageID) : self { if(!Message::isValidMailBox($mailbox)){ @@ -78,6 +84,15 @@ class Message } public function toArray():array{ + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + $messageTick = $clock->dateTimeToTick($this->date); + $deleteUntilTick = GameClock::addTicks($messageTick, $clock->ticksFromMinutes(5)); + $deleteRemainingTicks = max(0, $deleteUntilTick - $clock->nowTick()); + $deleteRemainingTicks = min($deleteRemainingTicks, $clock->ticksFromSeconds(2_147_483)); + $deleteRemainingMilliseconds = intdiv( + $deleteRemainingTicks * 1000, + $clock->ticksPerSecond(), + ); if($this->msgType === MessageType::public){ $src = $this->src->toArray(); $dest = null; @@ -98,7 +113,9 @@ class Message 'dest'=>$dest, 'text'=>$this->msg, 'option'=>$this->msgOption, - 'time'=>$this->date->format('Y-m-d H:i:s') + 'time'=>$this->date->format('Y-m-d H:i:s'), + 'deleteRemainingMilliseconds'=>$deleteRemainingMilliseconds, + 'clockMode'=>$clock->getMode(), ]; } @@ -446,7 +463,7 @@ class Message $src, $dest, $msg, - new \DateTime(), + self::gameNow(), new \DateTime('9999-12-31'), [] ); @@ -480,6 +497,8 @@ class Message } public function invalidate(?array $newMsgOption=null, bool $hideMsg=true){ + $clock = GameClock::fromStorage(KVStorage::getStorage(DB::db(), 'game_env')); + $validUntilTick = $clock->dateTimeToTick($this->validUntil); if($newMsgOption !== null){ $this->msgOption = $newMsgOption; } @@ -487,7 +506,8 @@ class Message $this->msgOption['invalid'] = true; if($hideMsg){ - $this->validUntil = new \DateTime('2000-12-31'); + $validUntilTick = GameClock::addTicks($clock->nowTick(), -1); + $this->validUntil = \DateTime::createFromImmutable($clock->tickToDateTime($validUntilTick)); } else{ if(key_exists('receiverMessageID', $this->msgOption)){ @@ -505,8 +525,7 @@ class Message 'text' => $this->msg, 'option' => $this->msgOption ]), - 'valid_until'=>GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')) - ->dateTimeToTick($this->validUntil), + 'valid_until'=>$validUntilTick, ], 'id=%i', $this->id); } diff --git a/hwe/sammo/RaiseInvaderMessage.php b/hwe/sammo/RaiseInvaderMessage.php index e70fee8e..ab840713 100644 --- a/hwe/sammo/RaiseInvaderMessage.php +++ b/hwe/sammo/RaiseInvaderMessage.php @@ -142,7 +142,7 @@ class RaiseInvaderMessage extends Message $srcTarget = MessageTarget::buildSystemTarget(); $destTarget = MessageTarget::buildQuick($destGeneralID); if ($date === null) { - $date = new \DateTime(); + $date = Message::gameNow(); } /** diff --git a/hwe/sammo/ScoutMessage.php b/hwe/sammo/ScoutMessage.php index e70aff45..202b4a1a 100644 --- a/hwe/sammo/ScoutMessage.php +++ b/hwe/sammo/ScoutMessage.php @@ -108,7 +108,7 @@ class ScoutMessage extends Message $this->src, $this->dest, "{$this->src->nationName}{$josaRo} 등용 제의 수락", - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [ 'delete' => $this->id @@ -156,7 +156,7 @@ class ScoutMessage extends Message $this->src, $this->dest, "{$this->src->nationName}{$josaRo} 등용 제의 거부", - new \DateTime(), + Message::gameNow(), new \DateTime('9999-12-31'), [ 'delete' => $this->id @@ -205,9 +205,9 @@ class ScoutMessage extends Message $db = DB::db(); $srcGeneral = $db->queryFirstRow('SELECT `name`, nation FROM general WHERE `no`=%i', $srcGeneralID); $destGeneral = $db->queryFirstRow('SELECT `name`, nation, `officer_level` FROM general WHERE `no`=%i', $destGeneralID); - if ($date === null) { - $date = new \DateTime(); - } + if ($date === null) { + $date = Message::gameNow(); + } if ($destGeneral['officer_level'] == 12) { if ($reason !== null) { diff --git a/hwe/sammo/TurnExecutionHelper.php b/hwe/sammo/TurnExecutionHelper.php index 761af2ef..3dc67ce4 100644 --- a/hwe/sammo/TurnExecutionHelper.php +++ b/hwe/sammo/TurnExecutionHelper.php @@ -6,15 +6,20 @@ use sammo\Enums\EventTarget; use sammo\Enums\InheritanceKey; use \Symfony\Component\Lock; -class TurnExecutionHelper -{ +class TurnExecutionHelper +{ /** @var General*/ protected $generalObj; - public function __construct(General $general) + public function __construct(General $general) { $this->generalObj = $general; - } + } + + public static function monotonicCompletionTick(int $completedTick, int $candidateTick): int + { + return max($completedTick, $candidateTick); + } public function __destruct() { @@ -450,9 +455,12 @@ class TurnExecutionHelper updateTraffic(); if ($executionOver) { - if ($currentTurn !== null) { - $executed = true; - $gameStor->turntime = $currentTurn; + if ($currentTurn !== null) { + $executed = true; + $gameStor->turntime = self::monotonicCompletionTick( + Util::toInt($gameStor->turntime), + $currentTurn, + ); } unlock(); return $gameStor->turntime; @@ -497,10 +505,16 @@ class TurnExecutionHelper $gameStor->month ); - if ($currentTurn !== null) { - $executed = true; - $gameStor->turntime = $currentTurn; - } + if ($currentTurn !== null) { + $executed = true; + // A general's sub-tick can be just before the monthly boundary that + // was completed above. Never move the global completion cursor back + // behind an already-applied monthly event. + $gameStor->turntime = self::monotonicCompletionTick( + Util::toInt($gameStor->turntime), + $currentTurn, + ); + } //토너먼트 처리 processTournament(); diff --git a/hwe/sammo/UserLogger.php b/hwe/sammo/UserLogger.php index 0ce91599..8ee22cd2 100644 --- a/hwe/sammo/UserLogger.php +++ b/hwe/sammo/UserLogger.php @@ -55,7 +55,8 @@ class UserLogger } $db = DB::db(); - $date = TimeUtil::now(); + $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); + $date = $clock->formatTick($clock->nowTick()); $serverID = UniqueConst::$serverID; $request = array_map(function ($textAndType) use ($date, $serverID) { [$text, $type] = $textAndType; diff --git a/hwe/sql/schema.sql b/hwe/sql/schema.sql index db095f46..6eeefdfb 100644 --- a/hwe/sql/schema.sql +++ b/hwe/sql/schema.sql @@ -297,7 +297,7 @@ CREATE TABLE IF NOT EXISTS `ng_old_nations` ( `server_id` CHAR(20) NOT NULL DEFAULT '0', `nation` INT(11) NOT NULL DEFAULT '0', `data` LONGTEXT NOT NULL DEFAULT '{}' COLLATE 'utf8mb4_bin', - `date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date` DATETIME NOT NULL, PRIMARY KEY (`id`), INDEX `server_id` (`server_id`, `nation`), CONSTRAINT `json` CHECK (json_valid(`data`)) @@ -395,7 +395,7 @@ CREATE TABLE `ng_diplomacy` ( `state` ENUM('proposed', 'activated', 'cancelled', 'replaced') NOT NULL DEFAULT 'proposed', `text_brief` TEXT NOT NULL, `text_detail` TEXT NOT NULL, - `date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date` DATETIME NOT NULL, `src_signer` INT(11) NOT NULL, `dest_signer` INT(11) NULL DEFAULT NULL, `aux` TEXT NULL DEFAULT NULL COLLATE 'utf8mb4_bin', diff --git a/hwe/ts/PageChiefCenter.vue b/hwe/ts/PageChiefCenter.vue index 2ec3ec78..d9722ff6 100644 --- a/hwe/ts/PageChiefCenter.vue +++ b/hwe/ts/PageChiefCenter.vue @@ -42,6 +42,7 @@ :maxTurn="maxChiefTurn" :maxPushTurn="Math.floor(maxChiefTurn / 2)" :date="date" + :clockMode="clockMode" :officer="officer" @raiseReload="reloadTable()" /> @@ -139,6 +140,7 @@ const tableObj = reactive, "result">>({ month: undefined, turnTerm: undefined, date: undefined, + clockMode: undefined, troopList: undefined, chiefList: undefined, isChief: undefined, @@ -149,7 +151,7 @@ const tableObj = reactive, "result">>({ unitSet: undefined, }); -const { year, month, turnTerm, date, chiefList, troopList, officerLevel, commandList } = toRefs(tableObj); +const { year, month, turnTerm, date, clockMode, chiefList, troopList, officerLevel, commandList } = toRefs(tableObj); let postFilterNationCommand = function (turnObj: TurnObj): TurnObj { return turnObj; diff --git a/hwe/ts/PageVote.vue b/hwe/ts/PageVote.vue index cf2fc070..fb17ccde 100644 --- a/hwe/ts/PageVote.vue +++ b/hwe/ts/PageVote.vue @@ -195,7 +195,6 @@ import { onMounted, reactive, ref, watch, computed } from "vue"; import type { VoteInfo, VoteDetailResult } from "@/defs/API/Vote"; import { SammoAPI } from "@/SammoAPI"; import { isString, range, sum } from "lodash-es"; -import { formatTime } from "@/util/formatTime"; import { isBrightColor } from "@/util/isBrightColor"; import { formatVoteColor } from "@/utilGame/formatVoteColor"; @@ -239,14 +238,7 @@ const canVote = computed(() => { if (currentVote.value.myVote) { return false; } - const endDate = currentVote.value.voteInfo.endDate; - if (endDate) { - const now = formatTime(new Date()); - if (now > endDate) { - return false; - } - } - return true; + return currentVote.value.isOpen; }); const currentVoteID = ref(); diff --git a/hwe/ts/PartialReservedCommand.vue b/hwe/ts/PartialReservedCommand.vue index 9ae40697..79b5c759 100644 --- a/hwe/ts/PartialReservedCommand.vue +++ b/hwe/ts/PartialReservedCommand.vue @@ -13,7 +13,7 @@ class="col alert alert-primary m-0 p-0" style="text-align: center; display: flex; justify-content: center; align-items: center" > - +
@@ -467,6 +467,7 @@ async function pushGeneralCommand(amount: number) { } const serverNow = ref(new Date()); +const clockRunning = ref(true); function pushGeneralCommandSingle(e: Event) { //NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함 @@ -548,6 +549,7 @@ async function reloadCommandList() { } serverNow.value = parseTime(result.date); + clockRunning.value = result.clockMode === "realtime"; } async function reserveCommandDirect(args: [number[], TurnObj][], reload = true): Promise { diff --git a/hwe/ts/components/ChiefReservedCommand.vue b/hwe/ts/components/ChiefReservedCommand.vue index 94ac76fd..dcd9cf5d 100644 --- a/hwe/ts/components/ChiefReservedCommand.vue +++ b/hwe/ts/components/ChiefReservedCommand.vue @@ -27,7 +27,7 @@
- +
@@ -289,6 +289,10 @@ const props = defineProps({ maxTurn: VueTypes.integer.isRequired, maxPushTurn: VueTypes.integer.isRequired, date: VueTypes.string.isRequired, + clockMode: { + type: String as PropType<"realtime" | "manual">, + required: true, + }, year: VueTypes.integer.isRequired, month: VueTypes.integer.isRequired, turnTerm: VueTypes.integer.isRequired, diff --git a/hwe/ts/components/MessagePlate.vue b/hwe/ts/components/MessagePlate.vue index a9d53e67..de87f40f 100644 --- a/hwe/ts/components/MessagePlate.vue +++ b/hwe/ts/components/MessagePlate.vue @@ -126,8 +126,6 @@ diff --git a/hwe/ts/defs/API/Command.ts b/hwe/ts/defs/API/Command.ts index fe3f6e16..84d749b3 100644 --- a/hwe/ts/defs/API/Command.ts +++ b/hwe/ts/defs/API/Command.ts @@ -7,6 +7,7 @@ export type ReservedCommandResponse = { year: number; month: number; date: string; + clockMode: "realtime" | "manual"; turn: TurnObj[]; autorun_limit: null | number; }; diff --git a/hwe/ts/defs/API/Message.ts b/hwe/ts/defs/API/Message.ts index b583bc8f..7af3e920 100644 --- a/hwe/ts/defs/API/Message.ts +++ b/hwe/ts/defs/API/Message.ts @@ -34,6 +34,8 @@ export type MsgItem = { delete?: number; }; time: string; + deleteRemainingMilliseconds: number; + clockMode: "realtime" | "manual"; }; export type MsgPrintItem = MsgItem & { @@ -77,4 +79,4 @@ export type MailboxItem = { export type MabilboxListResponse = { result: true, nation: MailboxItem[] -} \ No newline at end of file +} diff --git a/hwe/ts/defs/API/NationCommand.ts b/hwe/ts/defs/API/NationCommand.ts index 2b00e55c..fbfe3004 100644 --- a/hwe/ts/defs/API/NationCommand.ts +++ b/hwe/ts/defs/API/NationCommand.ts @@ -7,6 +7,7 @@ export type ChiefResponse = { month: number; turnTerm: number; date: string; + clockMode: "realtime" | "manual"; chiefList: Record< number, { @@ -28,4 +29,4 @@ export type ChiefResponse = { }[]; mapName: string, unitSet: string, -}; \ No newline at end of file +}; diff --git a/hwe/ts/defs/API/Vote.ts b/hwe/ts/defs/API/Vote.ts index f0dddd5b..60b5cd56 100644 --- a/hwe/ts/defs/API/Vote.ts +++ b/hwe/ts/defs/API/Vote.ts @@ -30,4 +30,5 @@ export type VoteDetailResult = ValidResponse & { comments: VoteComment[], myVote: null|number[], userCnt: number, -} \ No newline at end of file + isOpen: boolean, +} diff --git a/hwe/ts/gateway/entrance.ts b/hwe/ts/gateway/entrance.ts index 510a884a..aa082eb8 100644 --- a/hwe/ts/gateway/entrance.ts +++ b/hwe/ts/gateway/entrance.ts @@ -5,7 +5,6 @@ import axios from 'axios'; import { initTooltip } from "@/legacy/initTooltip"; import { TemplateEngine } from '@util/TemplateEngine'; import type { InvalidResponse } from '@/defs'; -import { getDateTimeNow } from '@util/getDateTimeNow'; import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest'; import { loadPlugin as loadAdminPlugin } from '@/gateway/admin_server'; import '@/gateway/common'; @@ -108,6 +107,7 @@ type ReservedGameInfo = { type GameInfo = { isUnited: number, + isOpen: boolean, npcMode: '불가' | '가능' | '선택 생성', year: number, month: number, @@ -175,7 +175,6 @@ async function Entrance_UpdateServer() { async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { const $serverList = $('#server_list'); - const now = getDateTimeNow(); const serverDetailInfoP: Record> = {}; @@ -239,7 +238,7 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { } else if (game.isUnited == 2) { $serverHtml.find('.n_country').html('§천하통일§'); $serverHtml.find('.server_date').html(`${game.starttime}
~ ${game.turntime}`); - } else if (game.opentime <= now) { + } else if (game.isOpen) { $serverHtml.find('.n_country').html(`<${game.nationCnt}국 경쟁중>`); $serverHtml.find('.server_date').html(`${game.starttime} ~`); } else { @@ -247,7 +246,7 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { $serverHtml.find('.server_date').html(`${game.starttime} ~`); } - if (game.opentime <= now) { + if (game.isOpen) { $serverHtml.append( TemplateEngine(serverTextInfo, game) ); @@ -294,4 +293,4 @@ async function Entrance_Logout() { return; } location.href = "../"; -} \ No newline at end of file +} diff --git a/hwe/ts/msg.ts b/hwe/ts/msg.ts index de7229fe..f3823e34 100644 --- a/hwe/ts/msg.ts +++ b/hwe/ts/msg.ts @@ -1,15 +1,11 @@ import $ from 'jquery'; import type { InvalidResponse } from '@/defs'; -import { getDateTimeNow } from '@util/getDateTimeNow'; import axios from 'axios'; import { convertFormData } from '@util/convertFormData'; import { isBrightColor } from "@util/isBrightColor"; import { unwrap } from '@util/unwrap'; import _, { isError, isString } from 'lodash-es'; -import { addMinutes } from 'date-fns'; -import { parseTime } from '@util/parseTime'; -import { formatTime } from '@util/formatTime'; import { TemplateEngine } from '@util/TemplateEngine'; import { isNotNull } from '@util/isNotNull'; import { unwrap_any } from '@util/unwrap_any'; @@ -32,8 +28,8 @@ const messageTemplate = `
- <%if(!this.option.action && src.id == myGeneralID && now <= last5min && invalidType == 'msg_valid' && !deletable){%> - + <%if(!this.option.action && src.id == myGeneralID && deleteRemainingMilliseconds > 0 && invalidType == 'msg_valid' && !deletable){%> + <%}%> <%if(msgType == 'private') {%> <%if(src.name == generalName){%> @@ -101,8 +97,7 @@ type MsgPrintItem = MsgItem & { nationType: 'local' | 'src' | 'dest'; myGeneralID: number; allowButton: boolean; - last5min: string; - now: string; + eraseUntil: number; invalidType: 'msg_invalid' | 'msg_valid'; deletable: boolean; src: MsgTarget & { colorType: 'bright' | 'dark' }, @@ -217,7 +212,7 @@ async function showOldMsg(msgType: MsgType): Promise { function redrawMsg(msgResponse: MsgResponse, addFront: boolean): MsgResponse { function checkErasable(obj: MsgResponse) { - const now = getDateTimeNow(); + const now = Date.now(); $('.btn-delete-msg').each(function () { const $btn = $(this); const eraseUntil = $btn.data('erase_until'); @@ -257,7 +252,6 @@ function redrawMsg(msgResponse: MsgResponse, addFront: boolean): MsgResponse { let needRefreshLastContact = (msgType == 'private'); - const now = getDateTimeNow(); //list의 맨 앞이 가장 최신 메시지임. const $msgs: JQuery[] = msgSource.map(function (msg) { @@ -311,7 +305,9 @@ function redrawMsg(msgResponse: MsgResponse, addFront: boolean): MsgResponse { allowButton = true; } - const last5min = formatTime(addMinutes(parseTime(msg.time), 5)); + const eraseUntil = msg.clockMode === "manual" + ? Number.MAX_SAFE_INTEGER + : Date.now() + msg.deleteRemainingMilliseconds; let invalidType: MsgPrintItem['invalidType']; if (msg.option && msg.option.invalid) { invalidType = 'msg_invalid'; @@ -336,9 +332,8 @@ function redrawMsg(msgResponse: MsgResponse, addFront: boolean): MsgResponse { myGeneralID: unwrap(myGeneralID), src, dest, - now, allowButton, - last5min, + eraseUntil, invalidType, deletable, defaultIcon, @@ -653,4 +648,4 @@ $(async function ($) { const msgType = $this.data('msg_type'); void showOldMsg(msgType); }) -}); \ No newline at end of file +}); diff --git a/hwe/ts/select_general_from_pool.ts b/hwe/ts/select_general_from_pool.ts index 03801c77..ba72eb84 100644 --- a/hwe/ts/select_general_from_pool.ts +++ b/hwe/ts/select_general_from_pool.ts @@ -42,8 +42,12 @@ type CardItem = { type GeneralPoolResponse = { result: true, pick: CardItem[], - validUntil: string, -} + validUntil: string, + validForSeconds: number, + clockMode: "realtime" | "manual", +} + +let logicalClockRunning = true; declare const characterInfo: Record; declare const hasGeneralID: number; @@ -185,7 +189,10 @@ async function buildGeneral(e: JQuery.Event) { location.href = './'; } -function updateOutdateTimer() { +function updateOutdateTimer() { + if (!logicalClockRunning) { + return; + } const $validUntilText = $('#valid_until_text'); const now = Date.now(); const validUntil = $validUntilText.data('until'); @@ -204,10 +211,11 @@ function updateOutdateTimer() { setTimeout(updateOutdateTimer, 1000); } -function printGenerals(value: GeneralPoolResponse) { +function printGenerals(value: GeneralPoolResponse) { + logicalClockRunning = value.clockMode === "realtime"; $('.card_holder').empty(); $('#valid_until').show(); - $('#valid_until_text').html(value.validUntil).data('until', (new Date(value.validUntil)).getTime()).css('color', 'white'); + $('#valid_until_text').html(value.validUntil).data('until', Date.now() + value.validForSeconds * 1000).css('color', 'white'); $('#outdate_token').hide(); const pick = value.pick.map(v => v);//XXX: 의도가 뭐였지? clone? diff --git a/hwe/ts/select_npc.ts b/hwe/ts/select_npc.ts index 354fbd32..3adf5920 100644 --- a/hwe/ts/select_npc.ts +++ b/hwe/ts/select_npc.ts @@ -68,9 +68,13 @@ type NPCToken = { result: true, pick: Record, pickMoreFrom: string, - pickMoreSeconds: number, - validUntil: string, -} + pickMoreSeconds: number, + validUntil: string, + validForSeconds: number, + clockMode: "realtime" | "manual", +} + +let logicalClockRunning = true; const templateGeneralCard = '
\ @@ -146,7 +150,10 @@ async function pickGeneral(this: HTMLElement, e: JQuery.Event) { location.href = './'; } -function updateOutdateTimer() { +function updateOutdateTimer() { + if (!logicalClockRunning) { + return; + } const $validUntilText = $('#valid_until_text'); const now = Date.now(); const validUntil = $validUntilText.data('until'); @@ -165,8 +172,15 @@ function updateOutdateTimer() { setTimeout(updateOutdateTimer, 1000); } -function updatePickMoreTimer() { - const $btn = $('#btn_pick_more'); +function updatePickMoreTimer() { + const $btn = $('#btn_pick_more'); + + if (!logicalClockRunning) { + const remain = Number($btn.data('remaining')); + $btn.prop('disabled', remain > 0); + $btn.html(remain > 0 ? '다른 장수 보기(논리 시계 대기)' : '다른 장수 보기'); + return; + } const now = Date.now(); const remain = ($btn.data('available') - now) / 1000; @@ -181,13 +195,14 @@ function updatePickMoreTimer() { setTimeout(updatePickMoreTimer, 250); } -function printGenerals(value: NPCToken) { +function printGenerals(value: NPCToken) { + logicalClockRunning = value.clockMode === "realtime"; $('.card_holder').empty(); $('#valid_until').show(); - $('#valid_until_text').html(value.validUntil).data('until', (new Date(value.validUntil)).getTime()).css('color', 'white'); + $('#valid_until_text').html(value.validUntil).data('until', Date.now() + value.validForSeconds * 1000).css('color', 'white'); $('#outdate_token').hide(); const time = Date.now() + value.pickMoreSeconds * 1000; - $('#btn_pick_more').data('available', time).prop('disabled', true); + $('#btn_pick_more').data('available', time).data('remaining', value.pickMoreSeconds).prop('disabled', true); const pick = $.map(value.pick, function (value) { return value; diff --git a/scripts/README-game-clock.md b/scripts/README-game-clock.md index 729f8177..df418966 100644 --- a/scripts/README-game-clock.md +++ b/scripts/README-game-clock.md @@ -35,7 +35,12 @@ anchor에 고정하므로 표시 시각이 튀지 않습니다. ```bash php scripts/verify-game-clock-engine.php --apply --engine-calls=2 +php scripts/verify-game-clock-engine.php --apply --until-unification --max-months=2400 ``` 이 검증기는 manual mode만 허용하고, 엔진 호출 전후 clock tick이 벽시계 때문에 변하지 않았는지와 마지막 처리 tick이 현재 tick을 넘지 않았는지 검사합니다. +`--until-unification`은 달력이나 DB 시각을 고쳐 쓰지 않고, 매월 manual clock만 +정확히 다음 turn tick으로 옮긴 뒤 실제 `TurnExecutionHelper`를 반복 호출합니다. +각 호출에서 시계 고정과 처리 tick 상한을 재검사하고 `isunited=2|3`이 되지 않으면 +성공으로 취급하지 않습니다. 반드시 격리 복제 DB에서 실행하세요. diff --git a/scripts/migrate-game-clock.php b/scripts/migrate-game-clock.php index fcca414d..3d3317f6 100644 --- a/scripts/migrate-game-clock.php +++ b/scripts/migrate-game-clock.php @@ -4,6 +4,7 @@ declare(strict_types=1); use sammo\DB; +use sammo\DTO\VoteInfo; use sammo\GameClock; use sammo\Json; use sammo\KVStorage; @@ -269,6 +270,30 @@ try { 'last천도Trial', ); } + foreach ($db->query( + 'SELECT `key`, value FROM storage WHERE namespace = %s AND `key` LIKE %s AND `key` NOT LIKE %s', + 'vote', + 'vote\_%', + '%\_wall\_backup', + ) as $row) { + $rawVote = Json::decode((string)$row['value']); + if (!is_array($rawVote)) { + throw new RuntimeException("{$row['key']} vote 저장값이 객체가 아닙니다."); + } + $db->insertUpdate('storage', [ + 'namespace' => 'vote', + 'key' => "{$row['key']}_wall_backup", + 'value' => Json::encode($rawVote), + ]); + $db->update('storage', [ + 'value' => Json::encode(VoteInfo::normalizeGameStorage($rawVote, $conversionClock)), + ], 'namespace = %s AND `key` = %s', 'vote', $row['key']); + } + + // Historical/display DATETIME columns remain dates, but inserts must always + // receive a GameClock-projected value rather than silently reading MariaDB time. + $db->query('ALTER TABLE ng_old_nations MODIFY `date` DATETIME NOT NULL'); + $db->query('ALTER TABLE ng_diplomacy MODIFY `date` DATETIME NOT NULL'); $db->query( 'ALTER TABLE general ' diff --git a/scripts/verify-game-clock-engine.php b/scripts/verify-game-clock-engine.php index 432b76a3..2d3eab9c 100644 --- a/scripts/verify-game-clock-engine.php +++ b/scripts/verify-game-clock-engine.php @@ -19,16 +19,25 @@ $_SERVER['REQUEST_URI'] ??= '/cli/verify-game-clock-engine'; require dirname(__DIR__) . '/hwe/lib.php'; require dirname(__DIR__) . '/hwe/func.php'; +while (ob_get_level() > 0) { + ob_end_flush(); +} +ob_implicit_flush(true); -$options = getopt('', ['apply', 'engine-calls:']); +$options = getopt('', ['apply', 'engine-calls:', 'until-unification', 'max-months:']); if (!isset($options['apply'])) { - fwrite(STDERR, "Usage: php scripts/verify-game-clock-engine.php --apply [--engine-calls=N]\n"); + fwrite(STDERR, "Usage: php scripts/verify-game-clock-engine.php --apply [--engine-calls=N] [--until-unification --max-months=N]\n"); exit(2); } -$engineCalls = filter_var($options['engine-calls'] ?? '1', FILTER_VALIDATE_INT); +$untilUnification = isset($options['until-unification']); +$engineCalls = filter_var($options['engine-calls'] ?? ($untilUnification ? '100' : '1'), FILTER_VALIDATE_INT); if ($engineCalls === false || $engineCalls < 1 || $engineCalls > 1000) { throw new InvalidArgumentException('--engine-calls는 1..1000이어야 합니다.'); } +$maxMonths = filter_var($options['max-months'] ?? '2400', FILTER_VALIDATE_INT); +if ($maxMonths === false || $maxMonths < 1 || $maxMonths > 10000) { + throw new InvalidArgumentException('--max-months는 1..10000이어야 합니다.'); +} $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); @@ -39,6 +48,112 @@ if ($clock->getMode() !== GameClock::MODE_MANUAL) { $fixedNowTick = $clock->nowTick(); $before = $gameStor->getValues(['year', 'month', 'turntime']); + +if ($untilUnification) { + $startedAt = GameClock::readWallTime(); + $startProjection = $clock->formatTick($fixedNowTick, true); + $advancedMonths = 0; + $totalEngineCalls = 0; + while ($advancedMonths < $maxMonths) { + $gameStor->resetCache(); + $state = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'isunited']); + if (in_array(Util::toInt($state['isunited']), [2, 3], true)) { + break; + } + + $beforeYearMonth = Util::joinYearMonth(Util::toInt($state['year']), Util::toInt($state['month'])); + $clock = GameClock::fromStorage($gameStor); + $nextMonthBoundary = $clock->addTurns( + \sammo\cutTurn(Util::toInt($state['turntime']), Util::toInt($state['turnterm'])), + 1, + ); + $nextMonthTick = GameClock::addTicks($nextMonthBoundary, 1); + + if (!\sammo\tryLock()) { + throw new RuntimeException('manual clock 전진을 위한 GAME lock을 획득하지 못했습니다.'); + } + try { + $clock->persistTick($gameStor, $nextMonthTick, GameClock::MODE_MANUAL); + } finally { + $gameStor->resetCache(); + \sammo\unlock(); + } + + $monthAdvanced = false; + for ($call = 0; $call < $engineCalls; $call++) { + $executed = false; + $locked = false; + TurnExecutionHelper::executeAllCommand($executed, $locked); + $totalEngineCalls++; + $gameStor->resetCache(); + $clock = GameClock::fromStorage($gameStor); + if ($clock->getMode() !== GameClock::MODE_MANUAL || $clock->nowTick() !== $nextMonthTick) { + throw new RuntimeException('실제 턴 엔진 실행 중 manual clock 상태가 벽시계에 의해 바뀌었습니다.'); + } + $afterCall = $gameStor->getValues(['year', 'month', 'turntime', 'isunited']); + if (Util::toInt($afterCall['turntime']) > $nextMonthTick) { + throw new RuntimeException('마지막 실행 tick이 현재 manual clock tick을 넘어갔습니다.'); + } + if (in_array(Util::toInt($afterCall['isunited']), [2, 3], true)) { + $monthAdvanced = true; + break; + } + // turntime is the authoritative completed schedule boundary. Some + // legacy monthly state is cached until the next storage read, so do + // not spin merely because year/month from that same call is stale. + if (Util::toInt($afterCall['turntime']) >= $nextMonthBoundary) { + $monthAdvanced = true; + break; + } + $afterYearMonth = Util::joinYearMonth(Util::toInt($afterCall['year']), Util::toInt($afterCall['month'])); + if ($afterYearMonth !== $beforeYearMonth) { + $monthAdvanced = true; + break; + } + if ($locked) { + throw new RuntimeException('통일 전 실제 턴 엔진이 GAME lock 또는 동결 상태에 머물렀습니다.'); + } + } + if (!$monthAdvanced) { + throw new RuntimeException("한 달을 {$engineCalls}회 엔진 호출 안에 완료하지 못했습니다."); + } + + $advancedMonths++; + if ($advancedMonths % 12 === 0) { + $gameStor->resetCache(); + printf( + "progress months=%d game=%d-%02d clock_tick=%d nations=%d engine_calls=%d\n", + $advancedMonths, + Util::toInt($gameStor->year), + Util::toInt($gameStor->month), + GameClock::fromStorage($gameStor)->nowTick(), + Util::toInt($db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0')), + $totalEngineCalls, + ); + } + } + + $gameStor->resetCache(); + $finalState = $gameStor->getValues(['year', 'month', 'turntime', 'isunited']); + if (!in_array(Util::toInt($finalState['isunited']), [2, 3], true)) { + throw new RuntimeException("{$maxMonths}개월 안에 천하통일에 도달하지 못했습니다."); + } + $finalClock = GameClock::fromStorage($gameStor); + printf( + "UNIFIED months=%d engine_calls=%d game=%d-%02d isunited=%d clock_tick=%d projected=%s start_projected=%s wall_elapsed=%.6f\n", + $advancedMonths, + $totalEngineCalls, + Util::toInt($finalState['year']), + Util::toInt($finalState['month']), + Util::toInt($finalState['isunited']), + $finalClock->nowTick(), + $finalClock->formatNow(true), + $startProjection, + (float)GameClock::readWallTime()->format('U.u') - (float)$startedAt->format('U.u'), + ); + exit(0); +} + $executedCount = 0; for ($call = 0; $call < $engineCalls; $call++) { $executed = false; diff --git a/src/sammo/GameClock.php b/src/sammo/GameClock.php index 0103ff67..cc874989 100644 --- a/src/sammo/GameClock.php +++ b/src/sammo/GameClock.php @@ -117,6 +117,11 @@ final class GameClock return self::addTicks($this->anchorTick, $this->ticksBetween($this->wallAnchor, $this->wallNow())); } + public function nowDateTime(): \DateTimeImmutable + { + return $this->tickToDateTime($this->nowTick()); + } + public function ticksFromSeconds(int|float $seconds): int { if (is_int($seconds)) { @@ -207,6 +212,11 @@ final class GameClock return TimeUtil::format($this->tickToDateTime($tick), $withFraction); } + public function formatNow(bool $withFraction = false): string + { + return $this->formatTick($this->nowTick(), $withFraction); + } + public static function baseTimeForProjection( \DateTimeInterface $projectedTime, int $tick, @@ -249,7 +259,7 @@ final class GameClock public function advance(KVStorage $gameStor, int $deltaTick): int { - $nextTick = $this->nowTick() + $deltaTick; + $nextTick = self::addTicks($this->nowTick(), $deltaTick); $this->persistTick($gameStor, $nextTick); return $nextTick; } diff --git a/src/sammo/Session.php b/src/sammo/Session.php index 86ed2e82..0f749173 100644 --- a/src/sammo/Session.php +++ b/src/sammo/Session.php @@ -239,13 +239,16 @@ class Session $loginDate = $this->get($serverID.static::GAME_KEY_DATE); $generalID = $this->get($serverID.static::GAME_KEY_GENERAL_ID); $generalName = $this->get($serverID.static::GAME_KEY_GENERAL_NAME); - $deadTime = $this->get($serverID.static::GAME_KEY_EXPECTED_DEADTIME); + $deadTick = $this->get($serverID.static::GAME_KEY_EXPECTED_DEADTIME); - $now = time(); + $wallNow = time(); + $db = DB::db(); + $gameStor = KVStorage::getStorage($db, 'game_env'); + $gameNowTick = GameClock::fromStorage($gameStor)->nowTick(); if ( $globalLoginDate < $loginDate && - $generalID && $generalName && $loginDate && $deadTime - && $loginDate + 1800 > $now && $deadTime > $now + $generalID && $generalName && $loginDate && $deadTick + && $loginDate + 1800 > $wallNow && $deadTick > $gameNowTick ) { //로그인 정보는 30분간 유지한다. if ($result !== null) { @@ -254,13 +257,10 @@ class Session return $this; } - if ($generalID || $generalName || $loginDate || $deadTime) { + if ($generalID || $generalName || $loginDate || $deadTick) { $this->logoutGame(); } - $db = DB::db(); - $gameStor = KVStorage::getStorage($db, 'game_env'); - $general = $db->queryFirstRow( 'SELECT `no`, `name`, `killturn`, `turntime` from general where `owner` = %i', $userID @@ -272,16 +272,15 @@ class Session return $this; } - $turnterm = $gameStor->turnterm; $isUnited = $gameStor->isunited != 0; $generalID = $general['no']; $generalName = $general['name']; - $nextTurn = new \DateTime($general['turntime']); - $nextTurn = $nextTurn->getTimestamp(); - - $deadTime = $nextTurn + $general['killturn'] * $turnterm; - if ($deadTime < $now && !$isUnited) { + $deadTick = GameClock::addTicks( + Util::toInt($general['turntime']), + Util::toInt($general['killturn']) * GameClock::TICKS_PER_TURN, + ); + if ($deadTick < $gameNowTick && !$isUnited) { $locked = $db->queryFirstField('SELECT plock FROM plock WHERE `type` = "GAME" LIMIT 1'); if (!$locked) { if ($result !== null) { @@ -291,10 +290,10 @@ class Session } } - $this->set($serverID.static::GAME_KEY_DATE, $now); + $this->set($serverID.static::GAME_KEY_DATE, $wallNow); $this->set($serverID.static::GAME_KEY_GENERAL_ID, $generalID); $this->set($serverID.static::GAME_KEY_GENERAL_NAME, $generalName); - $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, $deadTime); + $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, $deadTick); return $this; } diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php index 033cfb00..31b763c0 100644 --- a/tests/GameClockBoundaryTest.php +++ b/tests/GameClockBoundaryTest.php @@ -18,6 +18,7 @@ final class GameClockBoundaryTest extends TestCase '/\bNOW\s*\(/i', '/\bCURRENT_TIMESTAMP\b/i', '/\bCURDATE\s*\(/i', + '/\btime\s*\(/i', ] as $pattern) { self::assertDoesNotMatchRegularExpression($pattern, $source, $relativePath); } @@ -25,7 +26,7 @@ final class GameClockBoundaryTest extends TestCase public static function gameSchedulingFiles(): array { - return array_map(static fn (string $path): array => [$path], [ + $paths = [ 'hwe/sammo/TurnExecutionHelper.php', 'hwe/sammo/Auction.php', 'hwe/sammo/AuctionBasicResource.php', @@ -36,7 +37,43 @@ final class GameClockBoundaryTest extends TestCase 'hwe/sammo/AbsFromUserPool.php', 'hwe/sammo/GeneralPool/RandomNameGeneral.php', 'hwe/sammo/API/General/DieOnPrestart.php', - ]); + 'hwe/sammo/Message.php', + 'hwe/sammo/DiplomaticMessage.php', + 'hwe/sammo/ScoutMessage.php', + 'hwe/sammo/RaiseInvaderMessage.php', + 'hwe/sammo/GeneralAI.php', + 'hwe/sammo/API/Vote/NewVote.php', + 'hwe/sammo/API/Vote/Vote.php', + 'hwe/sammo/API/Vote/GetVoteList.php', + 'hwe/sammo/API/Vote/GetVoteDetail.php', + 'hwe/sammo/API/Vote/AddComment.php', + 'hwe/sammo/API/Nation/SetNotice.php', + 'hwe/j_get_select_npc_token.php', + 'hwe/j_get_select_pool.php', + 'hwe/j_set_npc_control.php', + 'hwe/j_board_article_add.php', + 'hwe/j_board_comment_add.php', + 'hwe/a_traffic.php', + 'hwe/j_server_basic_info.php', + 'hwe/j_diplomacy_send_letter.php', + 'hwe/j_diplomacy_respond_letter.php', + 'hwe/j_diplomacy_destroy_letter.php', + 'hwe/j_diplomacy_rollback_letter.php', + ]; + foreach (['hwe/sammo/Command', 'hwe/sammo/Event'] as $relativeDirectory) { + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator( + __DIR__ . '/../' . $relativeDirectory, + \FilesystemIterator::SKIP_DOTS, + )); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $paths[] = $relativeDirectory . '/' . $iterator->getSubPathName(); + } + } + } + $paths = array_values(array_unique($paths)); + sort($paths); + return array_map(static fn (string $path): array => [$path], $paths); } public function testTickSchemaDoesNotUseDatabaseDefaultsForGameSchedules(): void @@ -55,5 +92,55 @@ final class GameClockBoundaryTest extends TestCase ] as $expected) { self::assertStringContainsString($expected, $schema); } + self::assertDoesNotMatchRegularExpression('/\b(?:CURRENT_TIMESTAMP|NOW\s*\()/i', $schema); } + + public function testMonthlyTrafficTimestampUsesLogicalClock(): void + { + $source = file_get_contents(__DIR__ . '/../hwe/func.php'); + self::assertIsString($source); + self::assertMatchesRegularExpression( + '/function updateTraffic\(\).*?GameClock::fromStorage\(\$gameStor\)->formatNow\(\).*?function CheckOverhead\(/s', + $source, + ); + self::assertDoesNotMatchRegularExpression( + '/function updateTraffic\(\).*?TimeUtil::now\(.*?function CheckOverhead\(/s', + $source, + ); + } + + public function testGameLoginDeathCheckUsesTicksWhileSessionTtlRemainsOperational(): void + { + $source = file_get_contents(__DIR__ . '/../src/sammo/Session.php'); + self::assertIsString($source); + self::assertMatchesRegularExpression( + '/function loginGame\(.*?GameClock::fromStorage\(\$gameStor\)->nowTick\(\).*?GameClock::TICKS_PER_TURN.*?function logoutGame\(/s', + $source, + ); + self::assertDoesNotMatchRegularExpression( + '/function loginGame\(.*?new\s+\\?DateTime(?:Immutable)?\([^)]*turntime.*?function logoutGame\(/s', + $source, + ); + } + + public function testBrowserDoesNotCompareProjectedGameDatesToItsWallClock(): void + { + $expectations = [ + 'hwe/ts/PageVote.vue' => ['currentVote.value.isOpen'], + 'hwe/ts/components/MessagePlate.vue' => ['msg.clockMode === "manual"'], + 'hwe/ts/gateway/entrance.ts' => ['game.isOpen'], + 'hwe/ts/select_npc.ts' => ['logicalClockRunning'], + 'hwe/ts/select_general_from_pool.ts' => ['logicalClockRunning'], + ]; + foreach ($expectations as $path => $needles) { + $source = file_get_contents(__DIR__ . '/../' . $path); + self::assertIsString($source); + foreach ($needles as $needle) { + self::assertStringContainsString($needle, $source, $path); + } + } + self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue')); + self::assertStringNotContainsString('game.opentime <= now', file_get_contents(__DIR__ . '/../hwe/ts/gateway/entrance.ts')); + } + } diff --git a/tests/GameClockTest.php b/tests/GameClockTest.php index 74a08208..d62cfc30 100644 --- a/tests/GameClockTest.php +++ b/tests/GameClockTest.php @@ -5,6 +5,7 @@ namespace sammo; use PHPUnit\Framework\TestCase; require_once __DIR__ . '/../src/sammo/GameClock.php'; +require_once __DIR__ . '/../hwe/sammo/TurnExecutionHelper.php'; final class GameClockTest extends TestCase { @@ -58,6 +59,7 @@ final class GameClockTest extends TestCase ); self::assertSame(123_456, $clock->nowTick()); + self::assertSame($clock->formatTick(123_456), $clock->formatNow()); self::assertFalse($wallRead); } @@ -96,4 +98,10 @@ final class GameClockTest extends TestCase $this->expectException(\OverflowException::class); GameClock::addTicks(GameClock::MAX_SAFE_TICK, 1); } + + public function testGlobalCompletionTickIsMonotonicAcrossSubTickExecution(): void + { + self::assertSame(36_000_000, TurnExecutionHelper::monotonicCompletionTick(36_000_000, 35_500_000)); + self::assertSame(36_500_000, TurnExecutionHelper::monotonicCompletionTick(36_000_000, 36_500_000)); + } } diff --git a/tests/VoteGameClockTest.php b/tests/VoteGameClockTest.php new file mode 100644 index 00000000..a6831b9e --- /dev/null +++ b/tests/VoteGameClockTest.php @@ -0,0 +1,56 @@ + 7, + 'title' => '논리 시계 투표', + 'multipleOptions' => 1, + 'opener' => 'SYSTEM', + 'startDate' => '2035-01-01 01:00:00', + 'endDate' => '2035-01-01 03:00:00', + 'options' => ['찬성', '반대'], + ]; + + $stored = VoteInfo::normalizeGameStorage($raw, $clock); + + self::assertSame(GameClock::TICKS_PER_TURN, $stored['startTick']); + self::assertSame(GameClock::TICKS_PER_TURN * 3, $stored['endTick']); + self::assertSame('2035-01-01 01:00:00', $stored['startDate']); + self::assertSame('2035-01-01 03:00:00', $stored['endDate']); + } + + public function testStoredTicksRemainAuthoritativeWhenProjectionBaseChanges(): void + { + $oldBase = new \DateTimeImmutable('2035-01-01 00:00:00'); + $newBase = new \DateTimeImmutable('2040-05-01 12:00:00'); + $clock = new GameClock($newBase, 60, 0, GameClock::MODE_MANUAL, $newBase); + $stored = [ + 'id' => 8, + 'title' => 'tick 우선', + 'multipleOptions' => 1, + 'opener' => null, + 'startDate' => $oldBase->format('Y-m-d H:i:s'), + 'endDate' => $oldBase->modify('+1 hour')->format('Y-m-d H:i:s'), + 'startTick' => GameClock::TICKS_PER_TURN * 2, + 'endTick' => GameClock::TICKS_PER_TURN * 4, + 'options' => ['A'], + ]; + + $normalized = VoteInfo::normalizeGameStorage($stored, $clock); + self::assertSame('2040-05-01 14:00:00', $normalized['startDate']); + self::assertSame('2040-05-01 16:00:00', $normalized['endDate']); + } +} From 9da44d0001aef425fe8d71f18d4b50b448c25336 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 01:09:18 +0000 Subject: [PATCH 4/5] fix: support mixed wall and game clock servers --- hwe/j_server_basic_info.php | 3 +- hwe/test-ts/gatewayOpenState.test.ts | 14 ++++++ hwe/ts/gateway/entrance.ts | 11 +++-- hwe/ts/gateway/resolveGatewayOpenState.ts | 7 +++ src/sammo/GameClock.php | 59 ++++++++++++++++++++--- src/sammo/Session.php | 38 ++++++++++----- tests/GameClockBoundaryTest.php | 19 ++++++-- tests/GameClockTest.php | 27 +++++++++++ 8 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 hwe/test-ts/gatewayOpenState.test.ts create mode 100644 hwe/ts/gateway/resolveGatewayOpenState.ts diff --git a/hwe/j_server_basic_info.php b/hwe/j_server_basic_info.php index 27119356..92e86b96 100644 --- a/hwe/j_server_basic_info.php +++ b/hwe/j_server_basic_info.php @@ -77,7 +77,8 @@ $admin['npcMode'] = $admin['npcmode']; $admin['turnTerm'] = $admin['turnterm']; $admin['isUnited'] = $admin['isunited']; $admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']); -$admin['starttime'] = substr($clock->formatTick(Util::toInt($admin['opentime'])), 5, 11); +$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); unset($admin['npcmode']); unset($admin['maxgeneral']); diff --git a/hwe/test-ts/gatewayOpenState.test.ts b/hwe/test-ts/gatewayOpenState.test.ts new file mode 100644 index 00000000..9d4a1222 --- /dev/null +++ b/hwe/test-ts/gatewayOpenState.test.ts @@ -0,0 +1,14 @@ +import assert from 'assert'; +import { resolveGatewayOpenState } from '../ts/gateway/resolveGatewayOpenState'; + +describe('resolveGatewayOpenState', () => { + it('keeps the logical server decision even when projected dates disagree with wall time', () => { + assert.strictEqual(resolveGatewayOpenState(false, '2000-01-01 00:00:00', '2026-08-04 00:00:00'), false); + assert.strictEqual(resolveGatewayOpenState(true, '2042-01-01 00:00:00', '2026-08-04 00:00:00'), true); + }); + + it('falls back to legacy wall time only when isOpen is absent', () => { + assert.strictEqual(resolveGatewayOpenState(undefined, '2026-08-03 00:00:00', '2026-08-04 00:00:00'), true); + assert.strictEqual(resolveGatewayOpenState(undefined, '2026-08-05 00:00:00', '2026-08-04 00:00:00'), false); + }); +}); diff --git a/hwe/ts/gateway/entrance.ts b/hwe/ts/gateway/entrance.ts index aa082eb8..cd2e3383 100644 --- a/hwe/ts/gateway/entrance.ts +++ b/hwe/ts/gateway/entrance.ts @@ -5,8 +5,10 @@ import axios from 'axios'; import { initTooltip } from "@/legacy/initTooltip"; import { TemplateEngine } from '@util/TemplateEngine'; import type { InvalidResponse } from '@/defs'; +import { getDateTimeNow } from '@util/getDateTimeNow'; import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest'; import { loadPlugin as loadAdminPlugin } from '@/gateway/admin_server'; +import { resolveGatewayOpenState } from '@/gateway/resolveGatewayOpenState'; import '@/gateway/common'; declare const isAdmin: boolean; @@ -107,7 +109,7 @@ type ReservedGameInfo = { type GameInfo = { isUnited: number, - isOpen: boolean, + isOpen?: boolean, npcMode: '불가' | '가능' | '선택 생성', year: number, month: number, @@ -175,6 +177,7 @@ async function Entrance_UpdateServer() { async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { const $serverList = $('#server_list'); + const now = getDateTimeNow(); const serverDetailInfoP: Record> = {}; @@ -225,6 +228,8 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { } const game = response.game; + // 구버전 wall-clock profile은 isOpen을 아직 반환하지 않습니다. + const isOpen = resolveGatewayOpenState(game.isOpen, game.opentime, now); //TODO: 서버 폐쇄 방식을 새롭게 변경 $serverHtml.find('.server_down').detach(); @@ -238,7 +243,7 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { } else if (game.isUnited == 2) { $serverHtml.find('.n_country').html('§천하통일§'); $serverHtml.find('.server_date').html(`${game.starttime}
~ ${game.turntime}`); - } else if (game.isOpen) { + } else if (isOpen) { $serverHtml.find('.n_country').html(`<${game.nationCnt}국 경쟁중>`); $serverHtml.find('.server_date').html(`${game.starttime} ~`); } else { @@ -246,7 +251,7 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { $serverHtml.find('.server_date').html(`${game.starttime} ~`); } - if (game.isOpen) { + if (isOpen) { $serverHtml.append( TemplateEngine(serverTextInfo, game) ); diff --git a/hwe/ts/gateway/resolveGatewayOpenState.ts b/hwe/ts/gateway/resolveGatewayOpenState.ts new file mode 100644 index 00000000..40eb8dd3 --- /dev/null +++ b/hwe/ts/gateway/resolveGatewayOpenState.ts @@ -0,0 +1,7 @@ +export function resolveGatewayOpenState( + serverDecision: boolean | undefined, + openTime: string, + wallNow: string, +): boolean { + return serverDecision ?? openTime <= wallNow; +} diff --git a/src/sammo/GameClock.php b/src/sammo/GameClock.php index cc874989..a6a96ab7 100644 --- a/src/sammo/GameClock.php +++ b/src/sammo/GameClock.php @@ -43,13 +43,10 @@ final class GameClock public static function fromStorage(KVStorage $gameStor, ?callable $wallNowProvider = null): self { - $values = $gameStor->getValues([ - 'clock_base_time', - 'clock_tick', - 'clock_mode', - 'clock_wall_anchor', - 'turnterm', - ]); + $values = self::readStorageValues($gameStor); + if (!self::areStorageValuesInitialized($values)) { + throw new \RuntimeException('game clock storage가 초기화되지 않았습니다. migration 상태를 확인해 주세요.'); + } $baseTime = new \DateTimeImmutable((string)$values['clock_base_time']); $wallAnchor = new \DateTimeImmutable((string)$values['clock_wall_anchor']); @@ -64,6 +61,54 @@ final class GameClock ); } + /** + * 공용 src가 아직 migration하지 않은 wall-clock profile과 함께 배포될 수 + * 있으므로 호출부가 저장 형식을 먼저 판별할 수 있게 합니다. + */ + public static function isInitialized(KVStorage $gameStor): bool + { + $values = self::readStorageValues($gameStor); + $clockKeys = ['clock_base_time', 'clock_tick', 'clock_mode', 'clock_wall_anchor']; + $presentClockKeys = array_filter( + $clockKeys, + static fn (string $key): bool => array_key_exists($key, $values) + && $values[$key] !== null + && $values[$key] !== '', + ); + if (!$presentClockKeys) { + return false; + } + if (!self::areStorageValuesInitialized($values)) { + throw new \RuntimeException('game clock storage가 부분 초기화 상태입니다. migration 상태를 확인해 주세요.'); + } + return true; + } + + /** @return array */ + private static function readStorageValues(KVStorage $gameStor): array + { + return $gameStor->getValues([ + 'clock_base_time', + 'clock_tick', + 'clock_mode', + 'clock_wall_anchor', + 'turnterm', + ]); + } + + /** @param array $values */ + private static function areStorageValuesInitialized(array $values): bool + { + foreach (['clock_base_time', 'clock_tick', 'clock_mode', 'clock_wall_anchor', 'turnterm'] as $key) { + if (!array_key_exists($key, $values) || $values[$key] === null || $values[$key] === '') { + return false; + } + } + return in_array((string)$values['clock_mode'], [self::MODE_REALTIME, self::MODE_MANUAL], true) + && is_numeric($values['clock_tick']) + && is_numeric($values['turnterm']); + } + public static function initializeStorage( KVStorage $gameStor, \DateTimeInterface $baseTime, diff --git a/src/sammo/Session.php b/src/sammo/Session.php index 0f749173..0c5a7c65 100644 --- a/src/sammo/Session.php +++ b/src/sammo/Session.php @@ -36,6 +36,7 @@ class Session const GAME_KEY_GENERAL_ID = '_g_no'; const GAME_KEY_GENERAL_NAME = '_g_name'; const GAME_KEY_EXPECTED_DEADTIME = '_g_deadtime'; + const GAME_KEY_CLOCK_STORAGE = '_g_clock_storage'; protected $writeClosed = false; @@ -239,16 +240,22 @@ class Session $loginDate = $this->get($serverID.static::GAME_KEY_DATE); $generalID = $this->get($serverID.static::GAME_KEY_GENERAL_ID); $generalName = $this->get($serverID.static::GAME_KEY_GENERAL_NAME); - $deadTick = $this->get($serverID.static::GAME_KEY_EXPECTED_DEADTIME); + $deadAt = $this->get($serverID.static::GAME_KEY_EXPECTED_DEADTIME); + $sessionClockStorage = $this->get($serverID.static::GAME_KEY_CLOCK_STORAGE); $wallNow = time(); $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); - $gameNowTick = GameClock::fromStorage($gameStor)->nowTick(); + $usesLogicalClock = GameClock::isInitialized($gameStor); + $clockStorage = $usesLogicalClock ? 'logical' : 'wall'; + $gameNow = $usesLogicalClock + ? GameClock::fromStorage($gameStor)->nowTick() + : $wallNow; if ( $globalLoginDate < $loginDate && - $generalID && $generalName && $loginDate && $deadTick - && $loginDate + 1800 > $wallNow && $deadTick > $gameNowTick + $generalID && $generalName && $loginDate && $deadAt + && $sessionClockStorage === $clockStorage + && $loginDate + 1800 > $wallNow && $deadAt > $gameNow ) { //로그인 정보는 30분간 유지한다. if ($result !== null) { @@ -257,7 +264,7 @@ class Session return $this; } - if ($generalID || $generalName || $loginDate || $deadTick) { + if ($generalID || $generalName || $loginDate || $deadAt) { $this->logoutGame(); } @@ -276,11 +283,18 @@ class Session $generalID = $general['no']; $generalName = $general['name']; - $deadTick = GameClock::addTicks( - Util::toInt($general['turntime']), - Util::toInt($general['killturn']) * GameClock::TICKS_PER_TURN, - ); - if ($deadTick < $gameNowTick && !$isUnited) { + if ($usesLogicalClock) { + $deadAt = GameClock::addTicks( + Util::toInt($general['turntime']), + Util::toInt($general['killturn']) * GameClock::TICKS_PER_TURN, + ); + } else { + // migration 전 profile은 기존 DATETIME과 wall-clock 판정을 그대로 + // 유지합니다. 공용 src 배포가 기존 서버의 DB 형식을 바꾸지 않습니다. + $deadAt = (new \DateTimeImmutable((string)$general['turntime']))->getTimestamp() + + Util::toInt($general['killturn']) * Util::toInt($gameStor->turnterm); + } + if ($deadAt < $gameNow && !$isUnited) { $locked = $db->queryFirstField('SELECT plock FROM plock WHERE `type` = "GAME" LIMIT 1'); if (!$locked) { if ($result !== null) { @@ -293,7 +307,8 @@ class Session $this->set($serverID.static::GAME_KEY_DATE, $wallNow); $this->set($serverID.static::GAME_KEY_GENERAL_ID, $generalID); $this->set($serverID.static::GAME_KEY_GENERAL_NAME, $generalName); - $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, $deadTick); + $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, $deadAt); + $this->set($serverID.static::GAME_KEY_CLOCK_STORAGE, $clockStorage); return $this; } @@ -307,6 +322,7 @@ class Session $this->set($serverID.static::GAME_KEY_GENERAL_ID, null); $this->set($serverID.static::GAME_KEY_GENERAL_NAME, null); $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, null); + $this->set($serverID.static::GAME_KEY_CLOCK_STORAGE, null); return $this; } diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php index 31b763c0..df8d7fb9 100644 --- a/tests/GameClockBoundaryTest.php +++ b/tests/GameClockBoundaryTest.php @@ -114,7 +114,11 @@ final class GameClockBoundaryTest extends TestCase $source = file_get_contents(__DIR__ . '/../src/sammo/Session.php'); self::assertIsString($source); self::assertMatchesRegularExpression( - '/function loginGame\(.*?GameClock::fromStorage\(\$gameStor\)->nowTick\(\).*?GameClock::TICKS_PER_TURN.*?function logoutGame\(/s', + '/function loginGame\(.*?GameClock::isInitialized\(\$gameStor\).*?GameClock::fromStorage\(\$gameStor\)->nowTick\(\).*?GameClock::TICKS_PER_TURN.*?function logoutGame\(/s', + $source, + ); + self::assertMatchesRegularExpression( + '/function loginGame\(.*?new \\\\DateTimeImmutable.*?getTimestamp\(\).*?function logoutGame\(/s', $source, ); self::assertDoesNotMatchRegularExpression( @@ -128,7 +132,7 @@ final class GameClockBoundaryTest extends TestCase $expectations = [ 'hwe/ts/PageVote.vue' => ['currentVote.value.isOpen'], 'hwe/ts/components/MessagePlate.vue' => ['msg.clockMode === "manual"'], - 'hwe/ts/gateway/entrance.ts' => ['game.isOpen'], + 'hwe/ts/gateway/entrance.ts' => ['resolveGatewayOpenState(game.isOpen, game.opentime, now)'], 'hwe/ts/select_npc.ts' => ['logicalClockRunning'], 'hwe/ts/select_general_from_pool.ts' => ['logicalClockRunning'], ]; @@ -140,7 +144,16 @@ final class GameClockBoundaryTest extends TestCase } } self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue')); - self::assertStringNotContainsString('game.opentime <= now', file_get_contents(__DIR__ . '/../hwe/ts/gateway/entrance.ts')); + } + + public function testGatewayFormatsLogicalOpenTimeBeforeReturningIt(): void + { + $source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php'); + self::assertIsString($source); + self::assertStringContainsString( + '$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));', + $source, + ); } } diff --git a/tests/GameClockTest.php b/tests/GameClockTest.php index d62cfc30..063468a6 100644 --- a/tests/GameClockTest.php +++ b/tests/GameClockTest.php @@ -91,6 +91,33 @@ final class GameClockTest extends TestCase self::assertSame(7_600, $clock->nowTick()); } + public function testStorageInitializationDetectionSupportsMixedLegacyProfiles(): void + { + $logicalStorage = $this->createMock(KVStorage::class); + $logicalStorage->method('getValues')->willReturn([ + 'clock_base_time' => '2026-08-03 00:00:00.000000', + 'clock_tick' => 0, + 'clock_mode' => GameClock::MODE_REALTIME, + 'clock_wall_anchor' => '2026-08-03 00:00:00.000000', + 'turnterm' => 60, + ]); + self::assertTrue(GameClock::isInitialized($logicalStorage)); + + $legacyStorage = $this->createMock(KVStorage::class); + $legacyStorage->method('getValues')->willReturn([ + 'turnterm' => 60, + ]); + self::assertFalse(GameClock::isInitialized($legacyStorage)); + + $partialStorage = $this->createMock(KVStorage::class); + $partialStorage->method('getValues')->willReturn([ + 'clock_tick' => 0, + 'turnterm' => 60, + ]); + $this->expectException(\RuntimeException::class); + GameClock::isInitialized($partialStorage); + } + public function testTickArithmeticRejectsValuesThatJavaScriptCannotRepresentExactly(): void { self::assertSame(GameClock::MAX_SAFE_TICK, GameClock::addTicks(GameClock::MAX_SAFE_TICK - 1, 1)); From a38a8867ee35229803c2b37e16d4d653f822d4de Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 07:03:17 +0000 Subject: [PATCH 5/5] fix: reuse message ticks across mailboxes --- hwe/sammo/Message.php | 39 ++++++++++---- tests/MessageGameClockTest.php | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 tests/MessageGameClockTest.php diff --git a/hwe/sammo/Message.php b/hwe/sammo/Message.php index 1a140d4f..aca0c872 100644 --- a/hwe/sammo/Message.php +++ b/hwe/sammo/Message.php @@ -15,6 +15,9 @@ class Message protected $sendCnt = 0; + private ?int $sendTimeTick = null; + private ?int $sendValidUntilTick = null; + public function __construct( public MessageType $msgType, public MessageTarget $src, @@ -324,15 +327,7 @@ class Message $db = DB::db(); $clock = GameClock::fromStorage(KVStorage::getStorage($db, 'game_env')); - $timeTick = $clock->nowTick(); - if (Util::toInt($this->validUntil->format('Y')) >= 9000) { - $validUntilTick = GameClock::MAX_SAFE_TICK; - } else { - $validitySeconds = $this->validUntil->getTimestamp() - $this->date->getTimestamp(); - $validUntilTick = $timeTick + $clock->ticksFromSeconds($validitySeconds); - } - $this->date = \DateTime::createFromImmutable($clock->tickToDateTime($timeTick)); - $this->validUntil = \DateTime::createFromImmutable($clock->tickToDateTime($validUntilTick)); + [$timeTick, $validUntilTick] = $this->resolveSendTicks($clock); $db->insert('message', [ 'mailbox' => $mailbox, 'type' => $this->msgType->value, @@ -350,6 +345,32 @@ class Message return [$mailbox, $db->insertId()]; } + /** @return array{0:int, 1:int} */ + protected function resolveSendTicks(GameClock $clock): array + { + if ($this->sendTimeTick !== null && $this->sendValidUntilTick !== null) { + return [$this->sendTimeTick, $this->sendValidUntilTick]; + } + + $timeTick = $clock->nowTick(); + if (Util::toInt($this->validUntil->format('Y')) >= 9000) { + $validUntilTick = GameClock::MAX_SAFE_TICK; + } else { + $validitySeconds = $this->validUntil->getTimestamp() - $this->date->getTimestamp(); + $validUntilTick = GameClock::addTicks( + $timeTick, + $clock->ticksFromSeconds($validitySeconds), + ); + } + + $this->sendTimeTick = $timeTick; + $this->sendValidUntilTick = $validUntilTick; + $this->date = \DateTime::createFromImmutable($clock->tickToDateTime($timeTick)); + $this->validUntil = \DateTime::createFromImmutable($clock->tickToDateTime($validUntilTick)); + + return [$timeTick, $validUntilTick]; + } + private function sendToSender():array{ if($this->sendCnt > 1){ throw new \RuntimeException('이미 전송한 메일입니다.'); diff --git a/tests/MessageGameClockTest.php b/tests/MessageGameClockTest.php new file mode 100644 index 00000000..96f3c064 --- /dev/null +++ b/tests/MessageGameClockTest.php @@ -0,0 +1,95 @@ +createMock(MessageTarget::class), + $this->createMock(MessageTarget::class), + '선전포고', + new \DateTime('2026-08-04 00:00:00.000000'), + new \DateTime('9999-12-31'), + [], + ); + + $receiverTicks = $message->resolveTicksForTest($clock); + self::assertLessThan(9000, Util::toInt($message->validUntil->format('Y'))); + $legacySecondClock = new GameClock( + $base, + 1, + 0, + GameClock::MODE_REALTIME, + $base, + static fn (): \DateTimeImmutable => $wallTimes[1], + ); + $legacySecondNowTick = $legacySecondClock->nowTick(); + self::assertSame(562_884, $legacySecondNowTick); + $legacySecondExpiry = $legacySecondNowTick + $clock->ticksFromSeconds( + $message->validUntil->getTimestamp() - $message->date->getTimestamp(), + ); + self::assertSame(9_007_199_254_762_884, $legacySecondExpiry); + $senderTicks = $message->resolveTicksForTest($clock); + + self::assertSame([0, GameClock::MAX_SAFE_TICK], $receiverTicks); + self::assertSame($receiverTicks, $senderTicks); + self::assertSame(1, $wallReads); + } + + public function testFiniteMessageCachesOneValidatedExpiryForBothCopies(): void + { + $base = new \DateTimeImmutable('2026-08-04 00:00:00.000000'); + $clock = new GameClock($base, 1, 120_000, GameClock::MODE_MANUAL, $base); + $message = new TestableClockMessage( + MessageType::national, + $this->createMock(MessageTarget::class), + $this->createMock(MessageTarget::class), + '유한 메시지', + new \DateTime('2026-08-04 00:00:02.000000'), + new \DateTime('2026-08-04 00:01:02.000000'), + [], + ); + + $receiverTicks = $message->resolveTicksForTest($clock); + $senderTicks = $message->resolveTicksForTest($clock); + + self::assertSame([120_000, 36_120_000], $receiverTicks); + self::assertSame($receiverTicks, $senderTicks); + } +} + +final class TestableClockMessage extends Message +{ + /** @return array{0:int, 1:int} */ + public function resolveTicksForTest(GameClock $clock): array + { + return $this->resolveSendTicks($clock); + } +}