feat: replace game schedules with logical ticks

This commit is contained in:
2026-08-03 05:13:47 +00:00
parent f4a1e2258d
commit ed3c84aa8e
69 changed files with 1617 additions and 520 deletions
+41
View File
@@ -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을 넘지 않았는지 검사합니다.
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use sammo\DB;
use sammo\GameClock;
use sammo\KVStorage;
use sammo\Util;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
$_SERVER['REQUEST_URI'] ??= '/cli/game-clock';
require dirname(__DIR__) . '/hwe/lib.php';
require dirname(__DIR__) . '/hwe/func.php';
/** @return never */
function gameClockUsage(int $exitCode = 0): void
{
$stream = $exitCode === 0 ? STDOUT : STDERR;
fwrite($stream, <<<'TEXT'
Usage:
php scripts/game-clock.php --status
php scripts/game-clock.php --mode=manual|realtime --apply
php scripts/game-clock.php --advance-turns=N --apply
php scripts/game-clock.php --advance-ticks=N --apply
Mutation commands require --apply and the GAME lock. Explicit advancement
switches the clock to manual mode, so subsequent engine runs never consult the
wall clock. Use --mode=realtime to resume wall-clock-paced progression.
TEXT);
exit($exitCode);
}
/** @return int */
function signedIntegerOption(array $options, string $name): int
{
$value = $options[$name] ?? null;
if (!is_string($value) || preg_match('/^-?[0-9]+$/', $value) !== 1) {
throw new InvalidArgumentException("--{$name}에는 정수를 지정해야 합니다.");
}
$result = filter_var($value, FILTER_VALIDATE_INT);
if ($result === false) {
throw new InvalidArgumentException("--{$name} 값이 INT64 범위를 벗어났습니다.");
}
return $result;
}
function printGameClockState(KVStorage $gameStor): void
{
$gameStor->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);
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use sammo\DB;
use sammo\GameClock;
use sammo\Json;
use sammo\KVStorage;
use sammo\Util;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
$_SERVER['REQUEST_URI'] ??= '/cli/migrate-game-clock';
require dirname(__DIR__) . '/hwe/lib.php';
require dirname(__DIR__) . '/hwe/func.php';
/** @return never */
function migrationUsage(int $exitCode = 0): void
{
$stream = $exitCode === 0 ? STDOUT : STDERR;
fwrite($stream, <<<'TEXT'
Usage:
php scripts/migrate-game-clock.php --status
php scripts/migrate-game-clock.php --apply --backup=/absolute/path/to/pre-migration.sql
--status is read-only. --apply requires a pre-existing, non-empty SQL backup and
the GAME lock. Original DATETIME columns and game_env values are retained with
the *_wall_backup suffix so recovery does not depend only on reverse arithmetic.
Stop web/daemon traffic before applying; Aria DDL is not transactional.
TEXT);
exit($exitCode);
}
/** @return array<string,string> */
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<string,array<string,string>>} */
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");
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use sammo\DB;
use sammo\GameClock;
use sammo\KVStorage;
use sammo\TurnExecutionHelper;
use sammo\Util;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
$_SERVER['REQUEST_URI'] ??= '/cli/verify-game-clock-engine';
require dirname(__DIR__) . '/hwe/lib.php';
require dirname(__DIR__) . '/hwe/func.php';
$options = getopt('', ['apply', 'engine-calls:']);
if (!isset($options['apply'])) {
fwrite(STDERR, "Usage: php scripts/verify-game-clock-engine.php --apply [--engine-calls=N]\n");
exit(2);
}
$engineCalls = filter_var($options['engine-calls'] ?? '1', FILTER_VALIDATE_INT);
if ($engineCalls === false || $engineCalls < 1 || $engineCalls > 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']),
);