merge: refresh ng_compare for logical game clock
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace sammo;
|
||||
|
||||
/**
|
||||
* 게임 진행 시각과 표시용 달력 시각 사이의 유일한 변환 경계입니다.
|
||||
*
|
||||
* 게임 로직과 DB에는 tick만 저장합니다. 벽시계는 realtime mode의 anchor를
|
||||
* 진행시키는 입력으로만 사용하며, manual mode에서는 전혀 참조하지 않습니다.
|
||||
*/
|
||||
final class GameClock
|
||||
{
|
||||
public const TICKS_PER_TURN = 36_000_000;
|
||||
public const MAX_SAFE_TICK = 9_007_199_254_740_991;
|
||||
public const MODE_REALTIME = 'realtime';
|
||||
public const MODE_MANUAL = 'manual';
|
||||
|
||||
/** @var null|callable():\DateTimeImmutable */
|
||||
private $wallNowProvider;
|
||||
|
||||
public function __construct(
|
||||
private readonly \DateTimeImmutable $baseTime,
|
||||
private readonly int $turnTermMinutes,
|
||||
private readonly int $anchorTick,
|
||||
private readonly string $mode,
|
||||
private readonly \DateTimeImmutable $wallAnchor,
|
||||
?callable $wallNowProvider = null,
|
||||
) {
|
||||
if ($turnTermMinutes <= 0) {
|
||||
throw new \InvalidArgumentException('turnterm은 양수여야 합니다.');
|
||||
}
|
||||
if (self::TICKS_PER_TURN % ($turnTermMinutes * 60) !== 0) {
|
||||
throw new \InvalidArgumentException(
|
||||
"turnterm {$turnTermMinutes}분은 정수 tick/초로 표현할 수 없습니다."
|
||||
);
|
||||
}
|
||||
if (!in_array($mode, [self::MODE_REALTIME, self::MODE_MANUAL], true)) {
|
||||
throw new \InvalidArgumentException("알 수 없는 game clock mode: {$mode}");
|
||||
}
|
||||
self::requireSafeTick($anchorTick);
|
||||
$this->wallNowProvider = $wallNowProvider;
|
||||
}
|
||||
|
||||
public static function fromStorage(KVStorage $gameStor, ?callable $wallNowProvider = null): self
|
||||
{
|
||||
$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']);
|
||||
|
||||
return new self(
|
||||
$baseTime,
|
||||
Util::toInt($values['turnterm']),
|
||||
Util::toInt($values['clock_tick']),
|
||||
(string)$values['clock_mode'],
|
||||
$wallAnchor,
|
||||
$wallNowProvider,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공용 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<string, mixed> */
|
||||
private static function readStorageValues(KVStorage $gameStor): array
|
||||
{
|
||||
return $gameStor->getValues([
|
||||
'clock_base_time',
|
||||
'clock_tick',
|
||||
'clock_mode',
|
||||
'clock_wall_anchor',
|
||||
'turnterm',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $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,
|
||||
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 nowDateTime(): \DateTimeImmutable
|
||||
{
|
||||
return $this->tickToDateTime($this->nowTick());
|
||||
}
|
||||
|
||||
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 function formatNow(bool $withFraction = false): string
|
||||
{
|
||||
return $this->formatTick($this->nowTick(), $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 = self::addTicks($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();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace sammo;
|
||||
|
||||
final class NpcPossessionSelector
|
||||
{
|
||||
public static function buildSeed(string|int $hiddenSeed, int $owner, string $now): string
|
||||
public static function buildSeed(string|int $hiddenSeed, int $owner, string|int $now): string
|
||||
{
|
||||
return Util::simpleSerialize($hiddenSeed, 'SelectNPCToken', $owner, $now);
|
||||
}
|
||||
|
||||
+31
-16
@@ -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,13 +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);
|
||||
$deadTime = $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);
|
||||
|
||||
$now = time();
|
||||
$wallNow = time();
|
||||
$db = DB::db();
|
||||
$gameStor = KVStorage::getStorage($db, 'game_env');
|
||||
$usesLogicalClock = GameClock::isInitialized($gameStor);
|
||||
$clockStorage = $usesLogicalClock ? 'logical' : 'wall';
|
||||
$gameNow = $usesLogicalClock
|
||||
? GameClock::fromStorage($gameStor)->nowTick()
|
||||
: $wallNow;
|
||||
if (
|
||||
$globalLoginDate < $loginDate &&
|
||||
$generalID && $generalName && $loginDate && $deadTime
|
||||
&& $loginDate + 1800 > $now && $deadTime > $now
|
||||
$generalID && $generalName && $loginDate && $deadAt
|
||||
&& $sessionClockStorage === $clockStorage
|
||||
&& $loginDate + 1800 > $wallNow && $deadAt > $gameNow
|
||||
) {
|
||||
//로그인 정보는 30분간 유지한다.
|
||||
if ($result !== null) {
|
||||
@@ -254,13 +264,10 @@ class Session
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($generalID || $generalName || $loginDate || $deadTime) {
|
||||
if ($generalID || $generalName || $loginDate || $deadAt) {
|
||||
$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 +279,22 @@ 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) {
|
||||
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) {
|
||||
@@ -291,10 +304,11 @@ 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, $deadAt);
|
||||
$this->set($serverID.static::GAME_KEY_CLOCK_STORAGE, $clockStorage);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -308,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user