Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbd51a2ef | ||
|
|
b77276f202 | ||
|
|
424f0b66ec | ||
|
|
51f8be492f | ||
|
|
156f4e68b8 | ||
|
|
a2b0b997a2 | ||
|
|
62006fb2d4 | ||
|
|
03e47feef1 | ||
|
|
1cb66f76fb | ||
|
|
b250e908f4 | ||
|
|
0aa549a6c5 | ||
|
|
000e7e8fa5 | ||
|
|
abceee8315 | ||
|
|
10cddbb565 | ||
|
|
24a63d338c | ||
|
|
c10a71fbcc | ||
|
|
69ac6028df | ||
|
|
a9f23703d9 | ||
|
|
a3e2bf90ae | ||
|
|
ae7d55ef47 | ||
|
|
b91dcbcaaa | ||
|
|
1a2c7ba775 | ||
|
|
97a8b1daef | ||
|
|
b1984669d4 | ||
|
|
77299ffe41 | ||
|
|
89a33a8349 | ||
|
|
76da41a00b | ||
|
|
63e29743d9 | ||
|
|
426e1127ef | ||
|
|
9c82eb0cf5 | ||
|
|
dd84e6d6b3 | ||
|
|
8e0e7291de | ||
|
|
50a443d94b | ||
|
|
bf13dabc01 | ||
|
|
7080ae5504 | ||
|
|
96f6d85cf4 | ||
|
|
3ef86448d7 | ||
|
|
bd826b1ff8 | ||
|
|
0bc4be9a13 | ||
|
|
898d756036 | ||
|
|
29014e9c2d | ||
|
|
99a477b47e | ||
|
|
8d88603305 | ||
|
|
d4e83661ca | ||
|
|
70a4980a31 | ||
|
|
f3eaab1fa9 | ||
|
|
71b9449903 | ||
|
|
6162e56583 | ||
|
|
fa7898b6a2 | ||
|
|
c769d915a6 | ||
|
|
4e4dff55a1 | ||
|
|
dd66da14f4 | ||
|
|
c56610b179 | ||
|
|
24e57ee94c | ||
|
|
d68209879b | ||
|
|
7377aa62ed | ||
|
|
11fca1ed8b | ||
|
|
254d321e8b | ||
|
|
52a75ca9b7 | ||
|
|
23678df9de | ||
|
|
95d5246d4b | ||
|
|
342994f77b | ||
|
|
71ee529d35 | ||
|
|
20a2812d65 | ||
|
|
26952fcdc7 | ||
|
|
fa9fe71539 | ||
|
|
e6873f813a | ||
|
|
b251120e45 | ||
|
|
eb755757ec | ||
|
|
ddbfa0afa9 | ||
|
|
b45fe5ba39 | ||
|
|
59f0a051d8 | ||
|
|
8ad10e6b2c | ||
|
|
c741ef4be5 | ||
|
|
f43a1db136 | ||
|
|
ab0f1b9cd5 | ||
|
|
ca3e028603 | ||
|
|
212c13bf16 | ||
|
|
4b06330753 | ||
|
|
6a60882005 | ||
|
|
9e5e580134 | ||
|
|
68870be5e5 | ||
|
|
7c07742690 |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"plugins": ["typescript"],
|
||||
"options": {
|
||||
"typeAware": true,
|
||||
"respectEslintDisableDirectives": true
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": {
|
||||
"arguments": false,
|
||||
"attributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.d.ts",
|
||||
"**/*.js",
|
||||
"**/*.mjs",
|
||||
"**/*.cjs",
|
||||
"**/*.vue",
|
||||
"legacy/**",
|
||||
"packages/infra/prisma/client/**",
|
||||
"**/generated/**",
|
||||
".pnpm-store/**"
|
||||
]
|
||||
}
|
||||
@@ -6,9 +6,10 @@
|
||||
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
|
||||
지침 순으로 적용합니다.
|
||||
|
||||
목표는 `../ref/sam`의 PHP 서비스를 TypeScript 런타임으로 호환 이관하는
|
||||
것입니다. 내부 구조를 더 깔끔하게 만드는 일보다 기존 결과, 상태 전이, 권한,
|
||||
화면과 운영 경계를 보존하는 일이 우선입니다.
|
||||
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
|
||||
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
|
||||
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
|
||||
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
|
||||
|
||||
## 현재 기준과 저장소 경계
|
||||
|
||||
@@ -41,7 +42,9 @@ commit 또는 삭제를 하지 말아 주세요.
|
||||
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
|
||||
판정하지 말아 주세요.
|
||||
|
||||
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요.
|
||||
- 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
|
||||
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
|
||||
확정하지 않습니다.
|
||||
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
|
||||
운영 장애 복구를 자동으로 증명하지 않습니다.
|
||||
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
|
||||
@@ -95,6 +98,20 @@ priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로
|
||||
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
|
||||
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
|
||||
|
||||
Ref 비교 전에 대상 계약도 구분해 주세요.
|
||||
|
||||
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
|
||||
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
|
||||
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
|
||||
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
|
||||
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
|
||||
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
|
||||
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
|
||||
|
||||
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
|
||||
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
|
||||
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
|
||||
|
||||
ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
|
||||
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
|
||||
@@ -105,7 +122,7 @@ ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
확인해 주세요.
|
||||
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
|
||||
|
||||
## 호환성 우선순위
|
||||
## 계승 계약의 호환성 우선순위
|
||||
|
||||
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
|
||||
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
|
||||
@@ -113,10 +130,12 @@ ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
4. 문구, 로그, 화면 흐름과 룩앤필
|
||||
5. 내부 구현 세부사항
|
||||
|
||||
레거시의 이상해 보이는 동작도 계약일 수 있습니다. 보안 또는 데이터 손상 위험이
|
||||
아니면 우선 같은 동작을 재현하고 개선 제안은 분리해 주세요. 허용하는 차이는
|
||||
사용자 경험, 저장 상태와 후속 턴 결과에 영향이 없다는 근거를 mapping/report에
|
||||
남겨 주세요.
|
||||
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
|
||||
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
|
||||
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
|
||||
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
|
||||
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
|
||||
이름만으로 완화하지 않습니다.
|
||||
|
||||
## 인증과 권한
|
||||
|
||||
@@ -282,6 +301,11 @@ pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
루트 `pnpm test`와 `pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
|
||||
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
|
||||
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
|
||||
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
|
||||
|
||||
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
|
||||
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
|
||||
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
|
||||
|
||||
@@ -175,6 +175,17 @@ pnpm build
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
`pnpm lint`는 ESLint의 일반 TypeScript/Vue 검사, Oxlint의 type-aware TypeScript
|
||||
Promise 검사, ESLint의 type-aware Vue Promise 검사를 메모리 경계에 맞게 나누어
|
||||
실행합니다. cache를 제외한 전체 검증은 `pnpm lint:uncached`, package 하나만
|
||||
검사할 때는 기존처럼 `pnpm --filter <package-name> lint`를 사용합니다.
|
||||
|
||||
루트 `test`와 `build`는 Codex와 일반 개발 검증에서 성공 로그를 줄이는 간단 출력이
|
||||
기본입니다. 테스트별 진행과 전체 Turbo task 로그가 필요한 실패 진단에는
|
||||
`pnpm test-verbose`, `pnpm build-verbose`를 사용합니다. Gateway 서버 빌드 워커는
|
||||
관리자 operation 화면에 진행 상황을 제공하기 위해 별도의 streaming 상세 출력
|
||||
정책을 계속 사용합니다.
|
||||
|
||||
PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다.
|
||||
|
||||
```sh
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||
"worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js",
|
||||
"worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "pnpm -w lint:target app/game-api",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-api",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
|
||||
generalId: number,
|
||||
input: OpenAuctionInput,
|
||||
requestId?: string
|
||||
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionOpen',
|
||||
...(requestId ? { requestId } : {}),
|
||||
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
|
||||
const closeAt = new Date(result.closeAt);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
|
||||
]);
|
||||
return {
|
||||
auctionId: result.auctionId,
|
||||
closeAt: result.closeAt,
|
||||
closeTick: result.closeTick,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
|
||||
}
|
||||
|
||||
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
||||
if (closeTick !== null && closeTick !== undefined) {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
}
|
||||
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
||||
void time;
|
||||
void closeAt;
|
||||
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
||||
if (row.status === 'FINALIZING') {
|
||||
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
||||
return time.tick ?? time.now.getTime();
|
||||
if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
|
||||
return time.tick;
|
||||
}
|
||||
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
type GamePrismaClient,
|
||||
@@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
|
||||
|
||||
@@ -24,13 +28,27 @@ interface RedisTimerClient {
|
||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||
zRem(key: string, values: string | string[]): Promise<number>;
|
||||
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
|
||||
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
const POP_DUE_AUCTIONS_SCRIPT = `
|
||||
if redis.call('GET', KEYS[2]) ~= ARGV[1]
|
||||
or redis.call('GET', KEYS[3]) ~= ARGV[2]
|
||||
or redis.call('GET', KEYS[4]) ~= 'RUNNING' then
|
||||
return { '__CLOCK_FENCE__' }
|
||||
end
|
||||
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4])
|
||||
if #ids > 0 then
|
||||
redis.call('ZREM', KEYS[1], unpack(ids))
|
||||
end
|
||||
return ids
|
||||
`;
|
||||
|
||||
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
||||
|
||||
interface AuctionFinalizeDeadline {
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
closeTick: bigint;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeCommand {
|
||||
@@ -38,19 +56,10 @@ interface AuctionFinalizeCommand {
|
||||
requestId: string;
|
||||
auctionId: number;
|
||||
expectedCloseAt: string;
|
||||
expectedCloseTick?: number;
|
||||
expectedCloseTick: number;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeEventRecord {
|
||||
target: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
status: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
|
||||
if (closeTick === null) return undefined;
|
||||
const readSafeCloseTick = (closeTick: bigint): number => {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
@@ -63,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
|
||||
deadline: AuctionFinalizeDeadline,
|
||||
retry = 0
|
||||
): string => {
|
||||
const generation =
|
||||
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
|
||||
const base = `auction:finalize:${auctionId}:${generation}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
|
||||
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
|
||||
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
@@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = (
|
||||
requestId,
|
||||
auctionId,
|
||||
expectedCloseAt: deadline.closeAt.toISOString(),
|
||||
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
|
||||
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
|
||||
});
|
||||
|
||||
const isMatchingAuctionFinalizeEvent = (
|
||||
@@ -95,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
|
||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const expectedGenerationMatches =
|
||||
payloadRecord?.expectedCloseTick !== undefined
|
||||
? payloadRecord.expectedCloseTick === command.expectedCloseTick
|
||||
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
|
||||
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
|
||||
return (
|
||||
event.target === 'ENGINE' &&
|
||||
event.eventType === command.type &&
|
||||
@@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
|
||||
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
|
||||
};
|
||||
|
||||
const popDueAuctionIds = async (
|
||||
export const popDueAuctionIds = async (
|
||||
redis: RedisTimerClient,
|
||||
timerKey: string,
|
||||
nowMs: number,
|
||||
batchSize: number
|
||||
batchSize: number,
|
||||
clockFence?: {
|
||||
activeRevisionKey: string;
|
||||
deadlineGenerationKey: string;
|
||||
phaseKey: string;
|
||||
revision: number;
|
||||
generation: number;
|
||||
}
|
||||
): Promise<string[]> => {
|
||||
if (clockFence) {
|
||||
if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.');
|
||||
const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, {
|
||||
keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey],
|
||||
arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)],
|
||||
});
|
||||
if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.');
|
||||
if (result[0] === '__CLOCK_FENCE__') return [];
|
||||
return result.map(String);
|
||||
}
|
||||
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
|
||||
if (ids.length > 0) {
|
||||
await redis.zRem(timerKey, ids);
|
||||
@@ -157,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
|
||||
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
||||
continue;
|
||||
}
|
||||
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
|
||||
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
||||
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
||||
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
|
||||
const bases = [...new Set([canonicalBase, legacyBase])];
|
||||
const events = await options.db.inputEvent.findMany({
|
||||
where: {
|
||||
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
|
||||
OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
|
||||
},
|
||||
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
||||
orderBy: { sequence: 'desc' },
|
||||
@@ -182,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
|
||||
timers.push({
|
||||
score:
|
||||
row.status === 'FINALIZING'
|
||||
? (options.gameTime.tick ?? options.gameTime.now.getTime())
|
||||
? (() => {
|
||||
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
|
||||
return options.gameTime.tick;
|
||||
})()
|
||||
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
||||
value: String(row.id),
|
||||
});
|
||||
@@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: {
|
||||
nowMs: number;
|
||||
nowTick?: number | null;
|
||||
historyNowMs?: number;
|
||||
expectedClockRevision?: number;
|
||||
expectedDeadlineGeneration?: number;
|
||||
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
|
||||
const auctionId = Number(id);
|
||||
@@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: {
|
||||
}
|
||||
const now = new Date(nowMs);
|
||||
const outcome = await db.$transaction(async (transaction) => {
|
||||
if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const [world] = await transaction.$queryRaw<
|
||||
Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }>
|
||||
>(GamePrisma.sql`
|
||||
SELECT
|
||||
clock_phase AS "clockPhase",
|
||||
clock_revision AS "clockRevision",
|
||||
deadline_generation AS "deadlineGeneration"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (
|
||||
!world ||
|
||||
world.clockPhase !== 'RUNNING' ||
|
||||
world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) ||
|
||||
world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1)
|
||||
) {
|
||||
return { status: 'RESCHEDULED' as const, clockFenceFailed: true };
|
||||
}
|
||||
}
|
||||
const current = await transaction.auction.findUnique({
|
||||
where: { id: auctionId },
|
||||
select: { status: true, closeAt: true, closeTick: true },
|
||||
@@ -221,10 +264,10 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
if (current.status === 'OPEN') {
|
||||
const isDue =
|
||||
current.closeTick !== null && nowTick !== null
|
||||
? current.closeTick <= BigInt(nowTick)
|
||||
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
|
||||
if (current.closeTick === null || nowTick === null) {
|
||||
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
|
||||
}
|
||||
const isDue = current.closeTick <= BigInt(nowTick);
|
||||
if (!isDue) {
|
||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
}
|
||||
@@ -233,24 +276,15 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
|
||||
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
|
||||
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
||||
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
||||
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
||||
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
|
||||
let existing: AuctionFinalizeEventRecord | null = null;
|
||||
let existingRequestId = requestId;
|
||||
for (const candidateRequestId of candidateRequestIds) {
|
||||
existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId: candidateRequestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
if (existing) {
|
||||
existingRequestId = candidateRequestId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
|
||||
const existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
if (!existing) {
|
||||
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
await transaction.inputEvent.create({
|
||||
@@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
||||
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
|
||||
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
}
|
||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
||||
throw new Error(
|
||||
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
|
||||
`Auction remained ${current.status} after successful durable event: ${requestId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +318,10 @@ export const processDueAuctionId = async (options: {
|
||||
return 'PENDING';
|
||||
}
|
||||
if (outcome.status === 'RESCHEDULED') {
|
||||
if ('clockFenceFailed' in outcome) {
|
||||
await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]);
|
||||
return 'RESCHEDULED';
|
||||
}
|
||||
const gameTime = await loadCurrentGameTime(db, now);
|
||||
await redis.zAdd(timerKey, [
|
||||
{
|
||||
@@ -315,18 +353,30 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
|
||||
]);
|
||||
|
||||
let nextResyncAt = Date.now();
|
||||
let nextResyncAt = performance.now();
|
||||
const pendingFinalizationIds = new Set<number>();
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
const operationalNowMs = Date.now();
|
||||
const operationalElapsedMs = performance.now();
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||
const gameNowMs = gameTime.now.getTime();
|
||||
const dueScore = gameTime.tick ?? gameNowMs;
|
||||
if (operationalNowMs >= nextResyncAt) {
|
||||
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
|
||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
||||
continue;
|
||||
}
|
||||
const clockFence = gameTime.phase
|
||||
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
|
||||
: null;
|
||||
if (gameTime.phase && !clockFence) {
|
||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
||||
continue;
|
||||
}
|
||||
if (operationalElapsedMs >= nextResyncAt) {
|
||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
||||
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
|
||||
}
|
||||
if (pendingFinalizationIds.size > 0) {
|
||||
const reconciliation = await reconcilePendingAuctionTimers({
|
||||
@@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
if (historyTrimBefore > 0) {
|
||||
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
||||
}
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined);
|
||||
if (dueIds.length > 0) {
|
||||
for (const id of dueIds) {
|
||||
try {
|
||||
@@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
nowMs: gameNowMs,
|
||||
nowTick: gameTime.tick,
|
||||
historyNowMs: operationalNowMs,
|
||||
...(clockFence
|
||||
? {
|
||||
expectedClockRevision: clockFence.revision,
|
||||
expectedDeadlineGeneration: clockFence.generation,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (outcome === 'PENDING') {
|
||||
pendingFinalizationIds.add(Number(id));
|
||||
@@ -395,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
await closeResources();
|
||||
}
|
||||
};
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
@@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
||||
|
||||
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
||||
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
|
||||
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
|
||||
type WorldClockFields =
|
||||
| 'clockBaseTime'
|
||||
| 'clockTick'
|
||||
| 'clockMode'
|
||||
| 'clockWallAnchor'
|
||||
| 'lastTurnTick'
|
||||
| 'clockPhase'
|
||||
| 'clockRevision'
|
||||
| 'deadlineGeneration';
|
||||
type GeneralClockFields = 'turnTick' | 'recentWarTick';
|
||||
|
||||
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
readInputEventClockCoordinate,
|
||||
type DatabaseClient,
|
||||
type GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import type { TurnDaemonTransport } from './transport.js';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
@@ -82,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||
if (durableCommand.type === 'npcPossessGeneral') {
|
||||
delete durableCommand.acceptedGameAt;
|
||||
}
|
||||
// Rolling-upgrade compatibility: older API versions supplied game
|
||||
// coordinates. They are deliberately not persisted as command facts;
|
||||
// the daemon assigns the authoritative processing coordinate while
|
||||
// claiming the input event under the clock fence.
|
||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
|
||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
|
||||
if (command.type === 'npcPossessGeneral') {
|
||||
const existing = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
@@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
try {
|
||||
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
|
||||
const rejectionReason = await this.db.$transaction(async (transaction) => {
|
||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
||||
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
||||
const token = await transaction.npcSelectionToken.findFirst({
|
||||
where: {
|
||||
ownerUserId: command.userId,
|
||||
nonce: command.tokenNonce,
|
||||
validUntil: { gte: acceptedGameAt },
|
||||
validUntilTick: { gte: coordinate.gameTick },
|
||||
},
|
||||
select: { pickResult: true },
|
||||
});
|
||||
@@ -126,18 +133,20 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
) {
|
||||
return '선택한 장수가 목록에 없습니다.';
|
||||
}
|
||||
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
|
||||
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||
acceptedGameAt: acceptedGameAt.toISOString(),
|
||||
};
|
||||
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
|
||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||
return null;
|
||||
});
|
||||
if (rejectionReason) {
|
||||
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
|
||||
}
|
||||
} else {
|
||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||
if (this.db.$transaction) {
|
||||
await this.db.$transaction(async (transaction) => {
|
||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||
});
|
||||
} else {
|
||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RejectedNpcPossessionCommandError) {
|
||||
@@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
private async createInputEvent(
|
||||
db: DatabaseClient,
|
||||
command: TurnDaemonCommand,
|
||||
requestId: string,
|
||||
createdAt?: Date
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
@@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
eventType: command.type,
|
||||
payload: asJson(command),
|
||||
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
// PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
|
||||
// authoritative game coordinate when the daemon claims it.
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -192,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
}
|
||||
|
||||
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
||||
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
while (performance.now() < deadline) {
|
||||
const event = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
select: { status: true, result: true, error: true },
|
||||
@@ -204,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
if (event?.status === 'FAILED') {
|
||||
throw new FailedTurnDaemonCommandError(requestId, event.error);
|
||||
}
|
||||
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
|
||||
await delay(Math.min(50, Math.max(1, deadline - performance.now())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type DatabaseClient as InfraDatabaseClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from './context.js';
|
||||
|
||||
@@ -29,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
|
||||
$executeRawUnsafe(query: string): Promise<number>;
|
||||
};
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
const canonicalJson = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, entry: unknown) => {
|
||||
if (typeof entry === 'bigint') {
|
||||
@@ -85,6 +88,9 @@ const insertPendingIfAbsent = async (
|
||||
actor_user_id,
|
||||
status,
|
||||
attempts,
|
||||
accepted_game_tick,
|
||||
accepted_clock_revision,
|
||||
accepted_deadline_generation,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@@ -95,6 +101,9 @@ const insertPendingIfAbsent = async (
|
||||
${options.actorUserId},
|
||||
'PENDING'::"InputEventStatus",
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
ON CONFLICT (request_id) DO NOTHING
|
||||
@@ -153,20 +162,22 @@ const claimInputEvent = async (
|
||||
requestId: string,
|
||||
payloadIdentity: ApiInputPayloadIdentity
|
||||
): Promise<void> => {
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
payload: asJson(payloadIdentity),
|
||||
status: 'PROCESSING',
|
||||
result: GamePrisma.DbNull,
|
||||
error: null,
|
||||
attempts: { increment: 1 },
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: new Date(),
|
||||
completedAt: null,
|
||||
},
|
||||
});
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
|
||||
status = 'PROCESSING'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = NULL,
|
||||
attempts = attempts + 1,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
processing_game_tick = NULL,
|
||||
processing_clock_revision = NULL,
|
||||
processing_deadline_generation = NULL,
|
||||
completed_at = NULL
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
};
|
||||
|
||||
const markUnexpectedFailure = async (
|
||||
@@ -177,6 +188,7 @@ const markUnexpectedFailure = async (
|
||||
actorUserId: string | null;
|
||||
payloadIdentity: ApiInputPayloadIdentity;
|
||||
error: unknown;
|
||||
acquireClockFence: boolean;
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (!db.$transaction) return;
|
||||
@@ -184,6 +196,9 @@ const markUnexpectedFailure = async (
|
||||
|
||||
try {
|
||||
await db.$transaction(async (transaction) => {
|
||||
if (options.acquireClockFence) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
}
|
||||
await insertPendingIfAbsent(transaction, options);
|
||||
const row = await lockInputEvent(transaction, options.requestId);
|
||||
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
||||
@@ -192,20 +207,19 @@ const markUnexpectedFailure = async (
|
||||
// late failure recorder must never replace its durable success.
|
||||
return;
|
||||
}
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId: options.requestId },
|
||||
data: {
|
||||
payload: asJson(options.payloadIdentity),
|
||||
status: 'FAILED',
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
attempts: { increment: 1 },
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
|
||||
status = 'FAILED'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = ${message},
|
||||
attempts = attempts + 1,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${options.requestId}
|
||||
`);
|
||||
});
|
||||
} catch {
|
||||
// Preserve the transaction failure that the caller actually observed. If
|
||||
@@ -220,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
actorUserId?: string | null;
|
||||
acquireClockFence?: boolean;
|
||||
execute(db: DatabaseClient): Promise<T>;
|
||||
}): Promise<T> => {
|
||||
const { db, requestId, eventType, payload, execute } = options;
|
||||
const actorUserId = options.actorUserId ?? null;
|
||||
const acquireClockFence = options.acquireClockFence !== false;
|
||||
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
||||
if (!db.$transaction) {
|
||||
return execute(db);
|
||||
@@ -233,6 +249,9 @@ export const executeInputEvent = async <T>(options: {
|
||||
let outcome: InputEventOutcome<T>;
|
||||
try {
|
||||
outcome = await db.$transaction(async (transaction) => {
|
||||
if (acquireClockFence) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
}
|
||||
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
||||
const row = await lockInputEvent(transaction, requestId);
|
||||
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
||||
@@ -258,36 +277,41 @@ export const executeInputEvent = async <T>(options: {
|
||||
try {
|
||||
const value = await execute(transaction);
|
||||
const durableResult = canonicalJsonValue(value);
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(durableResult),
|
||||
error: null,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
|
||||
error = NULL,
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
return { kind: 'executed', value };
|
||||
} catch (error) {
|
||||
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = ${message},
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
return { kind: 'failed', error };
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
||||
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
|
||||
await markUnexpectedFailure(db, {
|
||||
requestId,
|
||||
eventType,
|
||||
actorUserId,
|
||||
payloadIdentity,
|
||||
error,
|
||||
acquireClockFence,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
enqueuePrivateMessageWebPush,
|
||||
GamePrisma,
|
||||
persistMessageEnvelope,
|
||||
type MessageGameContext,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -22,7 +26,9 @@ interface MessageRow {
|
||||
src: number;
|
||||
dest: number;
|
||||
time: Date;
|
||||
valid_until: Date;
|
||||
created_at_wall: Date;
|
||||
action_status: string | null;
|
||||
expires_game_tick: bigint | null;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
@@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => {
|
||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
};
|
||||
|
||||
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
|
||||
if (gameTime.tick === null) {
|
||||
// A legacy or partially migrated profile has no authoritative logical
|
||||
// tick. Rows that already carry a tick still need the wall-time
|
||||
// fallback used by the clock migration.
|
||||
return GamePrisma.sql`valid_until > ${gameTime.now}`;
|
||||
}
|
||||
return GamePrisma.sql`(
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)`;
|
||||
};
|
||||
|
||||
const toMessageView = (row: MessageRow): MessageView => {
|
||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||
const payload = parsePayload(row.message);
|
||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
||||
const actionUnavailable =
|
||||
actionStatus !== null &&
|
||||
(actionStatus !== 'PENDING' ||
|
||||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
|
||||
return {
|
||||
id: row.id,
|
||||
msgType: row.type,
|
||||
src: payload.src,
|
||||
dest: row.type === 'public' ? null : payload.dest,
|
||||
text: payload.text,
|
||||
option: payload.option ?? null,
|
||||
time: formatMessageTime(new Date(row.time)),
|
||||
option:
|
||||
actionUnavailable && payload.option && typeof payload.option === 'object'
|
||||
? { ...payload.option, used: true, invalid: true }
|
||||
: (payload.option ?? null),
|
||||
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
||||
};
|
||||
};
|
||||
|
||||
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
// Ref represents its unlimited 9999-12-31 message lifetime with the
|
||||
// largest safe game tick instead of falling back to a wall-clock-only row.
|
||||
if (date.getUTCFullYear() >= 9000) {
|
||||
return BigInt(MAX_SAFE_GAME_TICK);
|
||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||
let gameContext: MessageGameContext | null = null;
|
||||
if (typeof action === 'string' && action !== '') {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (
|
||||
gameTime.tick === null ||
|
||||
gameTime.revision === null ||
|
||||
gameTime.revision === undefined ||
|
||||
gameTime.deadlineGeneration === null ||
|
||||
gameTime.deadlineGeneration === undefined
|
||||
) {
|
||||
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
|
||||
}
|
||||
try {
|
||||
const tick = gameTime.dateToTick(date);
|
||||
return tick === null ? null : BigInt(tick);
|
||||
} catch {
|
||||
return null;
|
||||
let expiresGameTick: bigint | null = null;
|
||||
if (draft.validUntil.getUTCFullYear() < 9000) {
|
||||
const expires = gameTime.dateToTick(draft.validUntil);
|
||||
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
|
||||
expiresGameTick = BigInt(expires);
|
||||
}
|
||||
};
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${toTickOrNull(draft.time)},
|
||||
${draft.validUntil},
|
||||
${toTickOrNull(draft.validUntil)},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to insert message row.');
|
||||
gameContext = {
|
||||
occurredGameTick: BigInt(gameTime.tick),
|
||||
clockRevision: BigInt(gameTime.revision),
|
||||
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
|
||||
expiresGameTick,
|
||||
};
|
||||
}
|
||||
const id = await persistMessageEnvelope(db, draft, gameContext);
|
||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
|
||||
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
|
||||
return rows.map((row) => toMessageView(row, currentGameTick));
|
||||
};
|
||||
|
||||
export const fetchMessagesFromMailbox = async (params: {
|
||||
db: DatabaseClient;
|
||||
mailbox: number;
|
||||
@@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: {
|
||||
fromSeq: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const fromSeq = Math.max(params.fromSeq, 0);
|
||||
const gameTime = await loadCurrentGameTime(params.db);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id >= ${fromSeq}
|
||||
ORDER BY id DESC
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.mailbox = ${params.mailbox}
|
||||
AND m.type = ${params.msgType}
|
||||
AND m.id >= ${fromSeq}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
return loadMessageViews(params.db, rows);
|
||||
};
|
||||
|
||||
export const fetchOldMessagesFromMailbox = async (params: {
|
||||
@@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
||||
toSeq: number;
|
||||
limit: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const gameTime = await loadCurrentGameTime(params.db);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id < ${params.toSeq}
|
||||
ORDER BY id DESC
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.mailbox = ${params.mailbox}
|
||||
AND m.type = ${params.msgType}
|
||||
AND m.id < ${params.toSeq}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
return loadMessageViews(params.db, rows);
|
||||
};
|
||||
|
||||
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const rows = await db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.id = ${id}
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
@@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
|
||||
id: row.id,
|
||||
mailbox: row.mailbox,
|
||||
msgType: row.type,
|
||||
time: new Date(row.time),
|
||||
time: new Date(row.created_at_wall ?? row.time),
|
||||
payload: parsePayload(row.message),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
|
||||
const rows = await db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
JOIN message_action ma ON ma.message_id = m.id
|
||||
JOIN world_state world ON TRUE
|
||||
WHERE m.id = ${id}
|
||||
AND ma.status = 'PENDING'
|
||||
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
|
||||
AND world.clock_phase IN ('RUNNING', 'MANUAL')
|
||||
AND ma.clock_revision = world.clock_revision
|
||||
AND ma.deadline_generation = world.deadline_generation
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
FOR UPDATE OF m, ma, world
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
@@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
|
||||
id: row.id,
|
||||
mailbox: row.mailbox,
|
||||
msgType: row.type,
|
||||
time: new Date(row.time),
|
||||
time: new Date(row.created_at_wall ?? row.time),
|
||||
payload: parsePayload(row.message),
|
||||
};
|
||||
};
|
||||
@@ -202,16 +216,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return;
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
|
||||
await db.messageAction.updateMany({
|
||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
|
||||
});
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: gameTime.now,
|
||||
// A partially migrated profile can still carry a legacy logical
|
||||
// sentinel even while no authoritative clock exists. Replace it
|
||||
// with an already-expired logical tick when expiring by wall time;
|
||||
// NULL would fall back to the wall timestamp after clock recovery
|
||||
// and could make the handled message visible again.
|
||||
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
|
||||
validUntilTick: BigInt(gameTime.tick),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
)
|
||||
),
|
||||
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const tombstoneMessagesWithinDeleteWindow = async (
|
||||
db: DatabaseClient,
|
||||
authorityMessageId: number,
|
||||
ids: number[]
|
||||
): Promise<number[]> => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
WITH wall AS (
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
), authority AS (
|
||||
SELECT m.id
|
||||
FROM message m, wall
|
||||
WHERE m.id = ${authorityMessageId}
|
||||
AND m.tombstoned_at_wall IS NULL
|
||||
AND m.delete_until_wall >= wall.now_wall
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE message m
|
||||
SET message = jsonb_set(
|
||||
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
|
||||
'{option}',
|
||||
(
|
||||
CASE
|
||||
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
),
|
||||
tombstoned_at_wall = wall.now_wall
|
||||
FROM wall
|
||||
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
|
||||
AND EXISTS (SELECT 1 FROM authority)
|
||||
RETURNING m.id
|
||||
`);
|
||||
return rows.map(({ id }) => id).sort((left, right) => left - right);
|
||||
};
|
||||
|
||||
@@ -60,10 +60,7 @@ export interface AuctionDetail {
|
||||
export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
time: { now: Date; tick: number | null }
|
||||
): boolean =>
|
||||
auction.closeTick !== null && time.tick !== null
|
||||
? auction.closeTick < BigInt(time.tick)
|
||||
: auction.closeAt.getTime() < time.now.getTime();
|
||||
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
|
||||
|
||||
interface AuctionBidRow {
|
||||
id: number;
|
||||
@@ -433,7 +430,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -448,7 +444,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
@@ -511,7 +507,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -526,7 +521,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
@@ -650,7 +645,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -665,7 +659,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
||||
return world;
|
||||
};
|
||||
|
||||
interface BettingClockFenceRow {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
}
|
||||
|
||||
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
|
||||
SELECT current_year AS "currentYear",
|
||||
current_month AS "currentMonth",
|
||||
clock_phase AS "clockPhase",
|
||||
clock_revision AS "clockRevision",
|
||||
deadline_generation AS "deadlineGeneration"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
const world = rows[0];
|
||||
if (!world) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
|
||||
}
|
||||
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
|
||||
});
|
||||
}
|
||||
return world;
|
||||
};
|
||||
|
||||
export const bettingRouter = router({
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
|
||||
async ({ ctx, input }) => {
|
||||
requireUserId(ctx.auth);
|
||||
await getMyGeneral(ctx);
|
||||
const [world, rows] = await Promise.all([
|
||||
@@ -66,7 +100,8 @@ export const bettingRouter = router({
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
};
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
getDetail: authedProcedure
|
||||
.input(z.object({ bettingId: z.number().int().positive() }))
|
||||
@@ -141,7 +176,7 @@ export const bettingRouter = router({
|
||||
if (betting.finished) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
||||
}
|
||||
const world = await loadWorldDate(ctx.db);
|
||||
const world = await lockBettingClockFence(ctx.db);
|
||||
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
||||
if (betting.closeYearMonth <= yearMonth) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||
import { insertMessage } from '../../messages/store.js';
|
||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { readDatabaseWallTime } from '../../services/wallClock.js';
|
||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
|
||||
nationColor: destNation.color,
|
||||
},
|
||||
};
|
||||
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
|
||||
|
||||
const created = await ctx.db.diplomacyLetter.create({
|
||||
data: {
|
||||
srcNationId: srcNation.id,
|
||||
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
|
||||
state: 'PROPOSED',
|
||||
textBrief: purifyDiplomacyHtml(input.brief),
|
||||
textDetail: purifyDiplomacyHtml(input.detail),
|
||||
date: letterDate,
|
||||
srcSignerId: me.id,
|
||||
aux: aux as GamePrisma.InputJsonValue,
|
||||
},
|
||||
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
|
||||
src: srcTarget,
|
||||
dest: destTarget,
|
||||
text,
|
||||
time: letterDate,
|
||||
time: created.date,
|
||||
});
|
||||
|
||||
return { id: created.id };
|
||||
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
|
||||
);
|
||||
const messageSrc = buildActorTarget(me, destNation);
|
||||
const messageDest = buildNationTarget(srcNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
const aux = asRecord(letter.aux);
|
||||
let messageText: string;
|
||||
if (input.agree) {
|
||||
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
|
||||
);
|
||||
const messageSrc = buildActorTarget(me, srcNation);
|
||||
const messageDest = buildNationTarget(destNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
const aux = asRecord(letter.aux);
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
|
||||
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||
const messageSrc = buildActorTarget(me, actorNation);
|
||||
const messageDest = buildNationTarget(otherNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
let resultState: 'ACTIVATED' | 'CANCELLED';
|
||||
let messageText: string;
|
||||
|
||||
|
||||
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
|
||||
return started && !ended;
|
||||
};
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
const zGeneralSettings = z.strictObject({
|
||||
tnmt: z.number().int().optional(),
|
||||
defence_train: z.number().int().optional(),
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_war: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
|
||||
@@ -244,7 +243,6 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
|
||||
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
|
||||
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
|
||||
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
|
||||
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
|
||||
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
|
||||
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
|
||||
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
troopLeaderFirstTurn,
|
||||
accessLog,
|
||||
rankRows,
|
||||
gameTime,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
parsedLastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
gameTime.now
|
||||
),
|
||||
crewTypeId: general.crewTypeId,
|
||||
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
|
||||
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock is held at the future open anchor, so comparing them with
|
||||
// JavaScript wall time inside a timestamp-without-time-zone predicate can
|
||||
// hide an otherwise active poll. Resolve the same tick-first clock contract
|
||||
// used by voting instead; hasVoted only affects the notice, not activity.
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
|
||||
// predicate can hide an otherwise active poll. Resolve the same tick-first
|
||||
// clock contract used by voting; hasVoted only affects the notice, not activity.
|
||||
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
||||
|
||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||
|
||||
@@ -38,6 +38,8 @@ const BUFF_KEYS: InheritBuffType[] = [
|
||||
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||
|
||||
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
||||
const MIN_INHERIT_BUFF_LEVEL = 1;
|
||||
const MAX_INHERIT_BUFF_LEVEL = 5;
|
||||
|
||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
if (typeof raw === 'string') {
|
||||
@@ -302,7 +304,7 @@ export const inheritRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
type: z.enum(BUFF_KEYS),
|
||||
level: z.number().int().min(1).max(5),
|
||||
level: z.number().int(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -310,6 +312,12 @@ export const inheritRouter = router({
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (input.level < MIN_INHERIT_BUFF_LEVEL || input.level > MAX_INHERIT_BUFF_LEVEL) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await requestInheritanceAction(ctx, userId, {
|
||||
action: 'buyHiddenBuff',
|
||||
|
||||
@@ -4,6 +4,11 @@ import { z } from 'zod';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
} from '@sammo-ts/infra';
|
||||
import {
|
||||
isWarTraitKey,
|
||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||
@@ -302,6 +307,7 @@ export const joinRouter = router({
|
||||
}
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
const configConst = asRecord(config.const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
@@ -348,7 +354,8 @@ export const joinRouter = router({
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||
allowDirectCreation: (blockGeneralCreate & 1) === 0,
|
||||
allowCustomName: (blockGeneralCreate & 2) === 0,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
@@ -391,15 +398,12 @@ export const joinRouter = router({
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReserve',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionReservationCommandResult(result);
|
||||
}),
|
||||
@@ -429,7 +433,6 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolCreate',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
@@ -438,8 +441,6 @@ export const joinRouter = router({
|
||||
uniqueName: input.uniqueName,
|
||||
personality: input.personality,
|
||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
...(selectedIcon
|
||||
? {
|
||||
ownerPicture: selectedIcon.picture,
|
||||
@@ -469,15 +470,12 @@ export const joinRouter = router({
|
||||
input.clientRequestId,
|
||||
'reselect'
|
||||
);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReselect',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
ownerDisplayName: auth.user.displayName,
|
||||
uniqueName: input.uniqueName,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||
}),
|
||||
@@ -581,30 +579,46 @@ export const joinRouter = router({
|
||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
if (gameTime.tick === null) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Game clock is not initialized.',
|
||||
return await ctx.db.$transaction!(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
|
||||
SELECT clock_phase AS "clockPhase"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (!clockRows[0]) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const worldState = await transaction.worldState.findFirst();
|
||||
const gameTime = await loadCurrentGameTime(transaction);
|
||||
if (!worldState || gameTime.tick === null) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Game clock is not initialized.',
|
||||
});
|
||||
}
|
||||
return reserveNpcPossessionCandidates({
|
||||
db: transaction,
|
||||
worldState,
|
||||
userId: auth.user.id,
|
||||
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
||||
refresh: input.refresh,
|
||||
keepIds: input.keepIds,
|
||||
now: gameTime.now,
|
||||
createdGameTick: gameTime.tick,
|
||||
});
|
||||
}
|
||||
return await reserveNpcPossessionCandidates({
|
||||
db: ctx.db,
|
||||
worldState,
|
||||
userId: auth.user.id,
|
||||
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
||||
refresh: input.refresh,
|
||||
keepIds: input.keepIds,
|
||||
now: gameTime.now,
|
||||
acceptedGameTick: gameTime.tick,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NpcPossessionError) {
|
||||
|
||||
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
|
||||
: null,
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
|
||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
||||
myGeneral,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, ChangeJournal } from '@sammo-ts/common';
|
||||
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
|
||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
accessLimitAuthedInputProcedure,
|
||||
accessWallAuthedInputProcedure,
|
||||
authedProcedure,
|
||||
engineAuthedProcedure,
|
||||
router,
|
||||
wallAuthedProcedure,
|
||||
} from '../../trpc.js';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
MESSAGE_MAILBOX_PUBLIC,
|
||||
@@ -20,13 +28,13 @@ import {
|
||||
fetchOldMessagesFromMailbox,
|
||||
fetchMessageById,
|
||||
insertMessage,
|
||||
tombstoneMessages,
|
||||
tombstoneMessagesWithinDeleteWindow,
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
import { resolveNationPermission } from '../nation/shared.js';
|
||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { executeInputEvent } from '../../inputEventBoundary.js';
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
@@ -231,7 +239,7 @@ export const messagesRouter = router({
|
||||
})),
|
||||
};
|
||||
}),
|
||||
readLatest: authedProcedure
|
||||
readLatest: wallAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -264,7 +272,7 @@ export const messagesRouter = router({
|
||||
`;
|
||||
return { ok: true };
|
||||
}),
|
||||
delete: authedProcedure
|
||||
delete: wallAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -289,17 +297,16 @@ export const messagesRouter = router({
|
||||
if (message.payload.option?.deletable === false) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||
}
|
||||
const { now } = await loadCurrentGameTime(ctx.db);
|
||||
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||
const ids = [
|
||||
message.id,
|
||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||
];
|
||||
await tombstoneMessages(ctx.db, ids);
|
||||
const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
|
||||
if (!deletedIds.includes(message.id)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMailbox =
|
||||
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||
? message.payload.dest.generalId
|
||||
@@ -309,9 +316,9 @@ export const messagesRouter = router({
|
||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||
: null;
|
||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||
return { ok: true, deletedIds: ids };
|
||||
return { ok: true, deletedIds };
|
||||
}),
|
||||
respond: authedProcedure
|
||||
respond: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -342,29 +349,80 @@ export const messagesRouter = router({
|
||||
}
|
||||
return { result: commandResult.ok, reason: commandResult.reason };
|
||||
}
|
||||
const result = await respondToDiplomaticMessage({
|
||||
const ownsChangeJournal = !ctx.changeJournal;
|
||||
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
actor: general,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
requestId: `messages.respond.diplomatic:${input.messageId}`,
|
||||
eventType: 'messages.respond.diplomatic',
|
||||
payload: input,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
execute: async (transaction) => {
|
||||
const transactionContext = { ...ctx, db: transaction, changeJournal };
|
||||
const transactionGeneral = ownsChangeJournal
|
||||
? await getOwnedGeneral(transactionContext, input.generalId)
|
||||
: general;
|
||||
const result = await respondToDiplomaticMessage({
|
||||
db: transaction,
|
||||
actor: transactionGeneral,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
});
|
||||
markMessageMailboxes(transactionContext, result.affectedMailboxes);
|
||||
for (const generalId of result.affectedGeneralRecordIds) {
|
||||
changeJournal.mark('records.general', generalId);
|
||||
}
|
||||
for (const nationId of result.affectedNationIds) {
|
||||
changeJournal.mark('nation.content', nationId);
|
||||
}
|
||||
for (const cityId of result.affectedCityIds) {
|
||||
changeJournal.mark('city.content', cityId);
|
||||
}
|
||||
if (result.affectedCityIds.length > 0) {
|
||||
changeJournal.mark('map.world');
|
||||
}
|
||||
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
||||
changeJournal.mark('dashboard.global');
|
||||
}
|
||||
if (ownsChangeJournal) {
|
||||
journalPersisted = Boolean(
|
||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||
);
|
||||
}
|
||||
return {
|
||||
result: result.result,
|
||||
reason: result.reason,
|
||||
affectedNationIds: result.affectedNationIds,
|
||||
affectedCityIds: result.affectedCityIds,
|
||||
};
|
||||
},
|
||||
});
|
||||
markMessageMailboxes(ctx, result.affectedMailboxes);
|
||||
for (const generalId of result.affectedGeneralRecordIds) {
|
||||
ctx.changeJournal?.mark('records.general', generalId);
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
for (const nationId of result.affectedNationIds) {
|
||||
ctx.changeJournal?.mark('nation.content', nationId);
|
||||
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const synchronized = await ctx.turnDaemon.requestCommand({
|
||||
type: 'syncDiplomaticResponse',
|
||||
userId: ctx.auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: input.messageId,
|
||||
nationIds: response.affectedNationIds,
|
||||
cityIds: response.affectedCityIds,
|
||||
});
|
||||
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
|
||||
const synchronizationReason =
|
||||
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const cityId of result.affectedCityIds) {
|
||||
ctx.changeJournal?.mark('city.content', cityId);
|
||||
}
|
||||
if (result.affectedCityIds.length > 0) {
|
||||
ctx.changeJournal?.mark('map.world');
|
||||
}
|
||||
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
||||
ctx.changeJournal?.mark('dashboard.global');
|
||||
}
|
||||
return { result: result.result, reason: result.reason };
|
||||
return { result: response.result, reason: response.reason };
|
||||
}),
|
||||
getOld: authedProcedure
|
||||
.input(
|
||||
@@ -420,7 +478,7 @@ export const messagesRouter = router({
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
send: accessAuthedInputProcedure(
|
||||
send: accessWallAuthedInputProcedure(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
mailbox: z.number().int(),
|
||||
@@ -436,7 +494,9 @@ export const messagesRouter = router({
|
||||
}
|
||||
|
||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||
const { now } = await loadCurrentGameTime(ctx.db);
|
||||
// Compatibility-only projection. persistMessageEnvelope records and
|
||||
// displays the authoritative PostgreSQL wall instant.
|
||||
const now = new Date();
|
||||
const validUntil = new Date('9999-12-31T00:00:00Z');
|
||||
|
||||
let msgType: MessageType;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
@@ -44,6 +46,63 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
const withTournamentClockMutation = async <T>(
|
||||
ctx: {
|
||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
||||
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
|
||||
profile: { name: string };
|
||||
},
|
||||
store: TournamentStore,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const fence = await ensureActiveRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
|
||||
if (!fence) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Clock reconciliation is incomplete; tournament mutation is disabled.',
|
||||
});
|
||||
}
|
||||
const clockContext: TournamentClockContext = {
|
||||
phase: fence.phase,
|
||||
revision: fence.revision,
|
||||
deadlineGeneration: fence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
};
|
||||
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
|
||||
};
|
||||
|
||||
const withTournamentBetClockMutation = async <T>(
|
||||
ctx: {
|
||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
||||
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
|
||||
profile: { name: string };
|
||||
},
|
||||
store: TournamentStore,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
|
||||
if (!fence) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
|
||||
});
|
||||
}
|
||||
return store.withClockContext(
|
||||
{
|
||||
phase: fence.phase,
|
||||
revision: fence.revision,
|
||||
deadlineGeneration: fence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
},
|
||||
() => store.withMutationLock(operation)
|
||||
);
|
||||
};
|
||||
|
||||
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
||||
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
|
||||
|
||||
const zTournamentState = z.object({
|
||||
stage: z.number().int().min(0),
|
||||
phase: z.number().int().min(0),
|
||||
@@ -143,7 +202,10 @@ export const tournamentRouter = router({
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.getState();
|
||||
}),
|
||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||
getAdminStatus: adminProcedure.query(async ({ ctx }) => ({
|
||||
ok: true,
|
||||
clock: await loadClockAdminStatus(ctx.db),
|
||||
})),
|
||||
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
@@ -271,7 +333,7 @@ export const tournamentRouter = router({
|
||||
}),
|
||||
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setState({
|
||||
...input,
|
||||
type: input.type as TournamentType,
|
||||
@@ -281,7 +343,7 @@ export const tournamentRouter = router({
|
||||
}),
|
||||
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
const current = await store.getState();
|
||||
if (!current) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
@@ -297,21 +359,21 @@ export const tournamentRouter = router({
|
||||
}),
|
||||
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setParticipants(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setMatches(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setBettingEntries(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
@@ -347,7 +409,7 @@ export const tournamentRouter = router({
|
||||
};
|
||||
});
|
||||
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setParticipants(participants);
|
||||
return { ok: true, count: participants.length };
|
||||
});
|
||||
@@ -394,7 +456,7 @@ export const tournamentRouter = router({
|
||||
join: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
||||
@@ -459,7 +521,7 @@ export const tournamentRouter = router({
|
||||
}),
|
||||
cancel: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
const state = await store.getState();
|
||||
if (!state) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
@@ -513,7 +575,10 @@ export const tournamentRouter = router({
|
||||
return { ok: true };
|
||||
});
|
||||
}),
|
||||
placeBet: authedProcedure
|
||||
// This route delegates its game mutations to durable ENGINE input events.
|
||||
// Wrapping it in the API input-event transaction would hold the clock
|
||||
// advisory lock while waiting for the daemon to claim the child event.
|
||||
placeBet: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
targetId: z.number().int().positive(),
|
||||
@@ -523,7 +588,7 @@ export const tournamentRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentBetClockMutation(ctx, store, async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 6) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||
@@ -558,6 +623,7 @@ export const tournamentRouter = router({
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
});
|
||||
@@ -573,6 +639,7 @@ export const tournamentRouter = router({
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
@@ -584,6 +651,7 @@ export const tournamentRouter = router({
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
@@ -600,11 +668,13 @@ export const tournamentRouter = router({
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveRefreshScoreText,
|
||||
resolveRemainingMinutes,
|
||||
} from '../../services/generalBasicCardProjection.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
@@ -72,7 +73,7 @@ export const troopRouter = router({
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
|
||||
}
|
||||
|
||||
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
||||
const [nation, troops, generals, cities, worldState, gameTime] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||
@@ -121,6 +122,7 @@ export const troopRouter = router({
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
@@ -214,15 +216,6 @@ export const troopRouter = router({
|
||||
const traitName = (code: string, names: Map<string, { name: string }>): string =>
|
||||
names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
|
||||
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||
const lastExecuted =
|
||||
rawLastExecuted instanceof Date
|
||||
? rawLastExecuted
|
||||
: typeof rawLastExecuted === 'string'
|
||||
? new Date(rawLastExecuted)
|
||||
: null;
|
||||
|
||||
const mappedTroops = troops
|
||||
.map((troop) => {
|
||||
const leader = generalMap.get(troop.troopLeaderId);
|
||||
@@ -348,8 +341,7 @@ export const troopRouter = router({
|
||||
killTurn: readNumber(meta.killturn ?? meta.killTurn),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
lastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
gameTime.now
|
||||
),
|
||||
troopId: general.troopId,
|
||||
troop: {
|
||||
|
||||
@@ -446,6 +446,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
nationGenerals,
|
||||
realNationCount: nations.filter((entry) => entry.id > 0).length,
|
||||
inputOptions,
|
||||
generalActionModules: moduleBundle.general,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -102,18 +102,16 @@ export const hasPollEnded = (
|
||||
time: CurrentGameTime
|
||||
): boolean =>
|
||||
Boolean(poll.closed_at) ||
|
||||
(poll.end_tick !== null && time.tick !== null
|
||||
? poll.end_tick < BigInt(time.tick)
|
||||
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
|
||||
Boolean(
|
||||
poll.end_at &&
|
||||
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
|
||||
);
|
||||
|
||||
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||
if (!date) return null;
|
||||
try {
|
||||
const tick = time.dateToTick(date);
|
||||
return tick === null ? null : BigInt(tick);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const tick = time.dateToTick(date);
|
||||
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
|
||||
return BigInt(tick);
|
||||
};
|
||||
|
||||
type VoteListRow = {
|
||||
@@ -358,7 +356,6 @@ export const voteRouter = router({
|
||||
voteId: input.voteId,
|
||||
generalId: general.id,
|
||||
selection: sortedSelection,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
throwIfCommandRejected(rewardResult);
|
||||
|
||||
@@ -399,8 +396,6 @@ export const voteRouter = router({
|
||||
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
||||
: null;
|
||||
const nationName = nation?.name ?? '재야';
|
||||
const createdAt = new Date();
|
||||
|
||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||
INSERT INTO vote_comment (
|
||||
vote_id,
|
||||
@@ -418,7 +413,7 @@ export const voteRouter = router({
|
||||
${general.name},
|
||||
${nationName},
|
||||
${input.text},
|
||||
${createdAt}
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -451,7 +446,6 @@ export const voteRouter = router({
|
||||
if (endAt && endAt < gameTime.now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||
}
|
||||
const operationalAt = new Date();
|
||||
|
||||
let multipleOptions = input.multipleOptions;
|
||||
if (multipleOptions < 0) {
|
||||
@@ -464,7 +458,8 @@ export const voteRouter = router({
|
||||
if (input.closePrevious) {
|
||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
|
||||
SET closed_at = ${gameTime.now},
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE closed_at IS NULL
|
||||
`);
|
||||
}
|
||||
@@ -497,8 +492,8 @@ export const voteRouter = router({
|
||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||
${endAt},
|
||||
${toGameTickOrNull(gameTime, endAt)},
|
||||
${operationalAt},
|
||||
${operationalAt}
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -573,7 +568,6 @@ export const voteRouter = router({
|
||||
if (endAt && endAt < gameTime.now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||
}
|
||||
const updatedAt = new Date();
|
||||
|
||||
if (
|
||||
input.title === undefined &&
|
||||
@@ -596,7 +590,7 @@ export const voteRouter = router({
|
||||
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
||||
end_at = ${endAt ?? poll.end_at},
|
||||
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
||||
updated_at = ${updatedAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${input.voteId}
|
||||
`);
|
||||
|
||||
@@ -609,10 +603,10 @@ export const voteRouter = router({
|
||||
.input(z.object({ voteId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const updatedAt = new Date();
|
||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
|
||||
SET closed_at = ${gameTime.now},
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${input.voteId}
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
@@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
const zDirectorySort = z.number().int().min(1).max(15).default(9);
|
||||
|
||||
const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
|
||||
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
|
||||
return left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => {
|
||||
if (leadership < 40) {
|
||||
return strength + intel < 40 ? '무능' : '무지';
|
||||
}
|
||||
|
||||
const maxStat = Math.max(leadership, strength, intel);
|
||||
const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership);
|
||||
if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) {
|
||||
return '만능';
|
||||
}
|
||||
if (strength >= 60 && intel < strength * 0.8) {
|
||||
return '무';
|
||||
}
|
||||
if (intel >= 60 && strength < intel * 0.8) {
|
||||
return '지';
|
||||
}
|
||||
if (leadership >= 60 && strength + intel < leadership) {
|
||||
return '통';
|
||||
}
|
||||
return '평범';
|
||||
};
|
||||
|
||||
const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
|
||||
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
|
||||
return Math.max(0, Math.min(level, maxLevel));
|
||||
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
|
||||
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
|
||||
const [nations, generals, cities] = await Promise.all([
|
||||
const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
meta: true,
|
||||
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
select: { id: true, name: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
select: { generalId: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: { tickSeconds: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const directoryNations = nations.some((nation) => nation.id === 0)
|
||||
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
}
|
||||
const citiesByNation = new Map<number, typeof cities>();
|
||||
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
|
||||
const accessScoreByGeneralId = new Map(
|
||||
accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const)
|
||||
);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const autorunUser = asRecord(worldMeta.autorun_user);
|
||||
const worldKillturn = readNumber(worldMeta.killturn);
|
||||
const turnMinutes = readNumber(worldState?.tickSeconds) / 60;
|
||||
const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0;
|
||||
const activeKillturnThreshold = worldKillturn - autorunLimitTurns;
|
||||
for (const city of cities) {
|
||||
const list = citiesByNation.get(city.nationId) ?? [];
|
||||
list.push(city);
|
||||
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
false
|
||||
),
|
||||
}));
|
||||
const analyzedGenerals = nationGenerals.map((general) => {
|
||||
const killturn = readMetaNumber(general.meta, 'killturn');
|
||||
const inactive = general.npcState < 2 && killturn < activeKillturnThreshold;
|
||||
const accessScore = accessScoreByGeneralId.get(general.id) ?? 0;
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
leadership: general.leadership,
|
||||
type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel),
|
||||
inactive,
|
||||
accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal',
|
||||
killturn,
|
||||
} as const;
|
||||
});
|
||||
const combatGenerals = analyzedGenerals.filter(
|
||||
(general) => general.type !== '무능' && general.type !== '무지'
|
||||
);
|
||||
const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive);
|
||||
const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5);
|
||||
const generalGroups = directoryGeneralTypes
|
||||
.map((type) => ({
|
||||
type,
|
||||
label: `${type}장`,
|
||||
generals: analyzedGenerals
|
||||
.filter((general) => general.type === type)
|
||||
.sort((left, right) => {
|
||||
const leftScore = accessScoreByGeneralId.get(left.id) ?? 0;
|
||||
const rightScore = accessScoreByGeneralId.get(right.id) ?? 0;
|
||||
return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id;
|
||||
})
|
||||
.map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general),
|
||||
}))
|
||||
.filter((group) => group.generals.length > 0);
|
||||
|
||||
return {
|
||||
id: nation.id,
|
||||
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
|
||||
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
|
||||
cityCount: nationCities.length,
|
||||
forceEstimate: {
|
||||
totalGeneralCount: analyzedGenerals.length,
|
||||
userCombatGeneralCount: userCombatGenerals.length,
|
||||
userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
|
||||
npcCombatGeneralCount: npcCombatGenerals.length,
|
||||
npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
|
||||
inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length,
|
||||
},
|
||||
generalGroups,
|
||||
officers,
|
||||
ambassadorNames: secretPermissions
|
||||
.filter(({ permission }) => permission === 4)
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||
import { scopeHttpIdempotencyKey } from './requestId.js';
|
||||
import { loadClockReadiness } from './services/clockReadiness.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -420,12 +421,17 @@ export const createGameApiServer = async () => {
|
||||
request.raw.on('aborted', close);
|
||||
});
|
||||
|
||||
app.get('/healthz', async () => ({
|
||||
ok: true,
|
||||
profile: config.profileName,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
}));
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
const clock = await loadClockReadiness(postgres.prisma);
|
||||
if (!clock.reconciliationComplete) reply.code(503);
|
||||
return {
|
||||
ok: clock.reconciliationComplete,
|
||||
profile: config.profileName,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
clock,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await realtimeHub.start();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
const safeInteger = (value: bigint, label: string): number => {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const loadClockReadiness = async (db: DatabaseClient) => {
|
||||
if (!db.clockProjectionOutbox) {
|
||||
return {
|
||||
reconciliationComplete: false,
|
||||
gameplayEnabled: false,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
incompleteOutboxCount: null,
|
||||
};
|
||||
}
|
||||
const [world, incompleteOutboxCount] = await Promise.all([
|
||||
db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
|
||||
}),
|
||||
db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }),
|
||||
]);
|
||||
if (!world) {
|
||||
return {
|
||||
reconciliationComplete: false,
|
||||
gameplayEnabled: false,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
incompleteOutboxCount,
|
||||
};
|
||||
}
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
return {
|
||||
reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
|
||||
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
|
||||
phase,
|
||||
revision: safeInteger(world.clockRevision, 'clock revision'),
|
||||
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
|
||||
incompleteOutboxCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
||||
const readiness = await loadClockReadiness(db);
|
||||
if (!db.clockSuspension) {
|
||||
return { ...readiness, latestReconciliation: null };
|
||||
}
|
||||
const latest = await db.clockSuspension.findFirst({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
participants: { orderBy: { participantKey: 'asc' } },
|
||||
projectionOutbox: { orderBy: { id: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!latest) return { ...readiness, latestReconciliation: null };
|
||||
return {
|
||||
...readiness,
|
||||
latestReconciliation: {
|
||||
id: latest.id,
|
||||
source: latest.source,
|
||||
policy: latest.policy,
|
||||
status: latest.status,
|
||||
sourceRevision: safeInteger(latest.sourceRevision, 'source revision'),
|
||||
targetRevision: safeInteger(latest.targetRevision, 'target revision'),
|
||||
cutTick: safeInteger(latest.cutTick, 'cut tick'),
|
||||
alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'),
|
||||
participantChecksumBefore: latest.participantChecksumBefore,
|
||||
participantChecksumAfter: latest.participantChecksumAfter,
|
||||
participants: latest.participants.map((participant) => ({
|
||||
key: participant.participantKey,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.beforeChecksum,
|
||||
afterChecksum: participant.afterChecksum,
|
||||
affectedCount: participant.affectedCount,
|
||||
})),
|
||||
outbox: latest.projectionOutbox.map((entry) => ({
|
||||
id: entry.id.toString(),
|
||||
targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'),
|
||||
status: entry.status,
|
||||
attempts: entry.attempts,
|
||||
lastError: entry.lastError,
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
|
||||
};
|
||||
|
||||
export const flushDeferredGeneralAccessBatch = async (
|
||||
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>,
|
||||
db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
|
||||
batchId: string,
|
||||
entries: readonly DeferredGeneralAccessEntry[]
|
||||
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
|
||||
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
await markBatchProcessed(db, batchId);
|
||||
return { states: [], refreshLimit: 0 };
|
||||
}
|
||||
if (!db.$transaction) {
|
||||
throw new Error('Deferred general access persistence requires transaction support.');
|
||||
}
|
||||
const worldState = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
}))
|
||||
);
|
||||
const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1;
|
||||
const states = await db.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
|
||||
const states = await db.$transaction(async (transaction) => {
|
||||
// The turn flush and synchronous access path must enter through the same
|
||||
// schema-scoped lock before either traffic or access rows are mutated.
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
return transaction.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
|
||||
WITH inserted_batch AS (
|
||||
INSERT INTO "general_access_batch" ("id")
|
||||
VALUES (${batchId})
|
||||
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
END,
|
||||
resolved."weight"
|
||||
FROM resolved
|
||||
ORDER BY resolved."general_id"
|
||||
ON CONFLICT ("general_id") DO UPDATE SET
|
||||
"user_id" = EXCLUDED."user_id",
|
||||
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
|
||||
@@ -394,7 +402,8 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
LEFT JOIN access_updates ON access_updates."general_id" = actor."id"
|
||||
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
|
||||
ORDER BY actor."id"
|
||||
`);
|
||||
`);
|
||||
});
|
||||
return {
|
||||
states,
|
||||
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { GameClock, type GameClockMode } from '@sammo-ts/common';
|
||||
import {
|
||||
GameClock,
|
||||
inferClockPhase,
|
||||
parseGameClockPhase,
|
||||
type GameClockMode,
|
||||
type GameClockPhase,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
@@ -7,6 +13,9 @@ export interface CurrentGameTime {
|
||||
wallNow: Date;
|
||||
tick: number | null;
|
||||
mode: GameClockMode | null;
|
||||
phase?: GameClockPhase | null;
|
||||
revision?: number | null;
|
||||
deadlineGeneration?: number | null;
|
||||
running: boolean;
|
||||
startsAt: Date | null;
|
||||
dateToTick(date: Date): number | null;
|
||||
@@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
wallNow,
|
||||
tick: null,
|
||||
mode: null,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
dateToTick: () => null,
|
||||
@@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
clockMode: true,
|
||||
clockWallAnchor: true,
|
||||
tickSeconds: true,
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
});
|
||||
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
||||
@@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
wallNow,
|
||||
tick: null,
|
||||
mode: null,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
dateToTick: () => null,
|
||||
};
|
||||
}
|
||||
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||
// During the dual-read migration an older profile (or a rolling-deploy
|
||||
// fixture) can lack clock_phase. Preserve the existing future-anchor
|
||||
// PREOPEN contract until every profile has the authoritative column.
|
||||
const phase = state.clockPhase
|
||||
? parseGameClockPhase(state.clockPhase)
|
||||
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
|
||||
? 'PREOPEN'
|
||||
: inferClockPhase(mode);
|
||||
const storedTick = Number(state.clockTick);
|
||||
if (!Number.isSafeInteger(storedTick)) {
|
||||
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
|
||||
}
|
||||
const revision = Number(state.clockRevision ?? 1n);
|
||||
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
|
||||
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
|
||||
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
|
||||
}
|
||||
const clock = new GameClock({
|
||||
baseTime: state.clockBaseTime,
|
||||
tick: storedTick,
|
||||
mode,
|
||||
wallAnchor: state.clockWallAnchor,
|
||||
turnSeconds: state.tickSeconds,
|
||||
phase,
|
||||
revision,
|
||||
});
|
||||
const tick = clock.nowTick(wallNow);
|
||||
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
|
||||
const running = phase === 'RUNNING' && mode === 'realtime';
|
||||
return {
|
||||
now: clock.tickToDate(tick),
|
||||
wallNow,
|
||||
tick,
|
||||
mode,
|
||||
phase,
|
||||
revision,
|
||||
deadlineGeneration,
|
||||
running,
|
||||
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
|
||||
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
|
||||
dateToTick: (date) => clock.dateToTick(date),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
|
||||
throw new Error('Traffic access persistence requires transaction support.');
|
||||
}
|
||||
await db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const periodKey = input.year * 12 + input.month - 1;
|
||||
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
|
||||
GamePrisma.sql`
|
||||
|
||||
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
|
||||
|
||||
export const resolveRemainingMinutes = (
|
||||
turnTime: Date,
|
||||
lastExecuted: Date | null,
|
||||
turnTermSeconds: number
|
||||
currentGameTime: Date | null
|
||||
): number | null => {
|
||||
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null;
|
||||
let nextTurnMillis = turnTime.getTime();
|
||||
if (nextTurnMillis < lastExecuted.getTime()) {
|
||||
nextTurnMillis += turnTermSeconds * 1_000;
|
||||
}
|
||||
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
|
||||
if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
|
||||
return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
|
||||
};
|
||||
|
||||
export interface NextTurnMonthOffsetInput {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { CurrentGameTime } from './gameClock.js';
|
||||
import type { GameClockPhase } from '@sammo-ts/common';
|
||||
|
||||
interface ClockFenceRedis {
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
const BOOTSTRAP_CLOCK_FENCE_SCRIPT = `
|
||||
local revision = redis.call('GET', KEYS[1])
|
||||
local generation = redis.call('GET', KEYS[2])
|
||||
local phase = redis.call('GET', KEYS[3])
|
||||
if not revision and not generation and not phase then
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
redis.call('SET', KEYS[2], ARGV[2])
|
||||
redis.call('SET', KEYS[3], ARGV[3])
|
||||
return 1
|
||||
end
|
||||
if revision == ARGV[1] and generation == ARGV[2] and phase == ARGV[3] then
|
||||
return 2
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
export interface ActiveRedisClockFence {
|
||||
activeRevisionKey: string;
|
||||
deadlineGenerationKey: string;
|
||||
phaseKey: string;
|
||||
revision: number;
|
||||
generation: number;
|
||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||
}
|
||||
|
||||
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
|
||||
|
||||
const ensureRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime,
|
||||
allowedPhases: readonly GameClockPhase[]
|
||||
): Promise<ActiveRedisClockFence | null> => {
|
||||
if (
|
||||
!gameTime.phase ||
|
||||
!allowedPhases.includes(gameTime.phase) ||
|
||||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
|
||||
!Number.isSafeInteger(gameTime.revision) ||
|
||||
!Number.isSafeInteger(gameTime.deadlineGeneration)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const phase: MutableProjectionPhase = gameTime.phase;
|
||||
const fence: ActiveRedisClockFence = {
|
||||
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
||||
phaseKey: `sammo:${profileName}:clock:phase`,
|
||||
revision: gameTime.revision!,
|
||||
generation: gameTime.deadlineGeneration!,
|
||||
phase,
|
||||
};
|
||||
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
|
||||
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
|
||||
arguments: [String(fence.revision), String(fence.generation), phase],
|
||||
});
|
||||
return Number(result) === 1 || Number(result) === 2 ? fence : null;
|
||||
};
|
||||
|
||||
export const ensureActiveRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
): Promise<ActiveRedisClockFence | null> => {
|
||||
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
|
||||
};
|
||||
|
||||
/**
|
||||
* User betting is allowed against a frozen tournament deadline while the game
|
||||
* clock is suspended. Stage progression and settlement continue to use the
|
||||
* RUNNING-only helper above.
|
||||
*/
|
||||
export const ensureBettingRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
): Promise<ActiveRedisClockFence | null> =>
|
||||
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
|
||||
@@ -1,32 +1,37 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
||||
|
||||
interface TurnDaemonLeaseSource {
|
||||
turnDaemonLease: {
|
||||
findUnique(input: {
|
||||
where: { profile: string };
|
||||
select: { leaseUntil: true };
|
||||
}): Promise<{ leaseUntil: Date } | null>;
|
||||
};
|
||||
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
|
||||
}
|
||||
|
||||
export const loadTurnEngineRunning = async (
|
||||
source: ProfileStatusSource | undefined,
|
||||
db: TurnDaemonLeaseSource,
|
||||
profileName: string,
|
||||
now = new Date()
|
||||
now?: Date
|
||||
): Promise<boolean | null> => {
|
||||
if (!source) return null;
|
||||
try {
|
||||
const status = await source.get(profileName);
|
||||
if (status === null) return null;
|
||||
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
||||
const lease = await db.turnDaemonLease.findUnique({
|
||||
where: { profile: profileName },
|
||||
select: { leaseUntil: true },
|
||||
});
|
||||
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
|
||||
const wallNow = now
|
||||
? GamePrisma.sql`${now}`
|
||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM turn_daemon_lease
|
||||
WHERE profile = ${profileName}
|
||||
AND lease_until > ${wallNow}
|
||||
) AS running
|
||||
`);
|
||||
return rows[0]?.running ?? false;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
|
||||
private readonly db: TurnDaemonLeaseSource,
|
||||
private readonly profileName: string,
|
||||
private readonly cacheMs = 2_000,
|
||||
private readonly now = () => Date.now()
|
||||
private readonly now = () => performance.now()
|
||||
) {}
|
||||
|
||||
get(): Promise<boolean | null> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
|
||||
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
|
||||
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
|
||||
`);
|
||||
const wallNow = rows[0]?.wallNow;
|
||||
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
|
||||
return new Date(wallNow);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushOutbox.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
await tx.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"lock_owner" = ${this.owner},
|
||||
"attempts" = "attempts" + 1
|
||||
WHERE "id" IN (${GamePrisma.join(ids)})
|
||||
`);
|
||||
return tx.webPushOutbox.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
|
||||
});
|
||||
|
||||
for (const event of claimed) {
|
||||
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
const expired = await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${event.id}
|
||||
AND "lock_owner" = ${this.owner}
|
||||
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
|
||||
`);
|
||||
if (expired > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||
`);
|
||||
} catch (error) {
|
||||
const attempts = event.attempts;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: {
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
||||
},
|
||||
});
|
||||
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = ${errorText}
|
||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||
`);
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
if (performance.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = performance.now() + 60_000;
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT "id"
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface TournamentKeys {
|
||||
sourceRevisionKey: string;
|
||||
sourceRevisionChannel: string;
|
||||
realtimeEventChannel: string;
|
||||
activeClockRevisionKey: string;
|
||||
deadlineGenerationKey: string;
|
||||
clockPhaseKey: string;
|
||||
}
|
||||
|
||||
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
@@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||
realtimeEventChannel: buildGameEventChannel(profileName),
|
||||
activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
||||
clockPhaseKey: `sammo:${profileName}:clock:phase`,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TournamentKeys } from './keys.js';
|
||||
@@ -37,8 +38,12 @@ const zTournamentState = z
|
||||
openMonth: z.number().int(),
|
||||
termSeconds: z.number(),
|
||||
nextAt: z.string(),
|
||||
nextTick: z.number().int().safe().optional(),
|
||||
clockRevision: z.number().int().positive().safe().optional(),
|
||||
deadlineGeneration: z.number().int().positive().safe().optional(),
|
||||
bettingId: z.number().int().optional(),
|
||||
bettingCloseAt: z.string().optional(),
|
||||
bettingCloseTick: z.number().int().safe().optional(),
|
||||
winnerId: z.number().int().optional(),
|
||||
bettingSettled: z.boolean().optional(),
|
||||
rewardSettled: z.boolean().optional(),
|
||||
@@ -131,12 +136,62 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export interface TournamentClockContext {
|
||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||
revision: number;
|
||||
deadlineGeneration: number;
|
||||
dateToTick(date: Date): number | null;
|
||||
}
|
||||
|
||||
const parseDeadlineTick = (value: string, context: TournamentClockContext, field: string): number => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`Tournament ${field} is not a valid instant.`);
|
||||
}
|
||||
const tick = context.dateToTick(date);
|
||||
if (tick === null || !Number.isSafeInteger(tick)) {
|
||||
throw new Error(`Tournament ${field} cannot be represented in the active game clock.`);
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({
|
||||
...state,
|
||||
nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'),
|
||||
clockRevision: context.revision,
|
||||
deadlineGeneration: context.deadlineGeneration,
|
||||
...(state.bettingCloseAt
|
||||
? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') }
|
||||
: { bettingCloseTick: undefined }),
|
||||
});
|
||||
|
||||
const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({
|
||||
activeRevisionKey: keys.activeClockRevisionKey,
|
||||
deadlineGenerationKey: keys.deadlineGenerationKey,
|
||||
phaseKey: keys.clockPhaseKey,
|
||||
revision: context.revision,
|
||||
deadlineGeneration: context.deadlineGeneration,
|
||||
phase: context.phase,
|
||||
});
|
||||
|
||||
export class TournamentStore {
|
||||
private clockContext: TournamentClockContext | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisClientLike,
|
||||
private readonly keys: TournamentKeys
|
||||
) {}
|
||||
|
||||
async withClockContext<T>(context: TournamentClockContext, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.clockContext;
|
||||
this.clockContext = context;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.clockContext = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
|
||||
if (!this.redis.del) {
|
||||
return operation();
|
||||
@@ -144,8 +199,8 @@ export class TournamentStore {
|
||||
|
||||
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
@@ -174,11 +229,15 @@ export class TournamentStore {
|
||||
}
|
||||
|
||||
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
|
||||
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]);
|
||||
const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined;
|
||||
return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence);
|
||||
}
|
||||
|
||||
async setState(state: TournamentState): Promise<string> {
|
||||
return this.writeWithSourceRevision(this.keys.stateKey, state);
|
||||
return this.writeWithSourceRevision(
|
||||
this.keys.stateKey,
|
||||
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
|
||||
);
|
||||
}
|
||||
|
||||
async getParticipants(): Promise<TournamentParticipantEntry[]> {
|
||||
|
||||
@@ -9,8 +9,12 @@ export interface TournamentState {
|
||||
openMonth: number;
|
||||
termSeconds: number;
|
||||
nextAt: string;
|
||||
nextTick?: number;
|
||||
clockRevision?: number;
|
||||
deadlineGeneration?: number;
|
||||
bettingId?: number;
|
||||
bettingCloseAt?: string;
|
||||
bettingCloseTick?: number;
|
||||
winnerId?: number;
|
||||
bettingSettled?: boolean;
|
||||
rewardSettled?: boolean;
|
||||
|
||||
@@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
|
||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
import { buildTournamentKeys } from './keys.js';
|
||||
import { TournamentStore } from './store.js';
|
||||
import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js';
|
||||
import type { TournamentMatchEntry, TournamentState } from './types.js';
|
||||
import {
|
||||
applyGroupMatch,
|
||||
@@ -595,41 +596,63 @@ export const processTournamentTick = async (options: {
|
||||
prisma: GamePrismaClient;
|
||||
daemonTransport: TurnDaemonTransport;
|
||||
now?: () => number;
|
||||
clockContext?: TournamentClockContext;
|
||||
}): Promise<TournamentState | null> => {
|
||||
const { store, prisma, daemonTransport } = options;
|
||||
const now = options.now ?? Date.now;
|
||||
let processedState: TournamentState | null = null;
|
||||
|
||||
await store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
return;
|
||||
}
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
|
||||
return;
|
||||
}
|
||||
const processWithLock = async (): Promise<void> =>
|
||||
store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options.clockContext &&
|
||||
(state.clockRevision !== options.clockContext.revision ||
|
||||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
|
||||
) {
|
||||
throw new Error('Tournament state does not match the active clock revision.');
|
||||
}
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const notDue = options.clockContext
|
||||
? !Number.isSafeInteger(state.nextTick) ||
|
||||
state.nextTick! > options.clockContext.dateToTick(new Date(now()))!
|
||||
: Number.isFinite(nextAt) && nextAt > now();
|
||||
if (state.auto && notDue) {
|
||||
if (options.clockContext && !Number.isSafeInteger(state.nextTick)) {
|
||||
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsSettlement(state)) {
|
||||
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||
return;
|
||||
}
|
||||
if (needsSettlement(state)) {
|
||||
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||
return;
|
||||
}
|
||||
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = state;
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
|
||||
}
|
||||
processedState =
|
||||
(await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
})) ?? nextState;
|
||||
});
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = state;
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
|
||||
}
|
||||
processedState =
|
||||
(await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
})) ?? nextState;
|
||||
});
|
||||
|
||||
if (options.clockContext) {
|
||||
await store.withClockContext(options.clockContext, processWithLock);
|
||||
} else {
|
||||
await processWithLock();
|
||||
}
|
||||
|
||||
return processedState;
|
||||
};
|
||||
@@ -656,16 +679,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
const state = await store.getState();
|
||||
let state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma);
|
||||
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
const clockFence = gameTime.phase
|
||||
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
|
||||
: null;
|
||||
if (gameTime.phase && !clockFence) {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
const clockContext: TournamentClockContext | undefined = clockFence
|
||||
? {
|
||||
phase: 'RUNNING',
|
||||
revision: clockFence.revision,
|
||||
deadlineGeneration: clockFence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
}
|
||||
: undefined;
|
||||
if (
|
||||
clockContext &&
|
||||
(!Number.isSafeInteger(state.nextTick) ||
|
||||
state.clockRevision === undefined ||
|
||||
state.deadlineGeneration === undefined)
|
||||
) {
|
||||
state = stampTournamentClock(state, clockContext);
|
||||
await store.withClockContext(clockContext, () => store.setState(state!));
|
||||
}
|
||||
if (
|
||||
clockContext &&
|
||||
(state.clockRevision !== clockContext.revision ||
|
||||
state.deadlineGeneration !== clockContext.deadlineGeneration)
|
||||
) {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime();
|
||||
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) {
|
||||
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow));
|
||||
const gameNow = gameTime.now.getTime();
|
||||
const notDue = clockContext
|
||||
? state.nextTick! > gameTime.tick!
|
||||
: Number.isFinite(nextAt) && nextAt > gameNow;
|
||||
if (state.auto && notDue) {
|
||||
await waitForWorkerPoll(
|
||||
control.signal,
|
||||
Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -675,6 +742,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
||||
prisma: postgres.prisma,
|
||||
daemonTransport,
|
||||
now: () => gameNow,
|
||||
clockContext,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -700,7 +768,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
||||
lastError: message,
|
||||
lastErrorAt: failedAt,
|
||||
};
|
||||
await store.setState(nextState);
|
||||
if (clockContext) {
|
||||
await store.withClockContext(clockContext, () => store.setState(nextState));
|
||||
} else {
|
||||
await store.setState(nextState);
|
||||
}
|
||||
}
|
||||
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
|
||||
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
|
||||
|
||||
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
||||
const scheduled = new Date(state.nextAt).getTime();
|
||||
return Number.isFinite(scheduled) ? scheduled : Date.now();
|
||||
if (!Number.isFinite(scheduled)) {
|
||||
throw new Error('Tournament GAME_TIME schedule is invalid.');
|
||||
}
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
export const resolveNextAt = (state: TournamentState): string =>
|
||||
|
||||
+71
-52
@@ -62,61 +62,68 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
||||
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
||||
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
||||
|
||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
}
|
||||
const createInputEventMiddleware = (acquireClockFence: boolean) =>
|
||||
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||
const payload = await getRawInput();
|
||||
const changeJournal = new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||
try {
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId,
|
||||
eventType: path,
|
||||
payload,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
execute: async (transaction) => {
|
||||
const result = await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
db: transaction,
|
||||
changeJournal,
|
||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
|
||||
executedResult = result;
|
||||
return result.data;
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
if (executedResult) {
|
||||
return executedResult;
|
||||
}
|
||||
return {
|
||||
marker: middlewareMarker,
|
||||
ok: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateInputEventError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: error.message,
|
||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||
const payload = await getRawInput();
|
||||
const changeJournal = new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||
try {
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId,
|
||||
eventType: path,
|
||||
payload,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
acquireClockFence,
|
||||
execute: async (transaction) => {
|
||||
const result = await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
db: transaction,
|
||||
changeJournal,
|
||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
journalPersisted = Boolean(
|
||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||
);
|
||||
executedResult = result;
|
||||
return result.data;
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
if (executedResult) {
|
||||
return executedResult;
|
||||
}
|
||||
return {
|
||||
marker: middlewareMarker,
|
||||
ok: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateInputEventError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const inputEventMiddleware = createInputEventMiddleware(true);
|
||||
const wallInputEventMiddleware = createInputEventMiddleware(false);
|
||||
|
||||
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
||||
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
||||
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const wallAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(wallInputEventMiddleware);
|
||||
|
||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.input(input)
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(wallInputEventMiddleware);
|
||||
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface TurnCommandInputField {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: TurnCommandOptionValue | boolean;
|
||||
constValue?: TurnCommandOptionValue;
|
||||
options?: TurnCommandOption[];
|
||||
optionSource?: TurnCommandOptionSource;
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
TriggerValue,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
||||
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
@@ -26,6 +26,7 @@ import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/log
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
||||
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
|
||||
import {
|
||||
buildTurnCommandInputFields,
|
||||
loadTurnCommandSpecs,
|
||||
@@ -40,6 +41,7 @@ export interface TurnCommandAvailability {
|
||||
key: string;
|
||||
name: string;
|
||||
turnDurationText?: string;
|
||||
costText?: string;
|
||||
reqArg: boolean;
|
||||
possible: boolean;
|
||||
status: AvailabilityStatus;
|
||||
@@ -326,9 +328,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
|
||||
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
const config = asRecord(worldState.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const meta = asRecord(worldState.meta);
|
||||
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
|
||||
return {
|
||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
// daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
|
||||
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
|
||||
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
||||
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
||||
@@ -554,7 +559,10 @@ export const buildRecruitmentCommandInfo = (options: {
|
||||
avoid: crewType.avoid,
|
||||
baseCost: displayCost.gold,
|
||||
baseRice: displayCost.rice,
|
||||
info: [...crewType.info],
|
||||
info: [
|
||||
...crewType.info,
|
||||
...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean),
|
||||
],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -646,6 +654,40 @@ const evaluateDefinition = (
|
||||
return evaluateAvailability(constraints, ctx, view, reqArg);
|
||||
};
|
||||
|
||||
const readNextAvailableTurn = (meta: Readonly<Record<string, unknown>>, actionName: string): number | null => {
|
||||
const raw = meta[`next_execute_${actionName}`];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const evaluateCooldown = (
|
||||
definition: GeneralActionDefinition,
|
||||
scope: 'general' | 'nation',
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
currentYearMonth: number
|
||||
): AvailabilityCore | null => {
|
||||
const owner =
|
||||
scope === 'general'
|
||||
? (view.get({ kind: 'general', id: ctx.actorId }) as General | null)
|
||||
: ctx.nationId === undefined
|
||||
? null
|
||||
: (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null);
|
||||
if (!owner) return null;
|
||||
|
||||
const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name);
|
||||
if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null;
|
||||
return {
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`,
|
||||
};
|
||||
};
|
||||
|
||||
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
|
||||
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
|
||||
|
||||
@@ -656,18 +698,33 @@ const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위
|
||||
const buildEntries = (
|
||||
env: CommandEnv,
|
||||
specs: TurnCommandSpec[],
|
||||
options: { foundingAvailable?: boolean } = {}
|
||||
options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
|
||||
): CommandEntry[] => {
|
||||
const entries: CommandEntry[] = [];
|
||||
|
||||
for (const spec of specs) {
|
||||
const definition = spec.createDefinition(env);
|
||||
const inputFields = buildTurnCommandInputFields(spec).map((field) => {
|
||||
if (spec.key !== 'che_불가침제의') return field;
|
||||
if (field.key === 'year' && options.currentYear !== undefined) {
|
||||
return {
|
||||
...field,
|
||||
min: options.currentYear + 1,
|
||||
max: resolveNonAggressionMaxEndYear(options.currentYear),
|
||||
defaultValue: options.currentYear + 1,
|
||||
};
|
||||
}
|
||||
if (field.key === 'month' && options.currentMonth !== undefined) {
|
||||
return { ...field, defaultValue: options.currentMonth };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
const entry: CommandEntry = {
|
||||
category: spec.category,
|
||||
definition,
|
||||
reqArg: spec.reqArg,
|
||||
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
|
||||
inputFields: buildTurnCommandInputFields(spec),
|
||||
inputFields,
|
||||
};
|
||||
|
||||
if (spec.key === 'che_포상') {
|
||||
@@ -711,23 +768,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
|
||||
return preReqTurn > 0 ? `${preReqTurn + 1}턴` : undefined;
|
||||
};
|
||||
|
||||
const formatCost = (gold: number | undefined, rice: number | undefined): string | undefined => {
|
||||
const parts = [
|
||||
gold && gold > 0 ? `금 ${gold.toLocaleString('ko-KR')}` : null,
|
||||
rice && rice > 0 ? `쌀 ${rice.toLocaleString('ko-KR')}` : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
};
|
||||
|
||||
const getCommandCostText = (
|
||||
entry: CommandEntry,
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
env: CommandEnv
|
||||
): string | undefined => {
|
||||
const hint = entry.definition.getCostHint?.(ctx, view);
|
||||
if (hint?.formula) return hint.formula;
|
||||
const hintedCost = hint ? formatCost(hint.gold, hint.rice) : undefined;
|
||||
if (hintedCost) return hintedCost;
|
||||
|
||||
const develCost = env.develCost;
|
||||
const general = view.get({ kind: 'general', id: ctx.actorId }) as General | null;
|
||||
switch (entry.definition.key) {
|
||||
case 'che_단련':
|
||||
case 'che_숙련전환':
|
||||
return formatCost(develCost, develCost);
|
||||
case 'che_첩보':
|
||||
return formatCost(develCost * 3, develCost * 3);
|
||||
case 'che_이동':
|
||||
case 'che_인재탐색':
|
||||
return formatCost(develCost, 0);
|
||||
case 'che_강행':
|
||||
return formatCost(develCost * 5, 0);
|
||||
case 'che_사기진작':
|
||||
return formatCost(Math.round((general?.crew ?? 0) / 100), 0);
|
||||
case 'che_출병':
|
||||
return formatCost(0, Math.round((general?.crew ?? 0) / 100));
|
||||
case 'che_천도':
|
||||
return `금·쌀 ${(develCost * 5).toLocaleString('ko-KR')} × 2^거리`;
|
||||
case 'cr_인구이동':
|
||||
return `금·쌀 ${develCost.toLocaleString('ko-KR')} × 인구[만]`;
|
||||
case 'che_증축': {
|
||||
const cost = develCost * 500 + 60_000;
|
||||
return formatCost(cost, cost);
|
||||
}
|
||||
case 'che_감축': {
|
||||
const recovery = develCost * 500 + 30_000;
|
||||
return `금 ${recovery.toLocaleString('ko-KR')} · 쌀 ${recovery.toLocaleString('ko-KR')} 회수`;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const buildGroups = (
|
||||
entries: CommandEntry[],
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
includeTurnDuration = false
|
||||
includeTurnDuration = false,
|
||||
env: CommandEnv,
|
||||
scope: 'general' | 'nation',
|
||||
currentYearMonth: number
|
||||
): TurnCommandGroup[] => {
|
||||
const groups = new Map<string, TurnCommandAvailability[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const availability = entry.evaluate
|
||||
const baseAvailability = entry.evaluate
|
||||
? entry.evaluate(ctx, view)
|
||||
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
|
||||
const availability =
|
||||
baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown'
|
||||
? baseAvailability
|
||||
: (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability);
|
||||
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
|
||||
const costText = getCommandCostText(entry, ctx, view, env);
|
||||
const value: TurnCommandAvailability = {
|
||||
key: entry.definition.key,
|
||||
name: entry.definition.name,
|
||||
...(turnDurationText ? { turnDurationText } : {}),
|
||||
...(costText ? { costText } : {}),
|
||||
reqArg: entry.reqArg,
|
||||
inputFields: entry.inputFields,
|
||||
...availability,
|
||||
@@ -785,6 +904,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
|
||||
realNationCount?: number;
|
||||
inputOptions?: TurnCommandInputOptions;
|
||||
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||
}): Promise<TurnCommandTable> => {
|
||||
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
||||
const general = mapGeneralRow(options.general);
|
||||
@@ -803,7 +923,10 @@ export const buildTurnCommandTable = async (options: {
|
||||
mode: 'precheck',
|
||||
};
|
||||
|
||||
const env = buildCommandEnv(options.worldState);
|
||||
const env = {
|
||||
...buildCommandEnv(options.worldState),
|
||||
...(options.generalActionModules ? { generalActionModules: options.generalActionModules } : {}),
|
||||
};
|
||||
const scenarioConst = asRecord(options.worldState.config).const;
|
||||
const {
|
||||
general: generalSpecs,
|
||||
@@ -817,19 +940,30 @@ export const buildTurnCommandTable = async (options: {
|
||||
? undefined
|
||||
: options.realNationCount < resolveMaxNation(options.worldState),
|
||||
});
|
||||
const nationEntries = buildEntries(env, nationSpecs);
|
||||
const nationEntries = buildEntries(env, nationSpecs, {
|
||||
currentYear: options.worldState.currentYear,
|
||||
currentMonth: options.worldState.currentMonth,
|
||||
});
|
||||
const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1;
|
||||
|
||||
return {
|
||||
general: buildGroups(
|
||||
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view
|
||||
view,
|
||||
false,
|
||||
env,
|
||||
'general',
|
||||
currentYearMonth
|
||||
),
|
||||
nation: buildGroups(
|
||||
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view,
|
||||
true
|
||||
true,
|
||||
env,
|
||||
'nation',
|
||||
currentYearMonth
|
||||
),
|
||||
inputOptions: options.inputOptions ?? {
|
||||
cities: [],
|
||||
|
||||
@@ -56,27 +56,36 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
}): RefGeneralTargetOptions => {
|
||||
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
|
||||
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
|
||||
const cityName = options.cityNames.get(entry.cityId) ?? '재야';
|
||||
const troopLabel = entry.troopId
|
||||
? `${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
|
||||
: '부대 없음';
|
||||
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
|
||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||
const label = (() => {
|
||||
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
|
||||
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||
})();
|
||||
const details = [
|
||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||
entry.troopId
|
||||
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
|
||||
: '탑승 부대 없음',
|
||||
action === 'che_발령' || action === 'che_포상' || action === 'che_몰수'
|
||||
? null
|
||||
: entry.troopId
|
||||
? `탑승 부대 ${troopLabel}`
|
||||
: '탑승 부대 없음',
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (isTroopExit) {
|
||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||
}
|
||||
return {
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
|
||||
options.cityNames.get(entry.cityId) ?? '재야'
|
||||
})`,
|
||||
label,
|
||||
description: details.join(' · '),
|
||||
...(availableNow === undefined ? {} : { availableNow }),
|
||||
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
||||
|
||||
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-07-31T10:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'MANUAL',
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: { killturn: 24, scenarioMeta },
|
||||
};
|
||||
const lifecycleGeneral: TurnGeneral = {
|
||||
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
|
||||
currentYear: lifecycleState.currentYear,
|
||||
currentMonth: lifecycleState.currentMonth,
|
||||
tickSeconds: lifecycleState.tickSeconds,
|
||||
clockBaseTime: lifecycleState.clockBaseTime,
|
||||
clockTick: BigInt(lifecycleState.clockTick ?? 0),
|
||||
clockMode: lifecycleState.clockMode ?? 'manual',
|
||||
clockWallAnchor: lifecycleState.clockWallAnchor,
|
||||
lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0),
|
||||
clockPhase: lifecycleState.clockPhase ?? 'MANUAL',
|
||||
clockRevision: BigInt(lifecycleState.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -96,6 +96,7 @@ const buildContext = (options: {
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
closeTick: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -103,6 +104,7 @@ const buildContext = (options: {
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
closeTick: 200,
|
||||
};
|
||||
});
|
||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||
@@ -112,14 +114,10 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick ?? 100),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
tick: 72_000_001,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
closeTick: 200n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
detail: { amount: 100 },
|
||||
status,
|
||||
closeAt,
|
||||
closeTick: 0n,
|
||||
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
||||
},
|
||||
});
|
||||
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
return auction;
|
||||
};
|
||||
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
||||
buildAuctionFinalizeRequestId(auction.id, {
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
|
||||
if (auction.closeTick === null || auction.closeTick === undefined) {
|
||||
throw new Error(`auction ${auction.id} fixture requires closeTick`);
|
||||
}
|
||||
return buildAuctionFinalizeRequestId(auction.id, {
|
||||
closeAt: auction.closeAt,
|
||||
closeTick: auction.closeTick ?? null,
|
||||
closeTick: auction.closeTick,
|
||||
});
|
||||
};
|
||||
|
||||
const memoryRedis = () => ({
|
||||
zRangeByScore: vi.fn(async () => []),
|
||||
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
|
||||
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: auction.id,
|
||||
expectedCloseAt: auction.closeAt.toISOString(),
|
||||
expectedCloseTick: Number(auction.closeTick),
|
||||
},
|
||||
status: 'FAILED',
|
||||
attempts: 3,
|
||||
error: 'simulated terminal failure',
|
||||
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
||||
});
|
||||
|
||||
it('creates a new generation after an earlier close was extended', async () => {
|
||||
const auction = await createAuction('OPEN');
|
||||
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`;
|
||||
const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
|
||||
await connector.prisma.inputEvent.create({
|
||||
data: {
|
||||
requestId: priorRequestId,
|
||||
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-07-31T11:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: { killturn: 24, scenarioMeta },
|
||||
};
|
||||
const buildGeneral = (options: {
|
||||
@@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
clockBaseTime: state.clockBaseTime,
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
clockMode: state.clockMode ?? 'realtime',
|
||||
clockWallAnchor: state.clockWallAnchor,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
||||
clockPhase: state.clockPhase ?? 'RUNNING',
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
@@ -415,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
amount: 200,
|
||||
eventId: `auction-durable-bid:${auction.id}`,
|
||||
eventAt: new Date(),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date(),
|
||||
},
|
||||
});
|
||||
const requestId = requestIdFor(auction);
|
||||
@@ -425,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
});
|
||||
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
@@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
detail: { remainCloseDateExtensionCnt: 1 },
|
||||
status: 'OPEN',
|
||||
closeAt: logicalPastCloseAt,
|
||||
closeTick: 0n,
|
||||
},
|
||||
});
|
||||
extensionAuctionId = extensionAuction.id;
|
||||
@@ -505,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
amount: 50,
|
||||
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
||||
eventAt: new Date(),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date(),
|
||||
meta: { tryExtendCloseDate: true },
|
||||
},
|
||||
});
|
||||
@@ -516,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(extensionAuction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
});
|
||||
|
||||
let reopened: { status: string; closeAt: Date } | null = null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||
import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
||||
|
||||
const buildRedis = () => ({
|
||||
@@ -33,7 +33,7 @@ const buildDb = (options: {
|
||||
$executeRaw: vi.fn(async () => options.updated),
|
||||
auction: {
|
||||
findUnique: vi.fn(async () =>
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
|
||||
),
|
||||
},
|
||||
inputEvent: {
|
||||
@@ -55,6 +55,38 @@ const buildDb = (options: {
|
||||
};
|
||||
|
||||
describe('auction worker clock-shift race', () => {
|
||||
it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => {
|
||||
const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) };
|
||||
await expect(
|
||||
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
|
||||
activeRevisionKey: 'clock-revision',
|
||||
deadlineGenerationKey: 'deadline-generation',
|
||||
phaseKey: 'clock-phase',
|
||||
revision: 4,
|
||||
generation: 8,
|
||||
})
|
||||
).resolves.toEqual(['7', '9']);
|
||||
expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), {
|
||||
keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'],
|
||||
arguments: ['4', '8', '123456', '100'],
|
||||
});
|
||||
expect(redis.zRangeByScore).not.toHaveBeenCalled();
|
||||
expect(redis.zRem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns no due member when the atomic Redis clock fence rejects the pop', async () => {
|
||||
const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) };
|
||||
await expect(
|
||||
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
|
||||
activeRevisionKey: 'clock-revision',
|
||||
deadlineGenerationKey: 'deadline-generation',
|
||||
phaseKey: 'clock-phase',
|
||||
revision: 4,
|
||||
generation: 8,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
const time = {
|
||||
@@ -201,11 +233,12 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 36_000_000,
|
||||
})
|
||||
).resolves.toBe('RESCHEDULED');
|
||||
|
||||
expect(redis.zAdd).toHaveBeenCalledTimes(1);
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]);
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -235,7 +268,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
@@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs,
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -318,6 +353,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -330,7 +366,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
@@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
@@ -359,6 +396,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
@@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: logicalNowMs,
|
||||
nowTick: 72_000_000,
|
||||
historyNowMs: operationalNowMs,
|
||||
});
|
||||
|
||||
@@ -388,12 +427,12 @@ describe('auction worker clock-shift race', () => {
|
||||
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const existingEvent = {
|
||||
requestId,
|
||||
target: 'ENGINE' as const,
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||
status: 'PENDING' as const,
|
||||
result: null,
|
||||
};
|
||||
@@ -411,6 +450,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -423,7 +463,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('creates one bounded successor after a terminal event failure', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const retryRequestId = `${requestId}:retry:1`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
@@ -433,7 +473,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||
status: 'FAILED',
|
||||
result: null,
|
||||
},
|
||||
@@ -448,6 +488,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId: retryRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -468,19 +510,23 @@ describe('auction worker clock-shift race', () => {
|
||||
|
||||
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
|
||||
const redis = buildRedis();
|
||||
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
||||
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const previousRequestId = 'auction:finalize:7:tick:36000000';
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: previousRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseTick: 36_000_000,
|
||||
},
|
||||
status: 'SUCCEEDED',
|
||||
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
||||
},
|
||||
@@ -495,6 +541,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -527,6 +575,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).rejects.toThrow('event insert failed');
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
|
||||
|
||||
describe('clock reconciliation readiness', () => {
|
||||
it('fails closed when the reconciliation schema is not available', async () => {
|
||||
const db = {} as DatabaseClient;
|
||||
await expect(loadClockReadiness(db)).resolves.toEqual({
|
||||
reconciliationComplete: false,
|
||||
gameplayEnabled: false,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
incompleteOutboxCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks readiness for RECONCILING or incomplete outbox state', async () => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockPhase: 'RECONCILING',
|
||||
clockRevision: 9n,
|
||||
deadlineGeneration: 4n,
|
||||
})),
|
||||
},
|
||||
clockProjectionOutbox: { count: vi.fn(async () => 1) },
|
||||
} as unknown as DatabaseClient;
|
||||
await expect(loadClockReadiness(db)).resolves.toMatchObject({
|
||||
reconciliationComplete: false,
|
||||
gameplayEnabled: false,
|
||||
phase: 'RECONCILING',
|
||||
revision: 9,
|
||||
deadlineGeneration: 4,
|
||||
incompleteOutboxCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes participant checksums and incomplete outbox detail to admins', async () => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 3n,
|
||||
deadlineGeneration: 2n,
|
||||
})),
|
||||
},
|
||||
clockProjectionOutbox: { count: vi.fn(async () => 0) },
|
||||
clockSuspension: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'maintenance-1',
|
||||
source: 'MAINTENANCE',
|
||||
policy: 'EXACT',
|
||||
status: 'APPLIED',
|
||||
sourceRevision: 2n,
|
||||
targetRevision: 3n,
|
||||
cutTick: 100n,
|
||||
alignedTick: 130n,
|
||||
participantChecksumBefore: 'before-all',
|
||||
participantChecksumAfter: 'after-all',
|
||||
participants: [
|
||||
{
|
||||
participantKey: 'general-turn',
|
||||
policy: 'SHIFT',
|
||||
beforeChecksum: 'before',
|
||||
afterChecksum: 'after',
|
||||
affectedCount: 2,
|
||||
},
|
||||
],
|
||||
projectionOutbox: [{ id: 8n, targetRevision: 3n, status: 'APPLIED', attempts: 1, lastError: null }],
|
||||
})),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
|
||||
await expect(loadClockAdminStatus(db)).resolves.toMatchObject({
|
||||
reconciliationComplete: true,
|
||||
latestReconciliation: {
|
||||
id: 'maintenance-1',
|
||||
participantChecksumBefore: 'before-all',
|
||||
participantChecksumAfter: 'after-all',
|
||||
participants: [{ key: 'general-turn', policy: 'SHIFT', affectedCount: 2 }],
|
||||
outbox: [{ id: '8', status: 'APPLIED' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,9 @@ const buildNation = (): NationRow =>
|
||||
}) as unknown as NationRow;
|
||||
|
||||
describe('buildTurnCommandTable', () => {
|
||||
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
|
||||
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
|
||||
|
||||
it('projects the general and chief reserved-turn categories and command order from Ref', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
@@ -211,6 +214,29 @@ describe('buildTurnCommandTable', () => {
|
||||
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
|
||||
expect.objectContaining({ turnDurationText: expect.any(String) })
|
||||
);
|
||||
const generalCosts = Object.fromEntries(
|
||||
table.general.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
|
||||
);
|
||||
expect(generalCosts).toMatchObject({
|
||||
che_농지개간: '금 100',
|
||||
che_기술연구: '금 100',
|
||||
che_정착장려: '쌀 200',
|
||||
che_주민선정: '쌀 200',
|
||||
che_화계: '금 500 · 쌀 500',
|
||||
che_첩보: '금 300 · 쌀 300',
|
||||
che_사기진작: '금 1',
|
||||
che_출병: '쌀 1',
|
||||
che_이동: '금 100',
|
||||
che_강행: '금 500',
|
||||
});
|
||||
const nationCosts = Object.fromEntries(
|
||||
table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
|
||||
);
|
||||
expect(nationCosts).toMatchObject({
|
||||
che_천도: '금·쌀 500 × 2^거리',
|
||||
che_증축: '금 110,000 · 쌀 110,000',
|
||||
che_감축: '금 80,000 · 쌀 80,000 회수',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects scenario-specific command categories instead of the default profile', async () => {
|
||||
@@ -239,6 +265,13 @@ describe('buildTurnCommandTable', () => {
|
||||
'che_물자조달',
|
||||
'cr_맹훈련',
|
||||
]);
|
||||
expect(
|
||||
Object.fromEntries(table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText])))
|
||||
).toMatchObject({
|
||||
cr_인구이동: '금·쌀 100 × 인구[만]',
|
||||
event_대검병연구: '금 50,000 · 쌀 50,000',
|
||||
event_화륜차연구: '금 100,000 · 쌀 100,000',
|
||||
});
|
||||
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
|
||||
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
|
||||
'휴식',
|
||||
@@ -256,6 +289,103 @@ describe('buildTurnCommandTable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks both speciality resets while their cooldown remains and opens them at the boundary month', async () => {
|
||||
const cooldownGeneral = {
|
||||
...buildGeneral(),
|
||||
specialCode: 'che_상재',
|
||||
special2Code: 'che_신산',
|
||||
meta: {
|
||||
killturn: 24,
|
||||
'next_execute_내정 특기 초기화': 36,
|
||||
'next_execute_전투 특기 초기화': '37',
|
||||
},
|
||||
} as GeneralRow;
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: cooldownGeneral,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
expect(findCommand(table, 'che_내정특기초기화')).toMatchObject({
|
||||
possible: true,
|
||||
status: 'available',
|
||||
});
|
||||
expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '1턴 더 기다려야 합니다',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: {
|
||||
...buildGeneral(),
|
||||
meta: {
|
||||
killturn: 24,
|
||||
'next_execute_내정 특기 초기화': 37,
|
||||
'next_execute_전투 특기 초기화': 37,
|
||||
},
|
||||
} as GeneralRow,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) {
|
||||
expect(findCommand(table, key)).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '특기가 없습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('projects a multi-turn nation command cooldown before target input', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: {
|
||||
...buildNation(),
|
||||
meta: { next_execute_피장파장: 37 },
|
||||
} as NationRow,
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
expect(findCommand(table, 'che_피장파장')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '1턴 더 기다려야 합니다',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects costs from the current world develcost used by command execution', async () => {
|
||||
const worldState = buildWorldState();
|
||||
(worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120;
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState,
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
const costs = Object.fromEntries(
|
||||
[...table.general, ...table.nation].flatMap(({ values }) =>
|
||||
values.map(({ key, costText }) => [key, costText])
|
||||
)
|
||||
);
|
||||
expect(costs).toMatchObject({
|
||||
che_농지개간: '금 120',
|
||||
che_화계: '금 600 · 쌀 600',
|
||||
che_천도: '금·쌀 600 × 2^거리',
|
||||
che_증축: '금 120,000 · 쌀 120,000',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
|
||||
const buildScenarioTable = async (scenarioId: number) => {
|
||||
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||
@@ -337,6 +467,27 @@ describe('buildTurnCommandTable', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('projects the Ref twenty-year non-aggression end-year window', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
const proposal = table.nation
|
||||
.flatMap((group) => group.values)
|
||||
.find((command) => command.key === 'che_불가침제의');
|
||||
|
||||
expect(proposal?.inputFields.find((field) => field.key === 'year')).toMatchObject({
|
||||
kind: 'number',
|
||||
min: 4,
|
||||
max: 23,
|
||||
defaultValue: 4,
|
||||
});
|
||||
expect(proposal?.inputFields.find((field) => field.key === 'month')).toMatchObject({ defaultValue: 1 });
|
||||
});
|
||||
|
||||
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||
buildTurnCommandTable({
|
||||
@@ -444,15 +595,17 @@ describe('buildTurnCommandTable', () => {
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
const appointment = table.general
|
||||
.flatMap((group) => group.values)
|
||||
.find((command) => command.key === 'che_임관');
|
||||
for (const commandKey of ['che_임관', 'che_등용']) {
|
||||
const command = table.general
|
||||
.flatMap((group) => group.values)
|
||||
.find((candidate) => candidate.key === commandKey);
|
||||
|
||||
expect(appointment).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
expect(command).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
|
||||
@@ -525,7 +678,11 @@ describe('buildTurnCommandTable', () => {
|
||||
magicCoef: 0,
|
||||
cost: 12,
|
||||
rice: 10,
|
||||
requirements: [{ type: 'ReqTech', tech: 2000 }],
|
||||
requirements: [
|
||||
{ type: 'ReqTech', tech: 2000 },
|
||||
{ type: 'ReqRegions', regions: ['중원'] },
|
||||
{ type: 'ReqCities', cities: ['TestCity'] },
|
||||
],
|
||||
attackCoef: {},
|
||||
defenceCoef: {},
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
@@ -583,7 +740,12 @@ describe('buildTurnCommandTable', () => {
|
||||
special: true,
|
||||
attack: 175,
|
||||
defence: 225,
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
info: [
|
||||
'강력하지만 기술이 필요합니다.',
|
||||
'기술력 2000 이상 필요',
|
||||
'중원 지역 소유시 가능',
|
||||
'TestCity 소유시 가능',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,19 @@ describe('Ref command general targets', () => {
|
||||
troopId: 3,
|
||||
description: expect.stringContaining('탑승 부대 청룡대'),
|
||||
});
|
||||
expect(detailed.generalTargets.che_포상?.[0]?.description).toContain('금 5,000 · 쌀 4,000 · 병력 1,000');
|
||||
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
|
||||
'본인 (부대 없음 · 업)',
|
||||
'부대원 (청룡대 · 업)',
|
||||
'부대장 (청룡대 (부대장) · 업)',
|
||||
]);
|
||||
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
|
||||
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
|
||||
'본인 (업)',
|
||||
'부대원 (업)',
|
||||
'부대장 (업)',
|
||||
]);
|
||||
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
|
||||
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
it('commits one complete ref-shaped general and survives a daemon reload', async () => {
|
||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
|
||||
expect(config.rules.allowDirectCreation).toBe(true);
|
||||
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
|
||||
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
|
||||
@@ -114,6 +114,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
|
||||
},
|
||||
}),
|
||||
},
|
||||
$transaction: db.$transaction.bind(db),
|
||||
$queryRaw: db.$queryRaw.bind(db),
|
||||
$executeRaw: db.$executeRaw.bind(db),
|
||||
};
|
||||
|
||||
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: vi.fn(async () => 1),
|
||||
};
|
||||
Object.assign(db, {
|
||||
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
|
||||
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
|
||||
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
|
||||
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
|
||||
expect(statement.sql).toContain('jsonb_to_recordset');
|
||||
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
|
||||
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
|
||||
expect(statement.sql).toContain('INSERT INTO "general_access_log"');
|
||||
expect(statement.sql).toContain('ORDER BY resolved."general_id"');
|
||||
expect(statement.sql).not.toContain('read_model_revision');
|
||||
expect(statement.sql).not.toContain('read_model_outbox');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -88,7 +87,7 @@ const storedLetter = {
|
||||
};
|
||||
|
||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
||||
const create = vi.fn(async () => ({ id: 9 }));
|
||||
const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
|
||||
let messageId = 100;
|
||||
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
||||
const db = {
|
||||
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
|
||||
textBrief: '<p><strong>공개</strong></p>',
|
||||
textDetail:
|
||||
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
||||
date: new Date('0185-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
|
||||
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
|
||||
);
|
||||
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
|
||||
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
|
||||
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
|
||||
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
|
||||
});
|
||||
|
||||
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
||||
|
||||
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
crew: 100,
|
||||
experience: 1_000,
|
||||
meta: { killturn: 3 },
|
||||
penalty: {},
|
||||
@@ -124,6 +125,9 @@ const globalGenerals = [
|
||||
name: '군주',
|
||||
dedication: 900,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
strength: 80,
|
||||
intel: 80,
|
||||
meta: { killturn: 4, owner_name: '통일유저' },
|
||||
experience: 10_000,
|
||||
}),
|
||||
@@ -132,6 +136,9 @@ const globalGenerals = [
|
||||
name: '외교관',
|
||||
dedication: 800,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 40,
|
||||
meta: { killturn: 2, permission: 'ambassador' },
|
||||
experience: 5_000,
|
||||
}),
|
||||
@@ -140,6 +147,9 @@ const globalGenerals = [
|
||||
name: '제재외교관',
|
||||
dedication: 700,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 40,
|
||||
intel: 80,
|
||||
meta: { killturn: 1, permission: 'ambassador' },
|
||||
penalty: { noTopSecret: true },
|
||||
experience: 2_000,
|
||||
@@ -149,6 +159,9 @@ const globalGenerals = [
|
||||
name: '조언자',
|
||||
dedication: 600,
|
||||
officerLevel: 1,
|
||||
leadership: 30,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
meta: { killturn: 5, permission: 'auditor' },
|
||||
experience: 1_000,
|
||||
}),
|
||||
@@ -161,6 +174,8 @@ const globalGenerals = [
|
||||
dedication: 500,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 65,
|
||||
meta: { killturn: 0 },
|
||||
experience: 20_000,
|
||||
}),
|
||||
@@ -221,7 +236,12 @@ const createContext = (
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
config: { const: { maxLevel: 100, maxDedLevel: 30 } },
|
||||
meta: { isUnited: options.isUnited ?? 0 },
|
||||
meta: {
|
||||
isUnited: options.isUnited ?? 0,
|
||||
killturn: 4,
|
||||
autorun_user: { limit_minutes: 60 },
|
||||
},
|
||||
tickSeconds: 3_600,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -279,6 +299,20 @@ describe('legacy global nation/general directories', () => {
|
||||
);
|
||||
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
||||
for (const result of results) {
|
||||
const serializedNationRows = JSON.stringify(result.nations);
|
||||
for (const privateField of [
|
||||
'totalCrew',
|
||||
'crew',
|
||||
'leadership',
|
||||
'strength',
|
||||
'intel',
|
||||
'killturn',
|
||||
'refreshScoreTotal',
|
||||
'meta',
|
||||
'penalty',
|
||||
]) {
|
||||
expect(serializedNationRows).not.toContain(`"${privateField}"`);
|
||||
}
|
||||
const serializedGeneralRows = JSON.stringify(result.generals);
|
||||
for (const privateField of [
|
||||
'userId',
|
||||
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
|
||||
id: 1,
|
||||
generalCount: 4,
|
||||
cityCount: 1,
|
||||
forceEstimate: {
|
||||
totalGeneralCount: 4,
|
||||
userCombatGeneralCount: 1,
|
||||
userTroops: 8_000,
|
||||
npcCombatGeneralCount: 0,
|
||||
npcTroops: 0,
|
||||
inactiveGeneralCount: 2,
|
||||
},
|
||||
ambassadorNames: ['군주', '외교관'],
|
||||
auditorCount: 1,
|
||||
});
|
||||
expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([
|
||||
['만능장', ['군주']],
|
||||
['무장', ['외교관']],
|
||||
['지장', ['제재외교관']],
|
||||
['무지장', ['조언자']],
|
||||
]);
|
||||
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
|
||||
expect(result[1]?.officers[0]).toMatchObject({
|
||||
officerLevel: 12,
|
||||
general: { id: 10, name: '군주' },
|
||||
});
|
||||
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
|
||||
expect(JSON.stringify(result)).not.toContain('totalCrew');
|
||||
});
|
||||
|
||||
it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => {
|
||||
const categoryStats = [
|
||||
['만능장', 80, 80, 80],
|
||||
['통장', 70, 20, 20],
|
||||
['무장', 70, 80, 40],
|
||||
['지장', 70, 40, 80],
|
||||
['평범장', 60, 50, 50],
|
||||
['무지장', 30, 50, 50],
|
||||
['무능장', 30, 10, 10],
|
||||
] as const;
|
||||
const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) =>
|
||||
directoryGeneral({
|
||||
id: 101 + index,
|
||||
name,
|
||||
nationId: 1,
|
||||
leadership,
|
||||
strength,
|
||||
intel,
|
||||
meta: { killturn: 4 },
|
||||
})
|
||||
);
|
||||
const [nation] = await appRouter
|
||||
.createCaller(
|
||||
createContext({
|
||||
directory: {
|
||||
nations: [nationRows[0]!],
|
||||
generals: categoryGenerals,
|
||||
cities: [{ id: 1, name: '허창', nationId: 1 }],
|
||||
},
|
||||
}).context
|
||||
)
|
||||
.world.getNationDirectory();
|
||||
|
||||
expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name));
|
||||
expect(nation?.forceEstimate).toEqual({
|
||||
totalGeneralCount: 7,
|
||||
userCombatGeneralCount: 5,
|
||||
userTroops: 35_000,
|
||||
npcCombatGeneralCount: 0,
|
||||
npcTroops: 0,
|
||||
inactiveGeneralCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
|
||||
|
||||
@@ -3,37 +3,47 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
||||
|
||||
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
|
||||
const buildDatabase = (
|
||||
mode: 'realtime' | 'manual' = 'realtime',
|
||||
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
|
||||
): DatabaseClient =>
|
||||
({
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
||||
clockTick: 36_000_000n,
|
||||
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
clockPhase: phase,
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
})),
|
||||
},
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
describe('current game time projection', () => {
|
||||
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => {
|
||||
it('projects negative realtime ticks until the future opening anchor', async () => {
|
||||
const db = buildDatabase();
|
||||
|
||||
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||
expect(preopen).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
now: new Date('2026-08-21T10:30:00.000Z'),
|
||||
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
tick: -108_000_000,
|
||||
mode: 'realtime',
|
||||
phase: 'PREOPEN',
|
||||
running: false,
|
||||
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||
const opened = await loadCurrentGameTime(
|
||||
buildDatabase('realtime', 'RUNNING'),
|
||||
new Date('2026-08-21T11:00:05.000Z')
|
||||
);
|
||||
expect(opened).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||
tick: 36_300_000,
|
||||
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||
tick: 300_000,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
});
|
||||
@@ -43,8 +53,8 @@ describe('current game time projection', () => {
|
||||
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
now: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tick: 0,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
});
|
||||
|
||||
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(3);
|
||||
|
||||
const lockStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(lockStatement.sql).toContain('pg_advisory_xact_lock');
|
||||
expect(lockStatement.values).toContain('general-access:persistence');
|
||||
|
||||
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
|
||||
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
|
||||
expect(periodStatement.values).toContain(now);
|
||||
expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||
|
||||
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
const memberStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
||||
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
|
||||
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
|
||||
expect(memberStatement.sql).toContain('SET online = traffic_period.online');
|
||||
|
||||
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
||||
const accessStatement = executeRaw.mock.calls[2]![0] as { sql: string; values: unknown[] };
|
||||
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(accessStatement.values).toContain(7);
|
||||
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
|
||||
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
||||
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(now);
|
||||
});
|
||||
|
||||
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
|
||||
@@ -322,18 +326,14 @@ describe('general access tracking', () => {
|
||||
: [{ id: 41 }];
|
||||
}),
|
||||
$executeRaw: vi.fn(async (query: unknown) => {
|
||||
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
|
||||
const sql = (query as { sql?: string }).sql ?? '';
|
||||
if (sql.includes('INSERT INTO input_event')) {
|
||||
events.push('input-event-create');
|
||||
}
|
||||
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async () => 0),
|
||||
inputEvent: {
|
||||
update: vi.fn(async (args: { data: { status: string } }) => {
|
||||
if (args.data.status === 'FAILED') events.push('input-event-failed');
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
general: {
|
||||
|
||||
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
|
||||
expect(resolveRefreshScoreText(12_800)).toBe('헐...');
|
||||
});
|
||||
|
||||
it('matches the Ref remaining-minute calculation and one-turn rollover', () => {
|
||||
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull();
|
||||
it('matches the Ref remaining-minute calculation against the current game clock', () => {
|
||||
const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:19:00.000Z'), currentGameTime)).toBe(0);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:20:00.000Z'), null)).toBeNull();
|
||||
});
|
||||
|
||||
it('moves the first reserved month only after the general turn bucket has passed', () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
|
||||
it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
|
||||
const persistedPayload = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
const currentCommand = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
userId: 'user-7',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
};
|
||||
const create = async () => {
|
||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||
};
|
||||
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
transport.sendCommand(currentCommand)
|
||||
).resolves.toBe('vote-reward');
|
||||
|
||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
...currentCommand,
|
||||
...changedIdentity,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ const createContext = (options: {
|
||||
troopLeaderAction?: string | null;
|
||||
refreshScore?: number;
|
||||
refreshScoreTotal?: number;
|
||||
gameClockNow?: Date;
|
||||
rankRows?: Array<{ generalId?: number; type: string; value: number }>;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
@@ -164,6 +165,10 @@ const createContext = (options: {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: { upgradeLimit: 20 } },
|
||||
clockBaseTime: options.gameClockNow ?? null,
|
||||
clockTick: options.gameClockNow ? 0n : null,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: options.gameClockNow ?? null,
|
||||
})),
|
||||
},
|
||||
logEntry: {
|
||||
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
|
||||
troopLeaderAction: '휴식',
|
||||
refreshScore: 3,
|
||||
refreshScoreTotal: 1_141,
|
||||
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
|
||||
retirementYear: 70,
|
||||
defenceTrain: 80,
|
||||
killTurn: 6,
|
||||
remainingMinutes: null,
|
||||
remainingMinutes: 7,
|
||||
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
|
||||
refreshScore: { current: 3, total: 1_141, text: '열심' },
|
||||
},
|
||||
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
|
||||
defence_train: 80,
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the removed automatic diplomacy setting before dispatching it to ENGINE', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).general.setMySetting({
|
||||
use_auto_nation_diplomacy: 1,
|
||||
} as never)
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends settings directly to ENGINE without creating an API input event', async () => {
|
||||
const transaction = vi.fn(async () => {
|
||||
throw new Error('API transaction must not run');
|
||||
|
||||
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'MANUAL',
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
||||
};
|
||||
|
||||
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: state.clockBaseTime,
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
clockMode: state.clockMode ?? 'manual',
|
||||
clockWallAnchor: state.clockWallAnchor,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
||||
clockPhase: state.clockPhase ?? 'MANUAL',
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -285,6 +285,21 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an out-of-range inheritance buff level with a user-facing message', async () => {
|
||||
const fixture = buildContext({});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
type: 'warAvoidRatio',
|
||||
level: 6,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
|
||||
const fixture = buildContext({
|
||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const journalGeneralIds = [9_980_081, 9_980_082] as const;
|
||||
const clockScenarioCode = 'input-event-boundary';
|
||||
|
||||
const journalBoundaryRouter = router({
|
||||
mutate: procedure
|
||||
@@ -56,6 +57,21 @@ integration('API input event boundary', () => {
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
|
||||
const base = new Date('2099-09-03T00:00:00.000Z');
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: clockScenarioCode,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: base,
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: base,
|
||||
clockPhase: 'RUNNING',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -68,6 +84,7 @@ integration('API input event boundary', () => {
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
|
||||
await close?.();
|
||||
});
|
||||
|
||||
@@ -518,6 +535,10 @@ integration('API input event boundary', () => {
|
||||
expect(event.actorUserId).toBe('user-7');
|
||||
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||
expect(event.acceptedGameTick).toBeNull();
|
||||
expect(event.acceptedClockRevision).toBeNull();
|
||||
expect(event.acceptedDeadlineGeneration).toBeNull();
|
||||
expect(event.processingGameTick).toBeNull();
|
||||
await expect(
|
||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
||||
).resolves.toBe(requestId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||
import { procedure, router } from '../src/trpc.js';
|
||||
import { procedure, router, wallProcedure } from '../src/trpc.js';
|
||||
|
||||
const testRouter = router({
|
||||
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
|
||||
@@ -14,6 +14,14 @@ const testRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const wallTestRouter = router({
|
||||
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
|
||||
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
|
||||
ctx.changeJournal?.mark('front.general', 7);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const createContext = (payload: unknown = {}) => {
|
||||
const order: string[] = [];
|
||||
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
||||
@@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => {
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
attempts: 0,
|
||||
acceptedGameTick: 100n,
|
||||
acceptedClockRevision: 3n,
|
||||
acceptedDeadlineGeneration: 2n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => {
|
||||
});
|
||||
const transaction = {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: vi.fn(async () => {
|
||||
order.push('accepted');
|
||||
$executeRaw: vi.fn(async (query: { sql?: string }) => {
|
||||
const sql = query.sql ?? '';
|
||||
order.push(
|
||||
sql.includes('pg_advisory_xact_lock')
|
||||
? 'clock-fence'
|
||||
: sql.includes("status = 'PROCESSING'")
|
||||
? 'processing'
|
||||
: sql.includes("status = 'SUCCEEDED'")
|
||||
? 'succeeded'
|
||||
: sql.includes("status = 'FAILED'")
|
||||
? 'failed'
|
||||
: 'accepted'
|
||||
);
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
||||
@@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => {
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'clock-fence',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
@@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => {
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'clock-fence',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
@@ -126,4 +150,24 @@ describe('API input-event change journal boundary', () => {
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
expect(fixture.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
|
||||
const fixture = createContext();
|
||||
|
||||
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
'savepoint',
|
||||
'handler',
|
||||
'journal',
|
||||
'succeeded',
|
||||
'savepoint-release',
|
||||
'commit',
|
||||
'wake',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
@@ -42,11 +42,16 @@ const buildContext = (
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
||||
},
|
||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||
} as unknown as DatabaseClient,
|
||||
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
describe('lobby season state', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||
@@ -96,15 +101,18 @@ describe('lobby season state', () => {
|
||||
expect(result.turnEngineRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
||||
it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
|
||||
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||
const wallNow = new Date('2099-08-21T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(wallNow);
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||
tick: 36_000_000n,
|
||||
baseTime: wallAnchor,
|
||||
tick: 0n,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
}
|
||||
@@ -112,13 +120,22 @@ describe('lobby season state', () => {
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z');
|
||||
expect(result.serverTime).toBe(wallNow.toISOString());
|
||||
expect(result.clockMode).toBe('realtime');
|
||||
expect(result.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
||||
});
|
||||
|
||||
it('reports direct creation and possession as independent acquisition modes', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
|
||||
.lobby.info();
|
||||
|
||||
expect(result.directGeneralCreationEnabled).toBe(false);
|
||||
expect(result.npcPossessionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves zero as the first official game index', async () => {
|
||||
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ const buildContext = (
|
||||
tickSeconds: 3600,
|
||||
...(options.preopenClock
|
||||
? {
|
||||
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
|
||||
clockTick: -180_000_000n,
|
||||
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
|
||||
}
|
||||
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
|
||||
});
|
||||
|
||||
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z'));
|
||||
vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
|
||||
const context = buildContext({ hasVoted: true, preopenClock: true });
|
||||
|
||||
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { tombstoneMessages } from '../src/messages/store.js';
|
||||
import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
@@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => {
|
||||
|
||||
afterAll(async () => close?.());
|
||||
|
||||
it('persists an ordinary wall-time envelope without creating a game action', async () => {
|
||||
const rollback = new Error('rollback ordinary message envelope fixture');
|
||||
await expect(
|
||||
db.$transaction(async (transaction) => {
|
||||
const target = {
|
||||
generalId: 7,
|
||||
generalName: '보낸이',
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
const id = await persistMessageEnvelope(
|
||||
transaction,
|
||||
{
|
||||
mailbox: 9999,
|
||||
msgType: 'public',
|
||||
srcId: target.generalId,
|
||||
destId: 9999,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
payload: {
|
||||
src: target,
|
||||
dest: target,
|
||||
text: '일반 메시지는 WALL_TIME envelope만 저장한다.',
|
||||
option: {},
|
||||
},
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const message = await transaction.message.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { action: true },
|
||||
});
|
||||
expect(message.createdAtWall).toBeInstanceOf(Date);
|
||||
expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000);
|
||||
expect(message.occurredGameTick).toBeNull();
|
||||
expect(message.action).toBeNull();
|
||||
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
|
||||
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
|
||||
const rollback = new Error('rollback message tombstone fixture');
|
||||
await expect(
|
||||
@@ -70,6 +115,7 @@ integration('message deletion tombstone persistence', () => {
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(row.validUntil).toEqual(validUntil);
|
||||
expect(row.tombstonedAtWall).not.toBeNull();
|
||||
expect(row.message).toMatchObject({
|
||||
text: '삭제된 메시지입니다.',
|
||||
option: { invalid: true },
|
||||
@@ -81,4 +127,43 @@ integration('message deletion tombstone persistence', () => {
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
|
||||
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
|
||||
const rollback = new Error('rollback wall deletion fixture');
|
||||
await expect(
|
||||
db.$transaction(async (transaction) => {
|
||||
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
`;
|
||||
const draft = (text: string) => ({
|
||||
mailbox: 7,
|
||||
type: 'private' as const,
|
||||
src: 7,
|
||||
dest: 8,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
createdAtWall: nowWall,
|
||||
message: {
|
||||
src: { generalId: 7 },
|
||||
dest: { generalId: 8 },
|
||||
text,
|
||||
option: {},
|
||||
},
|
||||
});
|
||||
const deletable = await transaction.message.create({
|
||||
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
|
||||
});
|
||||
const expired = await transaction.message.create({
|
||||
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
|
||||
});
|
||||
|
||||
expect(
|
||||
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
|
||||
).toEqual([deletable.id]);
|
||||
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
|
||||
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('national');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||
expect(queryRaw).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('journals committed message mailbox copies instead of publishing before commit', async () => {
|
||||
@@ -228,6 +228,84 @@ describe('messages router missing-flow compatibility', () => {
|
||||
expect(redis.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
|
||||
'keeps ordinary public messages available while the game clock is %s',
|
||||
async (clockPhase) => {
|
||||
const queryRaw = vi.fn(async () => [{ id: 52 }]);
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: queryRaw,
|
||||
worldState: { findFirst: vi.fn(async () => ({ clockPhase })) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` })
|
||||
).resolves.toMatchObject({ msgType: 'public' });
|
||||
expect(queryRaw).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['SUSPENDED', 'RECONCILING'] as const)(
|
||||
'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s',
|
||||
async (clockPhase) => {
|
||||
const scoutRow = {
|
||||
id: 54,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: 8,
|
||||
dest: general.id,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
created_at_wall: new Date('2026-09-03T15:00:00.000Z'),
|
||||
action_status: 'PENDING',
|
||||
expires_game_tick: 200n,
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '등용권유자',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
text: '등용 권유 서신',
|
||||
option: { action: 'scout' },
|
||||
},
|
||||
};
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: vi.fn(async () => [scoutRow]),
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
clockTick: 100n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
clockPhase,
|
||||
clockRevision: 9n,
|
||||
deadlineGeneration: 4n,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.messages.getRecent({ generalId: general.id });
|
||||
|
||||
expect(result.private[0]).toMatchObject({
|
||||
id: scoutRow.id,
|
||||
text: '등용 권유 서신',
|
||||
option: { action: 'scout' },
|
||||
time: '2026-09-03 15:00:00',
|
||||
});
|
||||
expect(result.private[0]?.option).not.toMatchObject({ invalid: true });
|
||||
}
|
||||
);
|
||||
|
||||
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||
const ambassador = {
|
||||
...general,
|
||||
@@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('diplomacy');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
|
||||
@@ -288,7 +366,6 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('diplomacy');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 9000 },
|
||||
@@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => {
|
||||
|
||||
expect(result.msgType).toBe('national');
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||
});
|
||||
|
||||
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
|
||||
@@ -555,44 +631,50 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
it('invalidates a recent owned message and its receiver copy', async () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: 21,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: general.id,
|
||||
dest: 8,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
let rawCall = 0;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
|
||||
return [
|
||||
{
|
||||
id: 21,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: general.id,
|
||||
dest: 8,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 8,
|
||||
generalName: '받는이',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '삭제할 메시지',
|
||||
option: { receiverMessageID: 22 },
|
||||
},
|
||||
dest: {
|
||||
generalId: 8,
|
||||
generalName: '받는이',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '삭제할 메시지',
|
||||
option: { receiverMessageID: 22 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
];
|
||||
});
|
||||
const changeJournal = new ChangeJournal();
|
||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||
|
||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
||||
|
||||
expect(result.deletedIds).toEqual([21, 22]);
|
||||
expect(executeRaw).toHaveBeenCalledOnce();
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
@@ -601,43 +683,49 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: 25,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9001,
|
||||
dest: 9002,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
let rawCall = 0;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall > 1) return [{ id: 25 }];
|
||||
return [
|
||||
{
|
||||
id: 25,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9001,
|
||||
dest: 9002,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '일반 외교 메시지',
|
||||
option: { receiverMessageID: 26 },
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '일반 외교 메시지',
|
||||
option: { receiverMessageID: 26 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
];
|
||||
});
|
||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||
|
||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||
|
||||
expect(result.deletedIds).toEqual([25]);
|
||||
expect(executeRaw).toHaveBeenCalledOnce();
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -713,8 +801,11 @@ describe('messages router missing-flow compatibility', () => {
|
||||
action,
|
||||
reason: 'success',
|
||||
}));
|
||||
const transaction = vi.fn(async () => {
|
||||
throw new Error('ENGINE message response must not enter the API input-event transaction.');
|
||||
});
|
||||
const { caller } = buildContext(
|
||||
{ $queryRaw: vi.fn(async () => [messageRow]) },
|
||||
{ $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
|
||||
{ turnDaemon: { requestCommand } }
|
||||
);
|
||||
|
||||
@@ -734,6 +825,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
response: true,
|
||||
})
|
||||
);
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -864,8 +956,18 @@ describe('messages router missing-flow compatibility', () => {
|
||||
const nationUpdate = vi.fn(async () => ({}));
|
||||
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const cityUpdate = vi.fn(async () => ({}));
|
||||
const changeJournal = new ChangeJournal();
|
||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({
|
||||
type: 'syncDiplomaticResponse' as const,
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
messageId: command.messageId,
|
||||
nations: 2,
|
||||
diplomacy: 2,
|
||||
cities: 0,
|
||||
}));
|
||||
const { caller } = buildContext(
|
||||
{
|
||||
general: {
|
||||
@@ -941,13 +1043,22 @@ describe('messages router missing-flow compatibility', () => {
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
|
||||
clockTick: 1_000n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
messageAction: { updateMany: messageActionUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
{ changeJournal }
|
||||
{ changeJournal, turnDaemon: { requestCommand } }
|
||||
);
|
||||
return {
|
||||
caller,
|
||||
@@ -960,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
messageUpdateMany,
|
||||
cityUpdate,
|
||||
changeJournal,
|
||||
requestCommand,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -973,6 +1085,14 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(setup.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'syncDiplomaticResponse',
|
||||
userId: auth.user.id,
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
nationIds: [1, 2],
|
||||
cityIds: [],
|
||||
});
|
||||
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(setup.nationUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -987,7 +1107,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
);
|
||||
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [31] } },
|
||||
data: { validUntil: expect.any(Date), validUntilTick: 0n },
|
||||
data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
|
||||
});
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const bettingId = 990_071;
|
||||
const concurrentBettingId = 990_072;
|
||||
const phaseBettingId = 990_073;
|
||||
const generalId = 9_971;
|
||||
const otherGeneralId = 9_972;
|
||||
const phaseGeneralId = 9_973;
|
||||
const nationId = 990_071;
|
||||
const otherNationId = 990_072;
|
||||
const userId = 'nation-betting-router-user';
|
||||
const otherUserId = 'nation-betting-router-other-user';
|
||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||
const phaseUserId = 'nation-betting-router-phase-user';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
@@ -58,6 +61,17 @@ const noGeneralAuth: GameSessionTokenPayload = {
|
||||
},
|
||||
};
|
||||
|
||||
const phaseAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-phase-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: phaseUserId,
|
||||
username: 'phase-bettor',
|
||||
displayName: 'Phase Bettor',
|
||||
},
|
||||
};
|
||||
|
||||
integration('nation betting router', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
||||
});
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
|
||||
await db.nation.createMany({
|
||||
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
id: phaseGeneralId,
|
||||
userId: phaseUserId,
|
||||
name: '정지중베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
@@ -169,6 +196,17 @@ integration('nation betting router', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.nationBetting.create({
|
||||
data: {
|
||||
id: phaseBettingId,
|
||||
name: '정지 중 베팅',
|
||||
selectCount: 1,
|
||||
requiresInheritancePoint: true,
|
||||
openYearMonth: 2_400,
|
||||
closeYearMonth: 2_424,
|
||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||
},
|
||||
});
|
||||
await db.nationBetting.create({
|
||||
data: {
|
||||
id: concurrentBettingId,
|
||||
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 1_000 },
|
||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||
{ userId: phaseUserId, key: 'previous', value: 500 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
||||
});
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
|
||||
).toMatchObject({ value: 250 });
|
||||
});
|
||||
|
||||
it('accepts nation betting during suspension but rejects it during reconciliation', async () => {
|
||||
const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const frozenTick = before.clockTick;
|
||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({
|
||||
bettingId: phaseBettingId,
|
||||
bettingType: [0],
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
|
||||
})
|
||||
).resolves.toMatchObject({ value: 400 });
|
||||
await expect(
|
||||
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
|
||||
).resolves.toMatchObject({ amount: 100 });
|
||||
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({
|
||||
clockPhase: 'SUSPENDED',
|
||||
clockTick: frozenTick,
|
||||
});
|
||||
|
||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({
|
||||
bettingId: phaseBettingId,
|
||||
bettingType: [0],
|
||||
amount: 50,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
|
||||
})
|
||||
).resolves.toMatchObject({ value: 400 });
|
||||
await expect(
|
||||
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
|
||||
).resolves.toMatchObject({ amount: 100 });
|
||||
|
||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } });
|
||||
});
|
||||
|
||||
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import {
|
||||
buildNpcSelectionTokenSeed,
|
||||
createTurnDaemonRuntime,
|
||||
seedScenarioToDatabase,
|
||||
type TurnDaemonRuntime,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
|
||||
const delayedUserId = 'npc-possession-integration-delayed';
|
||||
const cleanupUserId = 'npc-possession-integration-cleanup';
|
||||
const raceUserId = 'npc-possession-integration-race';
|
||||
const preopenUserId = 'npc-possession-integration-preopen';
|
||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
@@ -93,6 +88,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
|
||||
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
||||
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
||||
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
|
||||
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.logEntry.deleteMany();
|
||||
await db.npcSelectionToken.deleteMany();
|
||||
await db.worldState.updateMany({
|
||||
data: {
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date(),
|
||||
},
|
||||
});
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 24 }, (_, index) => ({
|
||||
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await closeDb?.();
|
||||
}, 30_000);
|
||||
|
||||
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
||||
it('reserves at most five type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
||||
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
||||
expect(config.npcPossession).toEqual({ enabled: true });
|
||||
|
||||
const worldState = await db.worldState.findFirstOrThrow();
|
||||
const acceptedGameTick = Number(worldState.clockTick);
|
||||
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
|
||||
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
|
||||
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
|
||||
throw new Error('NPC possession integration hidden seed is missing');
|
||||
}
|
||||
const selectable = await db.general.findMany({
|
||||
where: { userId: null, npcState: 2 },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, leadership: true, strength: true, intel: true },
|
||||
});
|
||||
const weights = Object.fromEntries(
|
||||
selectable.map((candidate) => [
|
||||
String(candidate.id),
|
||||
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
|
||||
])
|
||||
);
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
|
||||
);
|
||||
const expectedCandidateIds = new Set<number>();
|
||||
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
|
||||
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
|
||||
}
|
||||
|
||||
const [first, concurrentSameOwner] = await Promise.all([
|
||||
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
||||
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
||||
@@ -240,9 +217,6 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
expect(first.candidates.length).toBeGreaterThan(0);
|
||||
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
||||
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
||||
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
|
||||
[...expectedCandidateIds].sort((left, right) => left - right)
|
||||
);
|
||||
expect(first.pickMoreSeconds).toBe(0);
|
||||
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||
const rows = await db.general.findMany({
|
||||
@@ -281,6 +255,49 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it('refreshes possession candidates while the realtime game tick is negative before opening', async () => {
|
||||
await stopRuntime('preopen candidate clock boundary');
|
||||
const worldState = await db.worldState.findFirstOrThrow();
|
||||
const originalClock = {
|
||||
clockBaseTime: worldState.clockBaseTime,
|
||||
clockTick: worldState.clockTick,
|
||||
clockMode: worldState.clockMode,
|
||||
clockWallAnchor: worldState.clockWallAnchor,
|
||||
};
|
||||
const openAt = new Date('2099-08-01T00:00:00.000Z');
|
||||
try {
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: {
|
||||
clockBaseTime: openAt,
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: openAt,
|
||||
},
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-07-31T23:59:30.000Z'));
|
||||
|
||||
const first = await appRouter
|
||||
.createCaller(buildContext('npc-possession-preopen-first', preopenAuth))
|
||||
.join.listPossessCandidates({});
|
||||
expect(first.pickMoreSeconds).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(30_000);
|
||||
const refreshed = await appRouter
|
||||
.createCaller(buildContext('npc-possession-preopen-refresh', preopenAuth))
|
||||
.join.listPossessCandidates({ refresh: true, keepIds: [] });
|
||||
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
|
||||
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
|
||||
await db.worldState.update({ where: { id: worldState.id }, data: originalClock });
|
||||
await startRuntime('npc-possession-integration-daemon-resumed');
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-current'))
|
||||
@@ -403,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
});
|
||||
}, 45_000);
|
||||
|
||||
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => {
|
||||
it('revalidates a queued token at the authoritative daemon processing tick', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
||||
.join.listPossessCandidates({});
|
||||
@@ -423,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||
|
||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
const acceptedGameAt = new Date(
|
||||
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
|
||||
);
|
||||
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
|
||||
expect(event.acceptedGameTick).toBeNull();
|
||||
expect(event.processingGameTick).toBeNull();
|
||||
expect(event.payload).not.toHaveProperty('acceptedGameAt');
|
||||
const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
|
||||
await db.npcSelectionToken.update({
|
||||
where: { ownerUserId: delayedUserId },
|
||||
data: { validUntil: acceptedGameAt },
|
||||
data: { validUntilTick: queuedAtTick },
|
||||
});
|
||||
await db.worldState.updateMany({
|
||||
data: { clockTick: { increment: 1 } },
|
||||
@@ -453,12 +470,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await startRuntime('npc-possession-delayed-retry-daemon');
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
|
||||
).resolves.toEqual({ ok: true, generalId: candidate.id });
|
||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' });
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1);
|
||||
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0);
|
||||
}, 45_000);
|
||||
|
||||
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
|
||||
|
||||
@@ -27,15 +27,16 @@ const payload = (
|
||||
|
||||
const createFixture = (rows: readonly object[]) => {
|
||||
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
|
||||
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const executeRaw = vi.fn().mockResolvedValue(1);
|
||||
const incr = vi.fn().mockResolvedValue(41);
|
||||
const publish = vi.fn().mockResolvedValue(1);
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
readModelOutbox: { updateMany },
|
||||
$executeRaw: executeRaw,
|
||||
readModelOutbox: {},
|
||||
} as unknown as ReadModelOutboxDatabase;
|
||||
const redis = { incr, publish } as unknown as RedisConnector['client'];
|
||||
return { db, redis, queryRaw, updateMany, incr, publish };
|
||||
return { db, redis, queryRaw, executeRaw, incr, publish };
|
||||
};
|
||||
|
||||
describe('ReadModelOutboxWorker', () => {
|
||||
@@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
||||
@@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
||||
@@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
revision: 41,
|
||||
changes: { frontStatusActorIds: [7] },
|
||||
});
|
||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
|
||||
);
|
||||
expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
|
||||
});
|
||||
|
||||
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
||||
@@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).not.toHaveBeenCalled();
|
||||
@@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).not.toHaveBeenCalled();
|
||||
@@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
|
||||
);
|
||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
|
||||
data: expect.objectContaining({
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: expect.stringContaining('redis unavailable'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
||||
expect(releaseQuery.sql).toContain('"available_at"');
|
||||
expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
|
||||
});
|
||||
|
||||
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
|
||||
|
||||
@@ -710,7 +710,7 @@ describe('appRouter', () => {
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
|
||||
it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const requestId = 'select-pool-reserve-http';
|
||||
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
||||
@@ -741,8 +741,6 @@ describe('appRouter', () => {
|
||||
requestId: commandRequestId,
|
||||
userId: 'user-1',
|
||||
seedOwnerIdentity: 'user-1',
|
||||
acceptedGameAt,
|
||||
acceptedGameTick: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1418,6 +1416,20 @@ describe('appRouter', () => {
|
||||
message: expect.stringContaining('랜덤 임관만 가능합니다'),
|
||||
});
|
||||
expect(randomOnlyWrites).toHaveLength(0);
|
||||
|
||||
await expect(
|
||||
randomOnlyCaller.turns.reserved.setGeneral({
|
||||
generalId: general.id,
|
||||
turnIndex: 0,
|
||||
action: 'che_등용',
|
||||
args: { destGeneralId: 2 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: expect.stringContaining('랜덤 임관만 가능합니다'),
|
||||
});
|
||||
expect(randomOnlyWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('checks nation treaty-term reservation permission but not full diplomacy state', async () => {
|
||||
@@ -1462,6 +1474,28 @@ describe('appRouter', () => {
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(validTermWrites).toHaveLength(1);
|
||||
|
||||
const excessiveTermWrites: unknown[] = [];
|
||||
const excessiveTermCaller = appRouter.createCaller(
|
||||
buildContext({
|
||||
state: buildWorldState(),
|
||||
general,
|
||||
nationTurnWrites: excessiveTermWrites,
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
excessiveTermCaller.turns.reserved.setNation({
|
||||
generalId: general.id,
|
||||
turnIndex: 0,
|
||||
action: 'che_불가침제의',
|
||||
args: { destNationId: 2, year: 211, month: 1 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: expect.stringContaining('기한은 210년 이하'),
|
||||
});
|
||||
expect(excessiveTermWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('validates every bulk reservation permission before writing any turn', async () => {
|
||||
|
||||
@@ -227,19 +227,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
||||
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
||||
).toBe(false);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
const reserveEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||
});
|
||||
expect(reserveEvent).toMatchObject({
|
||||
eventType: 'selectPoolReserve',
|
||||
status: 'SUCCEEDED',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||
|
||||
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
|
||||
const attempts = await Promise.allSettled([
|
||||
@@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
|
||||
|
||||
const cooledAt = '2026-07-29T00:00:00.000Z';
|
||||
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
||||
await expect(
|
||||
turnDaemon.requestCommand({
|
||||
type: 'patchGeneral',
|
||||
@@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
meta: {
|
||||
next_change: cooledAt,
|
||||
nextChangeAt: cooledAt,
|
||||
next_change_tick: cooledTick,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -380,16 +380,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.createCaller(buildContext('select-pool-reselect'))
|
||||
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
|
||||
).resolves.toMatchObject({
|
||||
const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
|
||||
});
|
||||
expect(reselectionEvent).toMatchObject({
|
||||
eventType: 'selectPoolReselect',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||
|
||||
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
||||
expect(updated).toMatchObject({
|
||||
@@ -455,6 +455,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
|
||||
});
|
||||
const secondCooledAt = '2026-07-28T00:00:00.000Z';
|
||||
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
||||
await turnDaemon.requestCommand({
|
||||
type: 'patchGeneral',
|
||||
requestId: 'select-pool-full-cooldown-patch',
|
||||
@@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
meta: {
|
||||
next_change: secondCooledAt,
|
||||
nextChangeAt: secondCooledAt,
|
||||
next_change_tick: secondCooledTick,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.join.selectPoolGeneral(stableInput);
|
||||
expect(retried).toEqual(first);
|
||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({
|
||||
where: {
|
||||
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
const stableEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: {
|
||||
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
||||
},
|
||||
});
|
||||
expect(stableEvent).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: otherUserId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||
}, 30_000);
|
||||
|
||||
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
@@ -30,11 +30,31 @@ class MemoryRedis {
|
||||
}
|
||||
|
||||
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
|
||||
const [valueKey, revisionKey] = options.keys;
|
||||
const [value] = options.arguments;
|
||||
if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) {
|
||||
const current = options.keys.map((key) => this.values.get(key));
|
||||
if (current.every((value) => value === undefined)) {
|
||||
options.keys.forEach((key, index) => this.values.set(key, options.arguments[index]!));
|
||||
return '1';
|
||||
}
|
||||
return current.every((value, index) => value === options.arguments[index]) ? '2' : '0';
|
||||
}
|
||||
const fenced = options.keys.at(-1)?.endsWith(':clock:phase') === true;
|
||||
const writeCount = options.keys.length - (fenced ? 4 : 1);
|
||||
if (fenced) {
|
||||
const clockKeys = options.keys.slice(-3);
|
||||
const expected = options.arguments.slice(-3);
|
||||
if (!clockKeys.every((key, index) => this.values.get(key) === expected[index])) {
|
||||
return '__CLOCK_FENCE__';
|
||||
}
|
||||
}
|
||||
const valueKey = options.keys[0];
|
||||
const revisionKey = options.keys[writeCount];
|
||||
const value = options.arguments[0];
|
||||
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
|
||||
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
|
||||
this.values.set(valueKey, value);
|
||||
for (let index = 0; index < writeCount; index += 1) {
|
||||
this.values.set(options.keys[index]!, options.arguments[index]!);
|
||||
}
|
||||
this.values.set(revisionKey, String(revision));
|
||||
return String(revision);
|
||||
}
|
||||
@@ -131,6 +151,8 @@ const buildContext = (options: {
|
||||
develCost?: number;
|
||||
currentDevelCost?: number;
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
||||
requestId?: string;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
@@ -144,12 +166,21 @@ const buildContext = (options: {
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
clockPhase: options.clockPhase ?? 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
tickSeconds: 60,
|
||||
config: { const: { develCost: options.develCost ?? 200 } },
|
||||
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
|
||||
}),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
return {
|
||||
requestId: options.requestId,
|
||||
db,
|
||||
redis: options.redis as unknown as RedisConnector['client'],
|
||||
turnDaemon: options.transport,
|
||||
@@ -341,6 +372,83 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||
});
|
||||
|
||||
it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const context = buildContext({
|
||||
redis,
|
||||
transport,
|
||||
generals: [general],
|
||||
userId: 'user-1',
|
||||
clockPhase: 'SUSPENDED',
|
||||
requestId: 'http:suspended-tournament-bet',
|
||||
});
|
||||
const outerApiTransaction = vi.fn(async () => {
|
||||
throw new Error('tournament bet must not hold an API transaction while waiting for the daemon');
|
||||
});
|
||||
Object.assign(context.db, { $transaction: outerApiTransaction });
|
||||
const caller = appRouter.createCaller(context);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true });
|
||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' })
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(3_000);
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
@@ -394,7 +502,10 @@ describe('tournament router permissions and mutations', () => {
|
||||
roles: ['admin.tournament:che:default'],
|
||||
})
|
||||
);
|
||||
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
|
||||
await expect(adminCaller.tournament.getAdminStatus()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
clock: { reconciliationComplete: false, latestReconciliation: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the admin role boundary to every tournament mutation', async () => {
|
||||
@@ -479,9 +590,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
])
|
||||
).resolves.toEqual({ ok: true, count: 1 });
|
||||
await expect(
|
||||
caller.tournament.setBettingEntries([
|
||||
{ generalId: general.id, targetId: rival.id, amount: 100 },
|
||||
])
|
||||
caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }])
|
||||
).resolves.toEqual({ ok: true, count: 1 });
|
||||
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
|
||||
ok: true,
|
||||
|
||||
@@ -41,6 +41,9 @@ integration('TournamentStore Redis source revision', () => {
|
||||
keys.matchesKey,
|
||||
keys.bettingKey,
|
||||
keys.sourceRevisionKey,
|
||||
keys.activeClockRevisionKey,
|
||||
keys.deadlineGenerationKey,
|
||||
keys.clockPhaseKey,
|
||||
]);
|
||||
if (subscriber) {
|
||||
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
|
||||
@@ -124,4 +127,48 @@ integration('TournamentStore Redis source revision', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('dual-writes deadline ticks and rejects a Redis clock revision race atomically', async () => {
|
||||
const store = new TournamentStore(connector.client, keys);
|
||||
await Promise.all([
|
||||
connector.client.set(keys.activeClockRevisionKey, '7'),
|
||||
connector.client.set(keys.deadlineGenerationKey, '3'),
|
||||
connector.client.set(keys.clockPhaseKey, 'RUNNING'),
|
||||
]);
|
||||
const clockContext = {
|
||||
phase: 'RUNNING' as const,
|
||||
revision: 7,
|
||||
deadlineGeneration: 3,
|
||||
dateToTick: (date: Date) => Math.trunc(date.getTime() / 1_000),
|
||||
};
|
||||
const nextAt = '2026-09-03T10:00:00.000Z';
|
||||
await store.withClockContext(clockContext, () =>
|
||||
store.setState({
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 200,
|
||||
openMonth: 1,
|
||||
termSeconds: 10,
|
||||
nextAt,
|
||||
bettingCloseAt: '2026-09-03T09:59:50.000Z',
|
||||
})
|
||||
);
|
||||
await expect(store.getState()).resolves.toMatchObject({
|
||||
nextTick: Math.trunc(new Date(nextAt).getTime() / 1_000),
|
||||
bettingCloseTick: Math.trunc(new Date('2026-09-03T09:59:50.000Z').getTime() / 1_000),
|
||||
clockRevision: 7,
|
||||
deadlineGeneration: 3,
|
||||
});
|
||||
|
||||
const beforeRevision = await store.getSourceRevision();
|
||||
await connector.client.set(keys.activeClockRevisionKey, '8');
|
||||
await expect(
|
||||
store.withClockContext(clockContext, () =>
|
||||
store.setMatches([{ id: 99, stage: 7, roundIndex: 0, attackerId: 1, defenderId: 2 }])
|
||||
)
|
||||
).rejects.toThrow('clock revision fence failed');
|
||||
await expect(store.getSourceRevision()).resolves.toBe(beforeRevision);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
|
||||
describe('turn engine status projection', () => {
|
||||
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
||||
const activeLease = {
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
|
||||
},
|
||||
};
|
||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||
} as any;
|
||||
const now = new Date('2026-08-24T00:00:00.000Z');
|
||||
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||
true
|
||||
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
source,
|
||||
{ turnDaemonLease: { findUnique: async () => null } },
|
||||
{ $queryRaw: async () => [{ running: false }] } as any,
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
source,
|
||||
{
|
||||
turnDaemonLease: {
|
||||
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
|
||||
},
|
||||
},
|
||||
{ $queryRaw: async () => [{ running: false }] } as any,
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
|
||||
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
||||
let now = 1_000;
|
||||
const get = vi.fn(async () => 'RUNNING' as const);
|
||||
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') }));
|
||||
const queryRaw = vi.fn(async () => [{ running: true }]);
|
||||
const cache = new CachedTurnEngineStatus(
|
||||
{ get },
|
||||
{ turnDaemonLease: { findUnique } },
|
||||
{ $queryRaw: queryRaw } as any,
|
||||
'che:default',
|
||||
2_000,
|
||||
() => now
|
||||
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
|
||||
now += 1;
|
||||
await expect(cache.get()).resolves.toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,13 +230,7 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
|
||||
expect(
|
||||
hasPollEnded(
|
||||
{ closed_at: null, end_at: now, end_tick: null },
|
||||
{ ...time, now: new Date(now.getTime() + 1), tick: null }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated survey access', async () => {
|
||||
@@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => {
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||
false
|
||||
@@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => {
|
||||
const auth = buildAuth(['admin.survey.open']);
|
||||
const fixture = buildContext({ auth });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const windowStart = Date.now();
|
||||
|
||||
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
caller.vote.createPoll({
|
||||
@@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => {
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
|
||||
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
|
||||
const windowEnd = Date.now();
|
||||
|
||||
const mutationQueries = fixture.queryRaw.mock.calls
|
||||
.map(([query]) => query)
|
||||
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
|
||||
@@ -325,30 +314,24 @@ describe('vote router actor and permission boundaries', () => {
|
||||
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
|
||||
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
||||
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
|
||||
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => {
|
||||
const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => {
|
||||
expect(query).toBeDefined();
|
||||
const value = query?.values.at(index);
|
||||
expect(value).toBeInstanceOf(Date);
|
||||
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
|
||||
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
|
||||
return value as Date;
|
||||
expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
|
||||
};
|
||||
|
||||
expect(sqlText(commentInsert!)).toContain('created_at');
|
||||
expectCurrentDateAt(commentInsert, -1);
|
||||
expectDbWallClock(commentInsert);
|
||||
expect(sqlText(pollInsert!)).toContain('created_at');
|
||||
expect(sqlText(pollInsert!)).toContain('updated_at');
|
||||
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
|
||||
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
|
||||
expect(pollUpdatedAt).toBe(pollCreatedAt);
|
||||
expectDbWallClock(pollInsert);
|
||||
|
||||
expect(pollUpdates).toHaveLength(3);
|
||||
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
||||
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
|
||||
expectDbWallClock(closePreviousUpdate);
|
||||
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
||||
expectCurrentDateAt(editPollUpdate, -2);
|
||||
expectDbWallClock(editPollUpdate);
|
||||
expect(sqlText(closePollUpdate!)).toContain('updated_at');
|
||||
expectCurrentDateAt(closePollUpdate, -2);
|
||||
expectDbWallClock(closePollUpdate);
|
||||
});
|
||||
|
||||
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
||||
|
||||
@@ -8,5 +8,6 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
reporters: [process.env.SAMMO_TEST_REPORTER === 'verbose' ? 'default' : 'minimal'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
"types": "./dist/turn/databaseHooks.d.ts",
|
||||
"default": "./dist/turn/databaseHooks.js"
|
||||
},
|
||||
"./turn/clockReconciliation.js": {
|
||||
"types": "./dist/turn/clockReconciliation.d.ts",
|
||||
"default": "./dist/turn/clockReconciliation.js"
|
||||
},
|
||||
"./turn/clockProjectionOutbox.js": {
|
||||
"types": "./dist/turn/clockProjectionOutbox.d.ts",
|
||||
"default": "./dist/turn/clockProjectionOutbox.js"
|
||||
},
|
||||
"./turn/inMemoryWorld.js": {
|
||||
"types": "./dist/turn/inMemoryWorld.d.ts",
|
||||
"default": "./dist/turn/inMemoryWorld.js"
|
||||
@@ -103,8 +111,8 @@
|
||||
"build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .",
|
||||
"dev": "tsdown -c ./tsdown.config.ts --watch",
|
||||
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "pnpm -w lint:target app/game-engine",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-engine",
|
||||
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
|
||||
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs",
|
||||
"profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs",
|
||||
|
||||
@@ -60,27 +60,27 @@ export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
now: Date,
|
||||
nowTick: number | null
|
||||
): boolean =>
|
||||
auction.closeTick !== null && nowTick !== null
|
||||
? auction.closeTick < BigInt(nowTick)
|
||||
: auction.closeAt.getTime() < now.getTime();
|
||||
): boolean => {
|
||||
void now;
|
||||
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
|
||||
};
|
||||
|
||||
export const resolveAuctionBidTiming = (
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
): { bidAt: Date; bidTick: number } =>
|
||||
acceptedGameTick === undefined
|
||||
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
|
||||
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
|
||||
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
||||
processingGameTick: number
|
||||
): { bidAt: Date; bidTick: number } => {
|
||||
if (!Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
|
||||
};
|
||||
|
||||
export const hasAuctionBidClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
||||
processingGameTick: number
|
||||
): boolean => {
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
|
||||
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
||||
};
|
||||
|
||||
@@ -268,8 +268,15 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '경매가 종료되었습니다.',
|
||||
};
|
||||
}
|
||||
const processingNow = world.getGameNow(new Date());
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick);
|
||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
||||
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
||||
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
||||
throw new Error('auctionBid requires its durable input-event wall occurrence.');
|
||||
}
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
|
||||
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
@@ -503,13 +510,24 @@ export const createAuctionBidder = async (options: {
|
||||
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
||||
await tx.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
||||
INSERT INTO auction_bid (
|
||||
auction_id,
|
||||
general_id,
|
||||
amount,
|
||||
event_id,
|
||||
event_at,
|
||||
occurred_game_tick,
|
||||
requested_at_wall,
|
||||
meta
|
||||
)
|
||||
VALUES (
|
||||
${command.auctionId},
|
||||
${command.generalId},
|
||||
${command.amount},
|
||||
${eventId},
|
||||
${eventAt},
|
||||
${BigInt(bidTick)},
|
||||
${requestedAtWall},
|
||||
${JSON.stringify({
|
||||
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
|
||||
...(auction.type === 'UNIQUE_ITEM'
|
||||
@@ -529,7 +547,7 @@ export const createAuctionBidder = async (options: {
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
latest_event_id = ${eventId},
|
||||
latest_event_at = ${eventAt},
|
||||
updated_at = ${eventAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${command.auctionId}
|
||||
AND status = 'OPEN'
|
||||
AND latest_event_id = ${auction.latestEventId}
|
||||
@@ -549,7 +567,7 @@ export const createAuctionBidder = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE inheritance_point
|
||||
SET value = value - ${morePoint},
|
||||
updated_at = ${eventAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE user_id = ${userId}
|
||||
AND key = 'previous'
|
||||
AND value >= ${morePoint}
|
||||
@@ -580,11 +598,16 @@ export const createAuctionBidder = async (options: {
|
||||
await tx.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
||||
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
|
||||
VALUES (
|
||||
${prevUserId},
|
||||
'previous',
|
||||
${highestBid.amount},
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
ON CONFLICT (user_id, key)
|
||||
DO UPDATE SET
|
||||
value = inheritance_point.value + EXCLUDED.value,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
`
|
||||
);
|
||||
await tx.$executeRaw(
|
||||
@@ -704,6 +727,7 @@ export const createAuctionBidder = async (options: {
|
||||
ok: true,
|
||||
auctionId: command.auctionId,
|
||||
closeAt: nextCloseAt.toISOString(),
|
||||
closeTick: world.dateToGameTick(nextCloseAt),
|
||||
};
|
||||
},
|
||||
close: async (): Promise<void> => {
|
||||
|
||||
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
||||
): boolean => {
|
||||
if (command.expectedCloseTick !== undefined) {
|
||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||
}
|
||||
if (command.expectedCloseAt !== undefined) {
|
||||
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
|
||||
}
|
||||
return true;
|
||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||
};
|
||||
|
||||
export const hasAuctionFinalizeDeadlineArrived = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): boolean =>
|
||||
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
|
||||
): boolean => {
|
||||
void now;
|
||||
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
|
||||
};
|
||||
|
||||
export const buildAuctionBidderSystemMessage = (options: {
|
||||
bidder: TurnGeneral;
|
||||
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||
}
|
||||
|
||||
const now = world.getGameNow(new Date());
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const now = world.gameTickToDate(processingGameTick);
|
||||
if (auction.status === 'OPEN') {
|
||||
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
||||
return {
|
||||
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
reason: '경매 마감 세대가 변경되었습니다.',
|
||||
};
|
||||
}
|
||||
const nowTick = world.dateToGameTick(now);
|
||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
|
||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'FINALIZING',
|
||||
finalizing_at = ${now},
|
||||
updated_at = ${now}
|
||||
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${auctionId}
|
||||
AND status = 'OPEN'
|
||||
`
|
||||
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = ${status},
|
||||
finished_at = ${now},
|
||||
updated_at = ${now}
|
||||
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
);
|
||||
|
||||
@@ -94,7 +94,11 @@ const openResourceAuction = async (
|
||||
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
||||
}
|
||||
|
||||
const now = world.getGameNow(new Date());
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const now = world.gameTickToDate(processingGameTick);
|
||||
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
||||
const auction = await db.auction.create({
|
||||
@@ -113,7 +117,7 @@ const openResourceAuction = async (
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
openTick: BigInt(world.dateToGameTick(now)),
|
||||
openTick: BigInt(processingGameTick),
|
||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||
},
|
||||
});
|
||||
@@ -125,6 +129,7 @@ const openResourceAuction = async (
|
||||
ok: true,
|
||||
auctionId: auction.id,
|
||||
closeAt: closeAt.toISOString(),
|
||||
closeTick: world.dateToGameTick(closeAt),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
|
||||
}
|
||||
|
||||
const state = world.getState();
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
||||
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
||||
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
|
||||
}
|
||||
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
||||
const now = world.getGameNow(new Date());
|
||||
const now = world.gameTickToDate(processingGameTick);
|
||||
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
||||
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
||||
const extensionLimitMinutes = Math.max(
|
||||
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
openTick: BigInt(world.dateToGameTick(now)),
|
||||
openTick: BigInt(processingGameTick),
|
||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||
latestEventId: eventId,
|
||||
latestEventAt: now,
|
||||
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
|
||||
amount: command.amount,
|
||||
eventId,
|
||||
eventAt: now,
|
||||
occurredGameTick: BigInt(processingGameTick),
|
||||
requestedAtWall,
|
||||
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
||||
},
|
||||
},
|
||||
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
|
||||
ok: true,
|
||||
auctionId: auction.id,
|
||||
closeAt: closeAt.toISOString(),
|
||||
closeTick: world.dateToGameTick(closeAt),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export * from './turn/engineStateManager.js';
|
||||
export * from './turn/inMemoryStateStore.js';
|
||||
export * from './turn/inMemoryTurnProcessor.js';
|
||||
export * from './turn/databaseHooks.js';
|
||||
export * from './turn/clockReconciliation.js';
|
||||
export * from './turn/clockProjectionOutbox.js';
|
||||
export * from './turn/joinCreateGeneralService.js';
|
||||
export * from './turn/npcPossessionService.js';
|
||||
export * from './turn/selectPoolService.js';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
|
||||
import type {
|
||||
@@ -10,8 +11,9 @@ import type {
|
||||
TurnDaemonStatus,
|
||||
} from './types.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const serializeResult = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
|
||||
|
||||
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
||||
private readonly localQueue: TurnDaemonCommand[] = [];
|
||||
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
return local.concat(remote);
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
while (deadlineMs === null || Date.now() < deadlineMs) {
|
||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
|
||||
while (deadline === null || performance.now() < deadline) {
|
||||
const local = this.localQueue.shift();
|
||||
if (local) {
|
||||
return local;
|
||||
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
if (remote[0]) {
|
||||
return remote[0];
|
||||
}
|
||||
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
|
||||
const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now()));
|
||||
await delay(remaining);
|
||||
}
|
||||
return null;
|
||||
@@ -84,30 +87,50 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
return;
|
||||
}
|
||||
const terminal = event.attempts >= this.maxAttempts;
|
||||
await transaction.inputEvent.updateMany({
|
||||
where: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
lockedBy: this.workerId,
|
||||
attempts: event.attempts,
|
||||
},
|
||||
data: {
|
||||
status: terminal ? 'FAILED' : 'PENDING',
|
||||
processingAt: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
completedAt: terminal ? new Date() : null,
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
|
||||
processing_at = NULL,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
completed_at = CASE
|
||||
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
result = NULL,
|
||||
error = ${message}
|
||||
WHERE request_id = ${requestId}
|
||||
AND target = 'ENGINE'::"InputEventTarget"
|
||||
AND status = 'PROCESSING'::"InputEventStatus"
|
||||
AND locked_by = ${this.workerId}
|
||||
AND attempts = ${event.attempts}
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
||||
await this.recoverExpiredLeases();
|
||||
return this.db.$transaction(async (transaction) => {
|
||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||
const world = await transaction.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
||||
});
|
||||
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
||||
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
||||
const currentRevision = world?.clockRevision ?? null;
|
||||
const maintenanceSuspended =
|
||||
world?.clockPhase === 'SUSPENDED' &&
|
||||
Boolean(
|
||||
await transaction.clockSuspension.findFirst({
|
||||
where: {
|
||||
status: 'SUSPENDED',
|
||||
source: 'MAINTENANCE',
|
||||
sourceRevision: world.clockRevision,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
);
|
||||
const rows = await transaction.$queryRaw<
|
||||
Array<{
|
||||
sequence: bigint;
|
||||
@@ -115,6 +138,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
createdAt: Date;
|
||||
acceptedGameTick: bigint | null;
|
||||
acceptedClockRevision: bigint | null;
|
||||
acceptedDeadlineGeneration: bigint | null;
|
||||
}>
|
||||
>(GamePrisma.sql`
|
||||
SELECT
|
||||
@@ -122,10 +148,71 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
"request_id" AS "requestId",
|
||||
"event_type" AS "eventType",
|
||||
"payload",
|
||||
"created_at" AS "createdAt"
|
||||
"created_at" AS "createdAt",
|
||||
"accepted_game_tick" AS "acceptedGameTick",
|
||||
"accepted_clock_revision" AS "acceptedClockRevision",
|
||||
"accepted_deadline_generation" AS "acceptedDeadlineGeneration"
|
||||
FROM "input_event"
|
||||
WHERE "target" = 'ENGINE'::"InputEventTarget"
|
||||
AND "status" = 'PENDING'::"InputEventStatus"
|
||||
AND (
|
||||
${gameplayAllowed}
|
||||
OR "event_type" = 'getStatus'
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||
)
|
||||
OR (
|
||||
${maintenanceSuspended}
|
||||
AND "event_type" IN (
|
||||
'inheritanceAction',
|
||||
'dropItem',
|
||||
'changePermission',
|
||||
'appoint',
|
||||
'setNationSetting',
|
||||
'setNpcPolicy',
|
||||
'shiftSchedule'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
${maintenanceSuspended}
|
||||
AND "event_type" = 'messageRespond'
|
||||
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "message_action" AS pending_action
|
||||
WHERE pending_action."message_id" = ("input_event"."payload" ->> 'messageId')::integer
|
||||
AND pending_action."status" = 'PENDING'
|
||||
AND pending_action."clock_revision" = ${currentRevision}
|
||||
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
|
||||
)
|
||||
)
|
||||
OR (
|
||||
${world?.clockPhase === 'SUSPENDED'}
|
||||
AND "event_type" = 'messageRespond'
|
||||
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "message" AS pending_message
|
||||
JOIN "message_action" AS pending_action
|
||||
ON pending_action."message_id" = pending_message."id"
|
||||
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
|
||||
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
|
||||
AND pending_action."action_type" = 'raiseInvader'
|
||||
AND pending_action."status" = 'PENDING'
|
||||
AND pending_action."clock_revision" = ${currentRevision}
|
||||
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "clock_suspension" AS active_suspension
|
||||
WHERE active_suspension."status" = 'SUSPENDED'
|
||||
AND active_suspension."source" = 'UNIFICATION_WAIT'
|
||||
AND active_suspension."source_revision" = ${currentRevision}
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY "sequence" ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT ${limit}
|
||||
@@ -133,41 +220,93 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
await transaction.inputEvent.updateMany({
|
||||
where: {
|
||||
sequence: { in: rows.map((row) => row.sequence) },
|
||||
target: 'ENGINE',
|
||||
status: 'PENDING',
|
||||
},
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
processingAt: new Date(),
|
||||
lockedBy: this.workerId,
|
||||
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
const appliedSuspensions = currentRevision
|
||||
? await transaction.clockSuspension.findMany({
|
||||
where: { status: 'APPLIED', targetRevision: { lte: currentRevision } },
|
||||
orderBy: { sourceRevision: 'asc' },
|
||||
select: { sourceRevision: true, targetRevision: true, shiftTicks: true },
|
||||
})
|
||||
: [];
|
||||
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
|
||||
if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
|
||||
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
|
||||
return claimCoordinate.gameTick;
|
||||
}
|
||||
if (row.acceptedClockRevision > currentRevision) return undefined;
|
||||
let revision = row.acceptedClockRevision;
|
||||
let tick = row.acceptedGameTick;
|
||||
while (revision < currentRevision) {
|
||||
const step = appliedSuspensions.find((entry) => entry.sourceRevision === revision);
|
||||
if (!step || step.shiftTicks === null || step.targetRevision !== revision + 1n) return undefined;
|
||||
tick += step.shiftTicks;
|
||||
revision = step.targetRevision;
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
const processableRows = rows
|
||||
.map((row) => ({ row, processingGameTick: convertTick(row) }))
|
||||
.filter(
|
||||
(entry): entry is { row: (typeof rows)[number]; processingGameTick: bigint | null } =>
|
||||
entry.processingGameTick !== undefined
|
||||
);
|
||||
for (const { row, processingGameTick } of processableRows) {
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'PROCESSING'::"InputEventStatus",
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
|
||||
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
|
||||
accepted_deadline_generation = COALESCE(
|
||||
accepted_deadline_generation,
|
||||
${world?.deadlineGeneration ?? null}
|
||||
),
|
||||
processing_game_tick = ${processingGameTick},
|
||||
processing_clock_revision = ${currentRevision},
|
||||
processing_deadline_generation = ${world?.deadlineGeneration ?? null},
|
||||
locked_by = ${this.workerId},
|
||||
lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${this.leaseDurationMs} * INTERVAL '1 millisecond',
|
||||
attempts = attempts + 1
|
||||
WHERE sequence = ${row.sequence}
|
||||
`);
|
||||
}
|
||||
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
for (const row of rows) {
|
||||
for (const { row, processingGameTick } of processableRows) {
|
||||
const command = normalizeTurnDaemonCommand({
|
||||
requestId: row.requestId,
|
||||
sentAt: row.createdAt.toISOString(),
|
||||
command: row.payload as TurnDaemonCommand,
|
||||
});
|
||||
if (!command) {
|
||||
await transaction.inputEvent.update({
|
||||
where: { sequence: row.sequence },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: `Invalid command payload for ${row.eventType}`,
|
||||
completedAt: new Date(),
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
error = ${`Invalid command payload for ${row.eventType}`},
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE sequence = ${row.sequence}
|
||||
`);
|
||||
continue;
|
||||
}
|
||||
if (processingGameTick !== null) {
|
||||
const value = Number(processingGameTick);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
error = 'Converted processing game tick is outside the safe integer range.',
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE sequence = ${row.sequence}
|
||||
`);
|
||||
continue;
|
||||
}
|
||||
Reflect.set(command, 'processingGameTick', value);
|
||||
}
|
||||
Reflect.set(command, 'requestedAtWall', row.createdAt);
|
||||
commands.push(command);
|
||||
}
|
||||
return commands;
|
||||
@@ -175,23 +314,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
}
|
||||
|
||||
private async complete(requestId: string, result: unknown): Promise<void> {
|
||||
const completed = await this.db.inputEvent.updateMany({
|
||||
where: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
lockedBy: this.workerId,
|
||||
},
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(result),
|
||||
completedAt: new Date(),
|
||||
error: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
if (completed.count > 0) {
|
||||
const completed = await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||
result = CAST(${serializeResult(result)} AS jsonb),
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
error = NULL,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE request_id = ${requestId}
|
||||
AND target = 'ENGINE'::"InputEventTarget"
|
||||
AND status = 'PROCESSING'::"InputEventStatus"
|
||||
AND locked_by = ${this.workerId}
|
||||
`);
|
||||
if (completed > 0) {
|
||||
return;
|
||||
}
|
||||
// Database hooks commit mutation results atomically with game state and
|
||||
@@ -212,19 +348,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
}
|
||||
|
||||
private async recoverExpiredLeases(): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db.inputEvent.updateMany({
|
||||
where: {
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
leaseUntil: { lt: now },
|
||||
},
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
processingAt: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'PENDING'::"InputEventStatus",
|
||||
processing_at = NULL,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE target = 'ENGINE'::"InputEventTarget"
|
||||
AND status = 'PROCESSING'::"InputEventStatus"
|
||||
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ export class DatabaseTurnDaemonLease {
|
||||
VALUES (
|
||||
${this.profile},
|
||||
${this.ownerId},
|
||||
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
||||
1,
|
||||
CURRENT_TIMESTAMP
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
ON CONFLICT ("profile") DO UPDATE
|
||||
SET
|
||||
@@ -99,10 +99,10 @@ export class DatabaseTurnDaemonLease {
|
||||
THEN "turn_daemon_lease"."fencing_epoch"
|
||||
ELSE "turn_daemon_lease"."fencing_epoch" + 1
|
||||
END,
|
||||
"heartbeat_at" = CURRENT_TIMESTAMP
|
||||
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE
|
||||
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
||||
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP
|
||||
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
RETURNING "profile", "owner_id", "fencing_epoch"
|
||||
`);
|
||||
const row = rows[0];
|
||||
@@ -141,13 +141,13 @@ export class DatabaseTurnDaemonLease {
|
||||
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
||||
UPDATE "turn_daemon_lease"
|
||||
SET
|
||||
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
||||
"heartbeat_at" = CURRENT_TIMESTAMP
|
||||
"lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
||||
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE
|
||||
"profile" = ${token.profile}
|
||||
AND "owner_id" = ${token.ownerId}
|
||||
AND "fencing_epoch" = ${token.fencingEpoch}
|
||||
AND "lease_until" > CURRENT_TIMESTAMP
|
||||
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
RETURNING "profile", "owner_id", "fencing_epoch"
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
@@ -177,7 +177,7 @@ export class DatabaseTurnDaemonLease {
|
||||
"profile" = ${token.profile}
|
||||
AND "owner_id" = ${token.ownerId}
|
||||
AND "fencing_epoch" = ${token.fencingEpoch}
|
||||
AND "lease_until" > CURRENT_TIMESTAMP
|
||||
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
@@ -196,7 +196,8 @@ export class DatabaseTurnDaemonLease {
|
||||
}
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "turn_daemon_lease"
|
||||
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP
|
||||
SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE
|
||||
"profile" = ${token.profile}
|
||||
AND "owner_id" = ${token.ownerId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
||||
|
||||
type Waiter = {
|
||||
deadlineMs: number | null;
|
||||
timeoutMs: number | null;
|
||||
resolve: (command: TurnDaemonCommand | null) => void;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
|
||||
return drained;
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
if (this.queue.length > 0) {
|
||||
return this.queue.shift() ?? null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const waiter: Waiter = { deadlineMs, resolve };
|
||||
if (deadlineMs !== null) {
|
||||
const delay = Math.max(0, deadlineMs - Date.now());
|
||||
const waiter: Waiter = { timeoutMs, resolve };
|
||||
if (timeoutMs !== null) {
|
||||
const delay = Math.max(0, timeoutMs);
|
||||
waiter.timeoutId = setTimeout(() => {
|
||||
this.removeWaiter(waiter);
|
||||
resolve(null);
|
||||
|
||||
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
|
||||
return drained.concat(remote);
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
if (this.localQueue.length > 0) {
|
||||
return this.localQueue.shift() ?? null;
|
||||
}
|
||||
|
||||
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
|
||||
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
|
||||
if (deadlineMs !== null && blockMs === 0) {
|
||||
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
|
||||
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
|
||||
if (timeoutMs !== null && blockMs === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,20 @@ export class TurnDaemonLifecycle {
|
||||
|
||||
const nowMs = this.clock.nowMs();
|
||||
const wallNow = new Date(nowMs);
|
||||
const gameClock = await this.stateStore.loadGameClock?.(wallNow);
|
||||
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
|
||||
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
|
||||
await this.stateStore.promotePreopenAtOpening(wallNow);
|
||||
gameClock = await this.stateStore.loadGameClock?.(wallNow);
|
||||
}
|
||||
if (
|
||||
gameClock?.phase &&
|
||||
gameClock.phase !== 'RUNNING' &&
|
||||
gameClock.phase !== 'MANUAL'
|
||||
) {
|
||||
this.status.nextTurnTime = undefined;
|
||||
await this.clock.sleepMs(500);
|
||||
continue;
|
||||
}
|
||||
if (gameClock?.mode === 'manual') {
|
||||
// Ref observes all generals due before one monthly boundary in
|
||||
// a single snapshot. Manual mode advances directly to that
|
||||
@@ -209,7 +222,7 @@ export class TurnDaemonLifecycle {
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
|
||||
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
|
||||
if (command) {
|
||||
await this.handleCommand(command);
|
||||
}
|
||||
@@ -255,7 +268,7 @@ export class TurnDaemonLifecycle {
|
||||
}
|
||||
|
||||
private async waitForResume(): Promise<void> {
|
||||
const command = await this.controlQueue.waitUntil(null);
|
||||
const command = await this.controlQueue.waitFor(null);
|
||||
if (command) {
|
||||
await this.handleCommand(command);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
TurnRunResult,
|
||||
} from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameClockMode } from '@sammo-ts/common';
|
||||
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
|
||||
|
||||
export type {
|
||||
RunReason,
|
||||
@@ -29,6 +29,12 @@ export interface TurnDaemonCommandHandler {
|
||||
|
||||
export interface TurnDaemonCommandExecutionContext {
|
||||
db?: GamePrisma.TransactionClient;
|
||||
clockOperationAuthority?: {
|
||||
kind: 'DAEMON';
|
||||
profileName: string;
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TurnDaemonCommandResponder {
|
||||
@@ -60,7 +66,14 @@ export interface TurnStateStore {
|
||||
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
|
||||
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
|
||||
shouldHaltScheduledRuns?(): Promise<boolean>;
|
||||
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>;
|
||||
loadGameClock?(wallNow?: Date): Promise<{
|
||||
mode: GameClockMode;
|
||||
now: Date;
|
||||
phase?: GameClockPhase;
|
||||
revision?: number;
|
||||
deadlineGeneration?: number;
|
||||
}>;
|
||||
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
|
||||
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
||||
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||
@@ -69,7 +82,7 @@ export interface TurnStateStore {
|
||||
export interface TurnDaemonControlQueue {
|
||||
enqueue(command: TurnDaemonCommand): void;
|
||||
drain(): Promise<TurnDaemonCommand[]>;
|
||||
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||
getDepth(): number;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type InputJsonValue,
|
||||
type TurnEngineEventCreateManyInput,
|
||||
} from '@sammo-ts/infra';
|
||||
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common';
|
||||
import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
|
||||
import {
|
||||
buildScenarioBootstrap,
|
||||
resolveScenarioGeneralDeathMonth,
|
||||
@@ -94,6 +94,17 @@ export const calculateInitialTurnTick = (
|
||||
return clock.addTicks(baseTick, offsetTicks);
|
||||
};
|
||||
|
||||
export const resolveInitialClockPhase = (
|
||||
mode: GameClockMode,
|
||||
seededAtWall: Date,
|
||||
scheduledOpenAtWall: Date
|
||||
): GameClockPhase => {
|
||||
if (mode === 'manual') {
|
||||
return 'MANUAL';
|
||||
}
|
||||
return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING';
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date): string => {
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
return [
|
||||
@@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
// A realtime season prepared before its formal opening must not consume
|
||||
// wall time while users are only allowed to edit reserved commands.
|
||||
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
|
||||
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
|
||||
const initialClock = new GameClock({
|
||||
baseTime: startState.startTime,
|
||||
tick: 0,
|
||||
mode: gameClockMode,
|
||||
wallAnchor: initialClockWallAnchor,
|
||||
turnSeconds: tickSeconds,
|
||||
phase: initialClockPhase,
|
||||
revision: 1,
|
||||
});
|
||||
const initialClockTick = initialClock.dateToTick(now);
|
||||
// The formal opening wall instant is always logical tick zero. PREOPEN may
|
||||
// project signed negative observed ticks, but executable seed schedules are
|
||||
// derived from this zero coordinate rather than from the seed wall time.
|
||||
const initialClockTick = 0;
|
||||
|
||||
const { seed, warnings } = buildScenarioBootstrap({
|
||||
scenario: scenarioDefinition,
|
||||
@@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
}
|
||||
|
||||
worldMeta.hiddenSeed = hiddenSeed;
|
||||
worldMeta.seededAtWall = now.toISOString();
|
||||
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
|
||||
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
|
||||
worldMeta.calendarStart = startState.startTime.toISOString();
|
||||
|
||||
if (install?.preopenAt) {
|
||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||
@@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
clockMode: gameClockMode,
|
||||
clockWallAnchor: initialClock.wallAnchor,
|
||||
lastTurnTick: BigInt(initialClockTick),
|
||||
clockPhase: initialClockPhase,
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
||||
meta: asJson(worldMeta),
|
||||
},
|
||||
@@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
weaponCode: general.weapon ?? 'None',
|
||||
bookCode: general.book ?? 'None',
|
||||
itemCode: general.item ?? 'None',
|
||||
turnTime: new Date(
|
||||
now.getTime() +
|
||||
Math.floor(
|
||||
(typeof general.meta.initialTurnOffsetMicros === 'number'
|
||||
? general.meta.initialTurnOffsetMicros
|
||||
: 0) / 1_000
|
||||
)
|
||||
turnTime: initialClock.tickToDate(
|
||||
calculateInitialTurnTick(
|
||||
initialClock,
|
||||
initialClockTick,
|
||||
typeof general.meta.initialTurnOffsetMicros === 'number'
|
||||
? general.meta.initialTurnOffsetMicros
|
||||
: 0
|
||||
)
|
||||
),
|
||||
turnTick: BigInt(
|
||||
calculateInitialTurnTick(
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
|
||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||
import type { TurnEvent } from './types.js';
|
||||
import { reconcileClockSuspensionInTransaction, type ClockReconciliationResult } from './clockReconciliation.js';
|
||||
|
||||
type ActionableMessageType = 'scout' | 'raiseInvader';
|
||||
|
||||
@@ -17,6 +18,10 @@ interface MessageRow {
|
||||
type: string;
|
||||
time: Date;
|
||||
validUntil: Date;
|
||||
actionType: string;
|
||||
actionStatus: string;
|
||||
createdGameTick: bigint;
|
||||
expiresGameTick: bigint | null;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
@@ -93,15 +98,21 @@ const invalidateMessageIds = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
ids: number[],
|
||||
now: Date
|
||||
now: Date,
|
||||
authoritativeGameTick?: bigint
|
||||
): Promise<void> => {
|
||||
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const resolvedGameTick = authoritativeGameTick ?? BigInt(world.dateToGameTick(now));
|
||||
await db.messageAction.updateMany({
|
||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick },
|
||||
});
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: now,
|
||||
validUntilTick: BigInt(world.dateToGameTick(now)),
|
||||
validUntilTick: resolvedGameTick,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -112,40 +123,53 @@ const validateActor = async (options: {
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
}): Promise<Date> => {
|
||||
}): Promise<{ processingGameTick: number }> => {
|
||||
const actor = options.world.getGeneralById(options.generalId);
|
||||
if (!actor || actor.userId !== options.userId) {
|
||||
throw new Error('messageRespond general owner does not match command user.');
|
||||
}
|
||||
if (!options.requestId) return new Date();
|
||||
if (!options.requestId) {
|
||||
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
|
||||
}
|
||||
const event = await options.db.inputEvent.findUnique({
|
||||
where: { requestId: options.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
|
||||
select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
|
||||
});
|
||||
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
||||
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
||||
throw new Error('ENGINE input event actor or type does not match messageRespond.');
|
||||
}
|
||||
return event.createdAt;
|
||||
const processingGameTick = event.processingGameTick;
|
||||
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
|
||||
throw new Error('messageRespond requires an authoritative processing game tick.');
|
||||
}
|
||||
return { processingGameTick: Number(processingGameTick) };
|
||||
};
|
||||
|
||||
const fetchMessageForUpdate = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
messageId: number,
|
||||
now: Date
|
||||
currentGameTick: number
|
||||
): Promise<MessageRow | null> => {
|
||||
const currentTick = BigInt(world.dateToGameTick(now));
|
||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
||||
FROM message
|
||||
WHERE id = ${messageId}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
SELECT
|
||||
envelope.id,
|
||||
envelope.mailbox,
|
||||
envelope.type,
|
||||
envelope.time,
|
||||
envelope.valid_until AS "validUntil",
|
||||
action.action_type AS "actionType",
|
||||
action.status AS "actionStatus",
|
||||
action.created_game_tick AS "createdGameTick",
|
||||
action.expires_game_tick AS "expiresGameTick",
|
||||
envelope.message
|
||||
FROM message AS envelope
|
||||
JOIN message_action AS action ON action.message_id = envelope.id
|
||||
WHERE envelope.id = ${messageId}
|
||||
AND action.status = 'PENDING'
|
||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
FOR UPDATE OF envelope, action
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
@@ -164,7 +188,7 @@ const respondToScout = async (options: {
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
@@ -185,18 +209,17 @@ const respondToScout = async (options: {
|
||||
}
|
||||
|
||||
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
SELECT id
|
||||
FROM message
|
||||
WHERE mailbox = ${payload.src.generalId}
|
||||
AND type = 'private'
|
||||
AND dest = mailbox
|
||||
AND id <> ${row.id}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
AND message->'option'->>'action' = 'scout'
|
||||
FOR UPDATE
|
||||
SELECT envelope.id
|
||||
FROM message AS envelope
|
||||
JOIN message_action AS action ON action.message_id = envelope.id
|
||||
WHERE envelope.mailbox = ${payload.src.generalId}
|
||||
AND envelope.type = 'private'
|
||||
AND envelope.dest = envelope.mailbox
|
||||
AND envelope.id <> ${row.id}
|
||||
AND action.status = 'PENDING'
|
||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
AND action.action_type = 'scout'
|
||||
FOR UPDATE OF envelope, action
|
||||
`);
|
||||
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
||||
world.queueMessage({
|
||||
@@ -251,6 +274,18 @@ const respondToRaiseInvader = async (options: {
|
||||
payload: MessagePayload;
|
||||
now: Date;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
clockOperationAuthority?: {
|
||||
kind: 'DAEMON';
|
||||
profileName: string;
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
};
|
||||
reconcileUnificationWait?: (input: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
profileName: string;
|
||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||
}) => Promise<ClockReconciliationResult>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
@@ -272,6 +307,22 @@ const respondToRaiseInvader = async (options: {
|
||||
if (!reservedTurns) {
|
||||
throw new Error('RaiseInvader message response requires the reserved-turn store.');
|
||||
}
|
||||
const suspensionId =
|
||||
typeof state.meta.unificationClockSuspensionId === 'string' ? state.meta.unificationClockSuspensionId : null;
|
||||
if (!suspensionId || !options.clockOperationAuthority) {
|
||||
throw new Error('RaiseInvader requires a daemon-authorized UNIFICATION_WAIT suspension.');
|
||||
}
|
||||
const reconcile =
|
||||
options.reconcileUnificationWait ??
|
||||
((input) => reconcileClockSuspensionInTransaction({ ...input, allowUnificationWait: true }));
|
||||
const alignment = await reconcile({
|
||||
db,
|
||||
suspensionId,
|
||||
profileName: options.clockOperationAuthority.profileName,
|
||||
authority: options.clockOperationAuthority,
|
||||
});
|
||||
world.applyClockReconciliation(alignment);
|
||||
const alignedState = world.getState();
|
||||
const args = asRecord(payload.option).args;
|
||||
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
||||
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
||||
@@ -281,22 +332,44 @@ const respondToRaiseInvader = async (options: {
|
||||
reservedTurns,
|
||||
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
clockWallNow: alignment.resumeWallAt,
|
||||
});
|
||||
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
||||
await handler(
|
||||
args,
|
||||
{
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
startyear: asNumber(state.meta.startYear, state.currentYear),
|
||||
year: alignedState.currentYear,
|
||||
month: alignedState.currentMonth,
|
||||
startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear),
|
||||
currentEventID: 0,
|
||||
// Ref uses the frozen game_env.turntime while unification is paused.
|
||||
// `now` is the realtime game projection and can be hours ahead after
|
||||
// a long response wait, delaying every newly summoned invader turn.
|
||||
turnTime: state.lastTurnTime,
|
||||
// The exact reconciliation snapshot is the authority even when the
|
||||
// turn rate stays unchanged. Using the pre-reconciliation cursor here
|
||||
// would create immediately overdue invader turns after a long wait.
|
||||
turnTime: alignedState.lastTurnTime,
|
||||
},
|
||||
event
|
||||
);
|
||||
const resolvedGameTick = BigInt(alignment.alignedTick);
|
||||
if (resolvedGameTick < row.createdGameTick) {
|
||||
throw new Error(
|
||||
`RaiseInvader resolved tick ${resolvedGameTick} precedes prompt tick ${row.createdGameTick}; clock authority is inconsistent.`
|
||||
);
|
||||
}
|
||||
const promptRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
SELECT message_id AS id
|
||||
FROM message_action
|
||||
WHERE action_type = 'raiseInvader'
|
||||
AND status = 'PENDING'
|
||||
AND created_game_tick = ${row.createdGameTick}
|
||||
FOR UPDATE
|
||||
`);
|
||||
await invalidateMessageIds(
|
||||
db,
|
||||
world,
|
||||
promptRows.map(({ id }) => id),
|
||||
world.gameTickToDate(alignment.alignedTick),
|
||||
resolvedGameTick
|
||||
);
|
||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||
};
|
||||
|
||||
@@ -311,14 +384,27 @@ export const respondToActionableMessage = async (options: {
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
clockOperationAuthority?: {
|
||||
kind: 'DAEMON';
|
||||
profileName: string;
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
};
|
||||
reconcileUnificationWait?: (input: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
profileName: string;
|
||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||
}) => Promise<ClockReconciliationResult>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const acceptedAt = await validateActor(options);
|
||||
const now = options.world.getGameNow(acceptedAt);
|
||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||
const accepted = await validateActor(options);
|
||||
const now = options.world.gameTickToDate(accepted.processingGameTick);
|
||||
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
|
||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||
const payload = parsePayload(row.message);
|
||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||
const action = asRecord(payload.option).action;
|
||||
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
|
||||
if (action === 'scout') {
|
||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||
}
|
||||
|
||||
@@ -488,6 +488,23 @@ export class GeneralAI {
|
||||
return message;
|
||||
}
|
||||
|
||||
syncGeneralAfterNationAction(general: TurnGeneral): void {
|
||||
const meta = { ...general.meta } as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'fullLeadership',
|
||||
'fullStrength',
|
||||
'fullIntelligence',
|
||||
'effectiveLeadership',
|
||||
'effectiveStrength',
|
||||
'effectiveIntelligence',
|
||||
]) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.general.meta, key)) {
|
||||
meta[key] = this.general.meta[key];
|
||||
}
|
||||
}
|
||||
this.general = { ...general, meta: meta as TurnGeneral['meta'] };
|
||||
}
|
||||
|
||||
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||
this.updateInstance();
|
||||
if (!this.worldRef) {
|
||||
@@ -1198,6 +1215,13 @@ export class GeneralAI {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const oldChief = this.chiefGenerals[11];
|
||||
if (oldChief && oldChief.id !== candidate.id) {
|
||||
oldChief.officerLevel = 1;
|
||||
oldChief.meta = { ...oldChief.meta, officer_city: 0 };
|
||||
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
||||
effectiveOfficerLevel.set(oldChief.id, 1);
|
||||
}
|
||||
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
|
||||
candidate.officerLevel = 11;
|
||||
candidate.meta = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GeneralAI } from '../core.js';
|
||||
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js';
|
||||
import { resolveDiplomacyMessageValidUntilTick } from '@sammo-ts/logic';
|
||||
import { resolveDiplomacyMessageValidUntilTick, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
|
||||
import { isNeighbor } from '@sammo-ts/logic/world/distance.js';
|
||||
import { resolveNationIncome } from './helpers.js';
|
||||
|
||||
@@ -99,7 +99,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
|
||||
const maxEndMonth = joinYearMonth(resolveNonAggressionMaxEndYear(ai.world.currentYear), 12);
|
||||
const [targetYear, targetMonth] = parseYearMonth(Math.min(Math.floor(yearMonth + diplomatMonth), maxEndMonth));
|
||||
const result = ai.buildNationCandidate(
|
||||
'che_불가침제의',
|
||||
{ destNationId, year: targetYear, month: targetMonth },
|
||||
|
||||
@@ -61,10 +61,9 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
|
||||
NPC전방발령: true,
|
||||
};
|
||||
|
||||
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital';
|
||||
export type UserRulerAutomationFeature = 'war' | 'promotion' | 'finance' | 'capital';
|
||||
|
||||
const USER_RULER_AUTOMATION_META_KEY = {
|
||||
diplomacy: 'use_auto_nation_diplomacy',
|
||||
war: 'use_auto_nation_war',
|
||||
promotion: 'use_auto_nation_promotion',
|
||||
finance: 'use_auto_nation_finance',
|
||||
@@ -72,7 +71,6 @@ const USER_RULER_AUTOMATION_META_KEY = {
|
||||
} as const satisfies Record<UserRulerAutomationFeature, string>;
|
||||
|
||||
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
|
||||
불가침제의: 'diplomacy',
|
||||
선전포고: 'war',
|
||||
천도: 'capital',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { GameClock, parseGameClockPhase } from '@sammo-ts/common';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
export interface ClockProjectionRedis {
|
||||
get(key: string): Promise<string | null>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClaimedOutboxRow {
|
||||
id: bigint;
|
||||
}
|
||||
|
||||
interface DbWallRow {
|
||||
wallNow: Date;
|
||||
}
|
||||
|
||||
interface ProjectionPayload {
|
||||
version: 1;
|
||||
profileName: string;
|
||||
suspensionId: string;
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
deadlineGeneration: number;
|
||||
shiftTicks: number;
|
||||
projectionDeltaMilliseconds: number;
|
||||
clockBaseTime: string;
|
||||
ticksPerSecond: number;
|
||||
}
|
||||
|
||||
interface TournamentProjectionState {
|
||||
stage?: number;
|
||||
nextAt?: string;
|
||||
nextTick?: number;
|
||||
bettingCloseAt?: string;
|
||||
bettingCloseTick?: number;
|
||||
clockRevision?: number;
|
||||
deadlineGeneration?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const APPLY_CLOCK_PROJECTION_SCRIPT = `
|
||||
local active = redis.call('GET', KEYS[1])
|
||||
if active == ARGV[2] then
|
||||
if redis.call('GET', KEYS[5]) == ARGV[4] and redis.call('GET', KEYS[2]) == ARGV[3] then
|
||||
return 2
|
||||
end
|
||||
return -3
|
||||
end
|
||||
if active and active ~= ARGV[1] then
|
||||
return -1
|
||||
end
|
||||
if ARGV[5] ~= '__NONE__' and redis.call('GET', KEYS[4]) ~= ARGV[5] then
|
||||
return -2
|
||||
end
|
||||
redis.call('DEL', KEYS[3])
|
||||
local count = tonumber(ARGV[7])
|
||||
local offset = 8
|
||||
for index = 1, count do
|
||||
redis.call('ZADD', KEYS[3], ARGV[offset], ARGV[offset + 1])
|
||||
offset = offset + 2
|
||||
end
|
||||
if ARGV[5] ~= '__NONE__' then
|
||||
redis.call('SET', KEYS[4], ARGV[6])
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[2])
|
||||
redis.call('SET', KEYS[2], ARGV[3])
|
||||
redis.call('SET', KEYS[5], ARGV[4])
|
||||
redis.call('SET', KEYS[6], 'RUNNING')
|
||||
return 1
|
||||
`;
|
||||
|
||||
const safeInteger = (value: unknown, label: string): number => {
|
||||
const result = typeof value === 'bigint' ? Number(value) : value;
|
||||
if (typeof result !== 'number' || !Number.isSafeInteger(result)) {
|
||||
throw new Error(`${label} must be a safe integer.`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalize(item)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
|
||||
|
||||
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
|
||||
|
||||
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
||||
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
|
||||
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
|
||||
`);
|
||||
if (!rows[0]?.wallNow) throw new Error('Failed to read PostgreSQL wall time for the projection outbox.');
|
||||
return rows[0].wallNow;
|
||||
};
|
||||
|
||||
const parsePayload = (value: GamePrisma.JsonValue): ProjectionPayload => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Clock projection outbox payload must be an object.');
|
||||
}
|
||||
const payload = value as Record<string, unknown>;
|
||||
if (payload.version !== 1 || typeof payload.profileName !== 'string' || typeof payload.suspensionId !== 'string') {
|
||||
throw new Error('Clock projection outbox payload identity is invalid.');
|
||||
}
|
||||
if (typeof payload.clockBaseTime !== 'string') {
|
||||
throw new Error('Clock projection outbox is missing its projection base.');
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
profileName: payload.profileName,
|
||||
suspensionId: payload.suspensionId,
|
||||
sourceRevision: safeInteger(payload.sourceRevision, 'sourceRevision'),
|
||||
targetRevision: safeInteger(payload.targetRevision, 'targetRevision'),
|
||||
deadlineGeneration: safeInteger(payload.deadlineGeneration, 'deadlineGeneration'),
|
||||
shiftTicks: safeInteger(payload.shiftTicks, 'shiftTicks'),
|
||||
projectionDeltaMilliseconds: safeInteger(payload.projectionDeltaMilliseconds, 'projectionDeltaMilliseconds'),
|
||||
clockBaseTime: payload.clockBaseTime,
|
||||
ticksPerSecond: safeInteger(payload.ticksPerSecond, 'ticksPerSecond'),
|
||||
};
|
||||
};
|
||||
|
||||
const claimNext = async (db: GamePrismaClient, workerId: string) =>
|
||||
db.$transaction(async (transaction) => {
|
||||
const rows = await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
|
||||
SELECT id
|
||||
FROM clock_projection_outbox
|
||||
WHERE available_at <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
AND (
|
||||
status IN ('PENDING', 'FAILED')
|
||||
OR (status = 'APPLYING' AND locked_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds')
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`);
|
||||
const id = rows[0]?.id;
|
||||
if (id === undefined) return null;
|
||||
const lockedAt = await readDbWall(transaction);
|
||||
return transaction.clockProjectionOutbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPLYING',
|
||||
attempts: { increment: 1 },
|
||||
lockedAt,
|
||||
lockedBy: workerId,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const projectTournamentState = (
|
||||
raw: string | null,
|
||||
payload: ProjectionPayload,
|
||||
clock: GameClock
|
||||
): { expected: string; next: string } | null => {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as TournamentProjectionState;
|
||||
const active = typeof parsed.stage === 'number' && parsed.stage > 0;
|
||||
if (active && parsed.nextAt && !Number.isSafeInteger(parsed.nextTick)) {
|
||||
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
|
||||
}
|
||||
if (active && parsed.bettingCloseAt && !Number.isSafeInteger(parsed.bettingCloseTick)) {
|
||||
throw new Error('Active tournament bettingCloseAt lacks the authoritative bettingCloseTick dual-write.');
|
||||
}
|
||||
const next: TournamentProjectionState = {
|
||||
...parsed,
|
||||
clockRevision: payload.targetRevision,
|
||||
deadlineGeneration: payload.deadlineGeneration,
|
||||
};
|
||||
if (Number.isSafeInteger(parsed.nextTick)) {
|
||||
next.nextTick = parsed.nextTick! + payload.shiftTicks;
|
||||
next.nextAt = clock.tickToDate(next.nextTick).toISOString();
|
||||
}
|
||||
if (Number.isSafeInteger(parsed.bettingCloseTick)) {
|
||||
next.bettingCloseTick = parsed.bettingCloseTick! + payload.shiftTicks;
|
||||
next.bettingCloseAt = clock.tickToDate(next.bettingCloseTick).toISOString();
|
||||
}
|
||||
return { expected: raw, next: JSON.stringify(next) };
|
||||
};
|
||||
|
||||
const recordFailure = async (db: GamePrismaClient, outboxId: bigint, error: unknown): Promise<void> => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE clock_projection_outbox
|
||||
SET status = 'FAILED',
|
||||
locked_at = NULL,
|
||||
locked_by = NULL,
|
||||
last_error = ${message.slice(0, 4_000)},
|
||||
available_at = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '1 second'
|
||||
WHERE id = ${outboxId} AND status = 'APPLYING'
|
||||
`);
|
||||
};
|
||||
|
||||
export const applyNextClockProjection = async (options: {
|
||||
db: GamePrismaClient;
|
||||
redis: ClockProjectionRedis;
|
||||
workerId: string;
|
||||
}): Promise<'IDLE' | 'APPLIED' | 'RECOVERED'> => {
|
||||
if (!options.workerId.trim()) throw new Error('Clock projection worker ID is required.');
|
||||
const outbox = await claimNext(options.db, options.workerId);
|
||||
if (!outbox) return 'IDLE';
|
||||
try {
|
||||
const payload = parsePayload(outbox.payload);
|
||||
if (checksum(outbox.payload) !== outbox.checksum) {
|
||||
throw new Error('Clock projection outbox checksum verification failed.');
|
||||
}
|
||||
if (outbox.targetRevision !== BigInt(payload.targetRevision)) {
|
||||
throw new Error('Clock projection payload revision differs from its outbox row.');
|
||||
}
|
||||
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: outbox.worldStateId } });
|
||||
if (
|
||||
parseGameClockPhase(world.clockPhase) !== 'RECONCILING' ||
|
||||
world.clockRevision !== outbox.targetRevision ||
|
||||
world.deadlineGeneration !== BigInt(payload.deadlineGeneration) ||
|
||||
!world.clockBaseTime
|
||||
) {
|
||||
throw new Error('Clock projection DB phase/revision/generation fence failed.');
|
||||
}
|
||||
const clock = new GameClock({
|
||||
baseTime: new Date(payload.clockBaseTime),
|
||||
tick: safeInteger(world.clockTick, 'world clock tick'),
|
||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: world.clockWallAnchor ?? new Date(),
|
||||
turnSeconds: world.tickSeconds,
|
||||
phase: 'RECONCILING',
|
||||
revision: payload.targetRevision,
|
||||
});
|
||||
if (clock.ticksPerSecond !== payload.ticksPerSecond) {
|
||||
throw new Error('Clock projection rate differs from the durable outbox payload.');
|
||||
}
|
||||
const auctions = await options.db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, closeTick: true },
|
||||
});
|
||||
const timers = auctions.map((auction) => {
|
||||
if (auction.closeTick === null) {
|
||||
throw new Error(`Active auction ${auction.id} lacks closeTick during projection rebuild.`);
|
||||
}
|
||||
return { score: safeInteger(auction.closeTick, `auction ${auction.id} closeTick`), id: String(auction.id) };
|
||||
});
|
||||
const prefix = `sammo:${payload.profileName}`;
|
||||
const tournamentKey = `${prefix}:tournament:state`;
|
||||
const tournament = projectTournamentState(await options.redis.get(tournamentKey), payload, clock);
|
||||
const result = await options.redis.eval(APPLY_CLOCK_PROJECTION_SCRIPT, {
|
||||
keys: [
|
||||
`${prefix}:clock:active-revision`,
|
||||
`${prefix}:clock:deadline-generation`,
|
||||
`${prefix}:auction:timer`,
|
||||
tournamentKey,
|
||||
`${prefix}:clock:projection-checksum`,
|
||||
`${prefix}:clock:phase`,
|
||||
],
|
||||
arguments: [
|
||||
String(payload.sourceRevision),
|
||||
String(payload.targetRevision),
|
||||
String(payload.deadlineGeneration),
|
||||
outbox.checksum,
|
||||
tournament?.expected ?? '__NONE__',
|
||||
tournament?.next ?? '__NONE__',
|
||||
String(timers.length),
|
||||
...timers.flatMap(({ score, id }) => [String(score), id]),
|
||||
],
|
||||
});
|
||||
const applied = Number(result);
|
||||
if (applied === -1) throw new Error('Redis active clock revision does not match the outbox source revision.');
|
||||
if (applied === -2) throw new Error('Redis tournament state changed while rebuilding its projection.');
|
||||
if (applied === -3) throw new Error('Redis target revision exists without the expected projection checksum.');
|
||||
if (applied !== 1 && applied !== 2)
|
||||
throw new Error(`Unexpected Redis clock projection result: ${String(result)}`);
|
||||
|
||||
await options.db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM world_state WHERE id = ${outbox.worldStateId} FOR UPDATE
|
||||
`);
|
||||
const finalized = await transaction.worldState.updateMany({
|
||||
where: {
|
||||
id: outbox.worldStateId,
|
||||
clockPhase: 'RECONCILING',
|
||||
clockRevision: outbox.targetRevision,
|
||||
deadlineGeneration: BigInt(payload.deadlineGeneration),
|
||||
},
|
||||
data: { clockPhase: 'RUNNING' },
|
||||
});
|
||||
if (finalized.count !== 1) {
|
||||
throw new Error('Clock projection final RUNNING transition fence failed.');
|
||||
}
|
||||
const appliedAt = await readDbWall(transaction);
|
||||
await transaction.clockProjectionOutbox.update({
|
||||
where: { id: outbox.id },
|
||||
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
|
||||
});
|
||||
if (outbox.suspensionId) {
|
||||
await transaction.clockSuspension.update({
|
||||
where: { id: outbox.suspensionId },
|
||||
data: { status: 'APPLIED' },
|
||||
});
|
||||
}
|
||||
});
|
||||
return applied === 2 ? 'RECOVERED' : 'APPLIED';
|
||||
} catch (error) {
|
||||
await recordFailure(options.db, outbox.id, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadClockReconciliationReadiness = async (db: GamePrismaClient) => {
|
||||
const world = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
|
||||
});
|
||||
const incompleteOutboxCount = await db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } });
|
||||
if (!world) {
|
||||
return { ready: false, phase: null, revision: null, deadlineGeneration: null, incompleteOutboxCount };
|
||||
}
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
return {
|
||||
ready: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
|
||||
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
|
||||
phase,
|
||||
revision: safeInteger(world.clockRevision, 'clock revision'),
|
||||
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
|
||||
incompleteOutboxCount,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,1044 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
MAX_SAFE_GAME_TICK,
|
||||
GameClock,
|
||||
buildClockAlignmentPlan,
|
||||
parseClockAlignmentPolicy,
|
||||
parseGameClockPhase,
|
||||
type ClockAlignmentPolicy,
|
||||
} from '@sammo-ts/common';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
export type ClockSuspensionSource = 'MAINTENANCE' | 'OPEN_DELAY' | 'UNIFICATION_WAIT' | 'RECOVERY';
|
||||
|
||||
export type ClockOperationAuthority =
|
||||
| { kind: 'DAEMON'; profileName: string; ownerId: string; fencingEpoch: bigint }
|
||||
| { kind: 'OFFLINE'; profileName: string; reason: string };
|
||||
|
||||
export interface ClockSuspensionResult {
|
||||
suspensionId: string;
|
||||
phase: 'SUSPENDED';
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
cutTick: number;
|
||||
cutWallAt: Date;
|
||||
}
|
||||
|
||||
export interface ClockReconciliationResult {
|
||||
suspensionId: string;
|
||||
phase: 'RECONCILING';
|
||||
sourceRevision: number;
|
||||
targetRevision: number;
|
||||
deadlineGeneration: number;
|
||||
gapTicks: number;
|
||||
catchUpTicks: number;
|
||||
shiftTicks: number;
|
||||
alignedTick: number;
|
||||
resumeWallAt: Date;
|
||||
}
|
||||
|
||||
interface DbWallRow {
|
||||
wallNow: Date;
|
||||
}
|
||||
|
||||
interface LeaseFenceRow {
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface TextIdRow {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ParticipantSnapshot {
|
||||
key: string;
|
||||
policy: 'SHIFT' | 'KEEP' | 'REBUILD';
|
||||
checksum: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
const safeNumber = (value: bigint, label: string): number => {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new Error(`${label} is outside the JavaScript safe integer range: ${value}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalize(item)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
|
||||
|
||||
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
|
||||
|
||||
const aggregateChecksum = (participants: readonly ParticipantSnapshot[]): string =>
|
||||
checksum(participants.map(({ key, policy, checksum: value, count }) => ({ key, policy, checksum: value, count })));
|
||||
|
||||
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
||||
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
|
||||
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
|
||||
`);
|
||||
const wallNow = rows[0]?.wallNow;
|
||||
if (!wallNow || Number.isNaN(wallNow.getTime())) {
|
||||
throw new Error('Failed to read the PostgreSQL wall clock.');
|
||||
}
|
||||
return wallNow;
|
||||
};
|
||||
|
||||
export const readClockDatabaseWall = readDbWall;
|
||||
|
||||
const isRetryableSerializableClockError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const value = error as { code?: unknown; message?: unknown; meta?: unknown };
|
||||
const meta =
|
||||
value.meta && typeof value.meta === 'object'
|
||||
? (value.meta as { code?: unknown; message?: unknown })
|
||||
: undefined;
|
||||
const code = typeof value.code === 'string' ? value.code : '';
|
||||
const databaseCode = typeof meta?.code === 'string' ? meta.code : '';
|
||||
const message = [value.message, meta?.message]
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.join(' ');
|
||||
return (
|
||||
code === 'P2034' ||
|
||||
code === '40001' ||
|
||||
databaseCode === '40001' ||
|
||||
message.includes('40001') ||
|
||||
message.includes('could not serialize access')
|
||||
);
|
||||
};
|
||||
|
||||
const runSerializableClockOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= maxAttempts || !isRetryableSerializableClockError(error)) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, attempt * 10));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAuthority = async (db: GamePrisma.TransactionClient, authority: ClockOperationAuthority): Promise<void> => {
|
||||
const rows = await db.$queryRaw<LeaseFenceRow[]>(GamePrisma.sql`
|
||||
SELECT owner_id AS "ownerId",
|
||||
fencing_epoch AS "fencingEpoch",
|
||||
lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid
|
||||
FROM turn_daemon_lease
|
||||
WHERE profile = ${authority.profileName}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const lease = rows[0];
|
||||
if (authority.kind === 'OFFLINE') {
|
||||
if (!authority.reason.trim()) {
|
||||
throw new Error('Offline clock operations require an audit reason.');
|
||||
}
|
||||
if (lease?.valid) {
|
||||
throw new Error(`Clock operation requires the ${authority.profileName} daemon lease to be offline.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!lease?.valid || lease.ownerId !== authority.ownerId || lease.fencingEpoch !== authority.fencingEpoch) {
|
||||
throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
|
||||
const rows = await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE
|
||||
`);
|
||||
if (rows.length !== 1) {
|
||||
throw new Error(`Clock reconciliation requires exactly one world_state row; found ${rows.length}.`);
|
||||
}
|
||||
return rows[0]!.id;
|
||||
};
|
||||
|
||||
const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise<void> => {
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM auction
|
||||
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT bid.id
|
||||
FROM auction_bid AS bid
|
||||
JOIN auction ON auction.id = bid.auction_id
|
||||
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
ORDER BY bid.id FOR UPDATE OF bid
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT message_id AS id FROM message_action
|
||||
WHERE status = 'PENDING'
|
||||
ORDER BY message_id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM select_pool WHERE general_id IS NULL ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<TextIdRow[]>(GamePrisma.sql`
|
||||
SELECT owner_user_id AS id FROM select_npc_token ORDER BY owner_user_id FOR UPDATE
|
||||
`);
|
||||
};
|
||||
|
||||
const readParticipantSnapshots = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
worldStateId: number,
|
||||
cutTick: bigint
|
||||
): Promise<ParticipantSnapshot[]> => {
|
||||
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
|
||||
await Promise.all([
|
||||
db.worldState.findUniqueOrThrow({
|
||||
where: { id: worldStateId },
|
||||
select: {
|
||||
clockTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
lastTurnTick: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
db.general.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
||||
}),
|
||||
db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||
}),
|
||||
db.auctionBid.findMany({
|
||||
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, occurredGameTick: true },
|
||||
}),
|
||||
db.messageAction.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { messageId: 'asc' },
|
||||
select: {
|
||||
messageId: true,
|
||||
createdGameTick: true,
|
||||
expiresGameTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
}),
|
||||
db.inheritanceLedger.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
|
||||
}),
|
||||
db.votePoll.findMany({
|
||||
where: { closedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, startTick: true, endTick: true },
|
||||
}),
|
||||
db.selectPoolEntry.findMany({
|
||||
where: { generalId: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, reservedUntilTick: true },
|
||||
}),
|
||||
db.npcSelectionToken.findMany({
|
||||
orderBy: { ownerUserId: 'asc' },
|
||||
select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true },
|
||||
}),
|
||||
db.inputEvent.findMany({
|
||||
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
|
||||
}),
|
||||
]);
|
||||
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
|
||||
key,
|
||||
policy,
|
||||
checksum: checksum(rows),
|
||||
count: rows.length,
|
||||
});
|
||||
const meta = world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta : {};
|
||||
return [
|
||||
snapshot('world-clock', 'REBUILD', [
|
||||
{
|
||||
clockTick: world.clockTick,
|
||||
clockRevision: world.clockRevision,
|
||||
deadlineGeneration: world.deadlineGeneration,
|
||||
},
|
||||
]),
|
||||
snapshot('turn-cursor', 'SHIFT', [{ lastTurnTick: world.lastTurnTick }]),
|
||||
snapshot(
|
||||
'general-next-turn',
|
||||
'SHIFT',
|
||||
generals.map(({ id, turnTick }) => ({ id, turnTick }))
|
||||
),
|
||||
snapshot(
|
||||
'general-recent-war-occurrence',
|
||||
'KEEP',
|
||||
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
|
||||
),
|
||||
snapshot(
|
||||
'selection-reselection-deadline',
|
||||
'SHIFT',
|
||||
generals.flatMap(({ id, meta: generalMeta }) => {
|
||||
const raw =
|
||||
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
|
||||
? Reflect.get(generalMeta, 'next_change_tick')
|
||||
: null;
|
||||
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'auction-open-occurrence',
|
||||
'KEEP',
|
||||
auctions.map(({ id, openTick }) => ({ id, openTick }))
|
||||
),
|
||||
snapshot(
|
||||
'auction-deadline',
|
||||
'SHIFT',
|
||||
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
||||
),
|
||||
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
|
||||
snapshot(
|
||||
'auction-finalizing-recovery',
|
||||
'REBUILD',
|
||||
auctions.map(({ id, status }) => ({ id, status }))
|
||||
),
|
||||
snapshot(
|
||||
'message-action-occurrence',
|
||||
'KEEP',
|
||||
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-action-expiry',
|
||||
'SHIFT',
|
||||
messages
|
||||
.filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick)
|
||||
.map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-action-clock-coordinate',
|
||||
'REBUILD',
|
||||
messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({
|
||||
messageId,
|
||||
clockRevision,
|
||||
deadlineGeneration,
|
||||
}))
|
||||
),
|
||||
snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects),
|
||||
snapshot(
|
||||
'vote-start-occurrence',
|
||||
'KEEP',
|
||||
votes.map(({ id, startTick }) => ({ id, startTick }))
|
||||
),
|
||||
snapshot(
|
||||
'vote-end-deadline',
|
||||
'SHIFT',
|
||||
votes.map(({ id, endTick }) => ({ id, endTick }))
|
||||
),
|
||||
snapshot('select-pool-reservation', 'SHIFT', pool),
|
||||
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
||||
snapshot('daemon-command-coordinate', 'KEEP', commands),
|
||||
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
||||
{
|
||||
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
|
||||
turntime: Reflect.get(meta, 'turntime'),
|
||||
starttime: Reflect.get(meta, 'starttime'),
|
||||
tnmt_time: Reflect.get(meta, 'tnmt_time'),
|
||||
},
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
const persistInitialParticipants = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
suspensionId: string,
|
||||
participants: readonly ParticipantSnapshot[]
|
||||
): Promise<void> => {
|
||||
for (const participant of participants) {
|
||||
await db.clockReconciliationParticipant.create({
|
||||
data: {
|
||||
suspensionId,
|
||||
participantKey: participant.key,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: participant.checksum,
|
||||
affectedCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Locks every registered participant before an enclosing transaction mutates
|
||||
* the world into a suspended state. The caller must already hold the daemon,
|
||||
* clock-operation, general-access, and world-row lock prefix.
|
||||
*/
|
||||
export const prepareClockSuspensionUnderHeldLocks = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
cutTick: number;
|
||||
cutWallAt?: Date;
|
||||
}): Promise<{ cutWallAt: Date }> => {
|
||||
if (!Number.isSafeInteger(options.cutTick)) {
|
||||
throw new Error(`Clock suspension cut tick is outside the safe integer range: ${options.cutTick}.`);
|
||||
}
|
||||
const cutWallAt = options.cutWallAt ? new Date(options.cutWallAt.getTime()) : await readDbWall(options.db);
|
||||
await lockParticipants(options.db, BigInt(options.cutTick));
|
||||
return { cutWallAt };
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the ledger after all suspension-boundary gameplay writes have been
|
||||
* staged in the same transaction. Lock acquisition belongs to
|
||||
* prepareClockSuspensionUnderHeldLocks and must happen first.
|
||||
*/
|
||||
export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
worldStateId: number;
|
||||
profileName: string;
|
||||
source: ClockSuspensionSource;
|
||||
cutTick: number;
|
||||
cutWallAt: Date;
|
||||
rateTicksPerSecond: number;
|
||||
sourceRevision: number;
|
||||
policy?: ClockAlignmentPolicy;
|
||||
catchUpTicks?: number;
|
||||
}): Promise<void> => {
|
||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
||||
}
|
||||
const policy = options.policy ?? 'EXACT';
|
||||
const catchUpTicks = options.catchUpTicks ?? 0;
|
||||
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
|
||||
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
|
||||
}
|
||||
const existing = await options.db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.worldStateId !== options.worldStateId ||
|
||||
existing.source !== options.source ||
|
||||
existing.sourceRevision !== BigInt(options.sourceRevision)
|
||||
) {
|
||||
throw new Error(`Clock suspension ID ${options.suspensionId} is already bound to another operation.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: options.worldStateId } });
|
||||
if (
|
||||
parseGameClockPhase(world.clockPhase) !== 'SUSPENDED' ||
|
||||
world.clockRevision !== BigInt(options.sourceRevision)
|
||||
) {
|
||||
throw new Error('Clock suspension ledger requires a matching durable SUSPENDED world revision.');
|
||||
}
|
||||
const participants = await readParticipantSnapshots(options.db, options.worldStateId, BigInt(options.cutTick));
|
||||
await options.db.clockSuspension.create({
|
||||
data: {
|
||||
id: options.suspensionId,
|
||||
worldStateId: options.worldStateId,
|
||||
source: options.source,
|
||||
policy,
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: BigInt(options.sourceRevision),
|
||||
targetRevision: BigInt(options.sourceRevision + 1),
|
||||
cutTick: BigInt(options.cutTick),
|
||||
cutWallAt: options.cutWallAt,
|
||||
rateTicksPerSecond: options.rateTicksPerSecond,
|
||||
catchUpTicks: BigInt(catchUpTicks),
|
||||
participantChecksumBefore: aggregateChecksum(participants),
|
||||
detail: asJson({ authority: 'DAEMON', profileName: options.profileName }),
|
||||
},
|
||||
});
|
||||
await persistInitialParticipants(options.db, options.suspensionId, participants);
|
||||
};
|
||||
|
||||
const shiftMetaDate = (value: unknown, deltaMilliseconds: number): unknown => {
|
||||
if (typeof value !== 'string' || !value.trim()) return value;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Date(parsed.getTime() + deltaMilliseconds).toISOString();
|
||||
};
|
||||
|
||||
const shiftedMeta = (value: GamePrisma.JsonValue, deltaMilliseconds: number): GamePrisma.InputJsonValue => {
|
||||
const meta = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
for (const key of ['lastTurnTime', 'turntime', 'starttime', 'tnmt_time'] as const) {
|
||||
if (Object.hasOwn(meta, key)) {
|
||||
Reflect.set(meta, key, shiftMetaDate(Reflect.get(meta, key), deltaMilliseconds));
|
||||
}
|
||||
}
|
||||
return asJson(meta);
|
||||
};
|
||||
|
||||
const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTicks: number): void => {
|
||||
if (!Number.isSafeInteger(shiftTicks) || shiftTicks < 0) {
|
||||
throw new Error(`Invalid reconciliation shift: ${shiftTicks}`);
|
||||
}
|
||||
// Checksums retain stringified values for audit; actual row ranges are
|
||||
// checked by PostgreSQL BIGINT and the world aligned tick is checked by the
|
||||
// shared GameClock plan. The sentinel expiry is deliberately never shifted.
|
||||
if (participants.some((participant) => !participant.checksum)) {
|
||||
throw new Error('Participant snapshot is incomplete.');
|
||||
}
|
||||
};
|
||||
|
||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||
const shift = BigInt(shiftTicks);
|
||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
||||
FROM general
|
||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
||||
`),
|
||||
db.auction.aggregate({
|
||||
_max: { closeTick: true },
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
||||
}),
|
||||
db.messageAction.aggregate({
|
||||
_max: { expiresGameTick: true },
|
||||
where: { status: 'PENDING', expiresGameTick: { not: null } },
|
||||
}),
|
||||
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
||||
db.selectPoolEntry.aggregate({
|
||||
_max: { reservedUntilTick: true },
|
||||
where: { generalId: null, reservedUntilTick: { not: null } },
|
||||
}),
|
||||
db.npcSelectionToken.aggregate({ _max: { validUntilTick: true }, where: { validUntilTick: { not: null } } }),
|
||||
db.npcSelectionToken.aggregate({
|
||||
_max: { pickMoreFromTick: true },
|
||||
where: { pickMoreFromTick: { not: null } },
|
||||
}),
|
||||
]);
|
||||
const values: Array<[string, bigint | null]> = [
|
||||
['general.turn_tick', general._max.turnTick],
|
||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||
['auction.close_tick', auction._max.closeTick],
|
||||
['message_action.expires_game_tick', message._max.expiresGameTick],
|
||||
['vote_poll.end_tick', vote._max.endTick],
|
||||
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
||||
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
||||
['select_npc_token.pick_more_from_tick', npcMore._max.pickMoreFromTick],
|
||||
];
|
||||
for (const [label, value] of values) {
|
||||
if (value !== null && value > maximum) {
|
||||
throw new Error(`${label} would exceed the safe game tick range after reconciliation.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyParticipantShift = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
worldStateId: number,
|
||||
cutTick: bigint,
|
||||
alignedTick: bigint,
|
||||
targetRevision: bigint,
|
||||
targetGeneration: bigint,
|
||||
shiftTicks: bigint,
|
||||
projectionDeltaMilliseconds: number,
|
||||
resumeWallAt: Date
|
||||
): Promise<Map<string, number>> => {
|
||||
const affected = new Map<string, number>();
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { meta: true } });
|
||||
const cursor = await db.worldState.updateMany({
|
||||
where: { id: worldStateId, lastTurnTick: { not: null } },
|
||||
data: { lastTurnTick: { increment: shiftTicks } },
|
||||
});
|
||||
affected.set('turn-cursor', cursor.count);
|
||||
affected.set(
|
||||
'general-next-turn',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general
|
||||
SET turn_tick = turn_tick + ${shiftTicks},
|
||||
turn_time = turn_time + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE turn_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'selection-reselection-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general
|
||||
SET meta = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
meta,
|
||||
'{next_change_tick}',
|
||||
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
|
||||
true
|
||||
),
|
||||
'{next_change}',
|
||||
to_jsonb(((meta->>'next_change')::timestamp
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
||||
true
|
||||
),
|
||||
'{nextChangeAt}',
|
||||
to_jsonb(((meta->>'nextChangeAt')::timestamp
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
||||
true
|
||||
)
|
||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'auction-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET close_tick = close_tick + ${shiftTicks},
|
||||
close_at = close_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
AND close_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'message-action-clock-coordinate',
|
||||
(
|
||||
await db.messageAction.updateMany({
|
||||
where: { status: 'PENDING' },
|
||||
data: {
|
||||
clockRevision: targetRevision,
|
||||
deadlineGeneration: targetGeneration,
|
||||
},
|
||||
})
|
||||
).count
|
||||
);
|
||||
affected.set(
|
||||
'message-action-expiry',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
WITH shifted AS (
|
||||
UPDATE message_action
|
||||
SET expires_game_tick = expires_game_tick + ${shiftTicks},
|
||||
clock_revision = ${targetRevision},
|
||||
deadline_generation = ${targetGeneration},
|
||||
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE status = 'PENDING'
|
||||
AND expires_game_tick IS NOT NULL
|
||||
AND expires_game_tick >= ${cutTick}
|
||||
RETURNING message_id, expires_game_tick
|
||||
)
|
||||
UPDATE message AS envelope
|
||||
SET valid_until_tick = shifted.expires_game_tick,
|
||||
valid_until = envelope.valid_until
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
FROM shifted
|
||||
WHERE envelope.id = shifted.message_id
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'vote-end-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET end_tick = end_tick + ${shiftTicks},
|
||||
end_at = end_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE closed_at IS NULL AND end_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'select-pool-reservation',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_pool
|
||||
SET reserved_until_tick = reserved_until_tick + ${shiftTicks},
|
||||
reserved_until = reserved_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE general_id IS NULL AND reserved_until_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'npc-selection-window',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_npc_token
|
||||
SET valid_until_tick = CASE
|
||||
WHEN valid_until_tick IS NULL THEN NULL ELSE valid_until_tick + ${shiftTicks} END,
|
||||
valid_until = CASE
|
||||
WHEN valid_until_tick IS NULL THEN valid_until
|
||||
ELSE valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END,
|
||||
pick_more_from_tick = CASE
|
||||
WHEN pick_more_from_tick IS NULL THEN NULL ELSE pick_more_from_tick + ${shiftTicks} END,
|
||||
pick_more_from = CASE
|
||||
WHEN pick_more_from_tick IS NULL THEN pick_more_from
|
||||
ELSE pick_more_from + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END
|
||||
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: {
|
||||
clockTick: alignedTick,
|
||||
clockWallAnchor: resumeWallAt,
|
||||
clockPhase: 'RECONCILING',
|
||||
clockRevision: targetRevision,
|
||||
deadlineGeneration: targetGeneration,
|
||||
meta: shiftedMeta(world.meta, projectionDeltaMilliseconds),
|
||||
},
|
||||
});
|
||||
affected.set('world-clock', 1);
|
||||
affected.set('movable-json-rule-anchors', 1);
|
||||
affected.set('auction-finalizing-recovery', 0);
|
||||
return affected;
|
||||
};
|
||||
|
||||
export const startClockSuspension = async (options: {
|
||||
db: GamePrismaClient;
|
||||
suspensionId: string;
|
||||
source: ClockSuspensionSource;
|
||||
authority: ClockOperationAuthority;
|
||||
policy?: ClockAlignmentPolicy;
|
||||
catchUpTicks?: number;
|
||||
}): Promise<ClockSuspensionResult> => {
|
||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
||||
}
|
||||
const policy = options.policy ?? 'EXACT';
|
||||
const catchUpTicks = options.catchUpTicks ?? 0;
|
||||
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
|
||||
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
|
||||
}
|
||||
return runSerializableClockOperation(() =>
|
||||
options.db.$transaction(
|
||||
async (db) => {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const worldStateId = await lockWorld(db);
|
||||
const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.worldStateId !== worldStateId ||
|
||||
existing.source !== options.source ||
|
||||
existing.policy !== policy
|
||||
) {
|
||||
throw new Error(
|
||||
`Clock suspension ID ${options.suspensionId} is already bound to another operation.`
|
||||
);
|
||||
}
|
||||
if (existing.status !== 'SUSPENDED') {
|
||||
throw new Error(
|
||||
`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
suspensionId: existing.id,
|
||||
phase: 'SUSPENDED' as const,
|
||||
sourceRevision: safeNumber(existing.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(existing.targetRevision, 'target revision'),
|
||||
cutTick: safeNumber(existing.cutTick, 'cut tick'),
|
||||
cutWallAt: existing.cutWallAt,
|
||||
};
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'RUNNING') {
|
||||
throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`);
|
||||
}
|
||||
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
|
||||
throw new Error('Clock suspension requires a fully initialized logical game clock.');
|
||||
}
|
||||
const cutWallAt = await readDbWall(db);
|
||||
const storedTick = safeNumber(world.clockTick, 'world clock tick');
|
||||
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
||||
const clock = new GameClock({
|
||||
baseTime: world.clockBaseTime,
|
||||
tick: storedTick,
|
||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: world.clockWallAnchor,
|
||||
turnSeconds: world.tickSeconds,
|
||||
phase,
|
||||
revision: sourceRevision,
|
||||
});
|
||||
const cutTick = clock.nowTick(cutWallAt);
|
||||
await lockParticipants(db, BigInt(cutTick));
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt },
|
||||
});
|
||||
const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick));
|
||||
await db.clockSuspension.create({
|
||||
data: {
|
||||
id: options.suspensionId,
|
||||
worldStateId,
|
||||
source: options.source,
|
||||
policy,
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: BigInt(sourceRevision),
|
||||
targetRevision: BigInt(sourceRevision + 1),
|
||||
cutTick: BigInt(cutTick),
|
||||
cutWallAt,
|
||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds,
|
||||
catchUpTicks: BigInt(catchUpTicks),
|
||||
participantChecksumBefore: aggregateChecksum(participants),
|
||||
detail: asJson({
|
||||
authority: options.authority.kind,
|
||||
profileName: options.authority.profileName,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await persistInitialParticipants(db, options.suspensionId, participants);
|
||||
return {
|
||||
suspensionId: options.suspensionId,
|
||||
phase: 'SUSPENDED',
|
||||
sourceRevision,
|
||||
targetRevision: sourceRevision + 1,
|
||||
cutTick,
|
||||
cutWallAt,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Continues a suspension inside a transaction whose caller already verified
|
||||
* daemon authority and acquired the clock/general-access lock prefix.
|
||||
*/
|
||||
export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
profileName: string;
|
||||
allowUnificationWait?: boolean;
|
||||
authority?: ClockOperationAuthority;
|
||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||
testResumeWallAt?: Date;
|
||||
}): Promise<ClockReconciliationResult> => {
|
||||
const db = options.db;
|
||||
const worldStateId = await lockWorld(db);
|
||||
const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } });
|
||||
if (suspension.worldStateId !== worldStateId) {
|
||||
throw new Error('Clock suspension belongs to another world state.');
|
||||
}
|
||||
if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') {
|
||||
if (
|
||||
suspension.gapTicks === null ||
|
||||
suspension.shiftTicks === null ||
|
||||
suspension.alignedTick === null ||
|
||||
!suspension.resumeWallAt
|
||||
) {
|
||||
throw new Error('Persisted clock reconciliation result is incomplete.');
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING' as const,
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(suspension.targetRevision, 'target revision'),
|
||||
deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'),
|
||||
gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'),
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
||||
resumeWallAt: suspension.resumeWallAt,
|
||||
};
|
||||
}
|
||||
if (suspension.status !== 'SUSPENDED') {
|
||||
throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`);
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) {
|
||||
throw new Error('Clock reconciliation phase or source revision fence failed.');
|
||||
}
|
||||
const worldMeta =
|
||||
world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta)
|
||||
? (world.meta as Record<string, unknown>)
|
||||
: {};
|
||||
const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0);
|
||||
if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) {
|
||||
if (!options.allowUnificationWait || options.authority?.kind !== 'DAEMON') {
|
||||
throw new Error('Unification wait requires the daemon-authorized atomic alignment-and-invader workflow.');
|
||||
}
|
||||
await verifyAuthority(db, options.authority);
|
||||
}
|
||||
const cutTick = safeNumber(suspension.cutTick, 'cut tick');
|
||||
await lockParticipants(db, suspension.cutTick);
|
||||
if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('A clock reconciliation wall override is allowed only in tests.');
|
||||
}
|
||||
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: parseClockAlignmentPolicy(suspension.policy),
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
cutTick,
|
||||
cutWall: suspension.cutWallAt,
|
||||
resumeWall: resumeWallAt,
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
});
|
||||
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
assertShiftFits(before, plan.shiftTicks);
|
||||
await assertScheduleRanges(db, plan.shiftTicks);
|
||||
const projectionDeltaMilliseconds = Math.trunc((plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond);
|
||||
if (!Number.isSafeInteger(projectionDeltaMilliseconds)) {
|
||||
throw new Error('Clock reconciliation projection delta is outside the safe integer range.');
|
||||
}
|
||||
const targetGeneration = world.deadlineGeneration + 1n;
|
||||
const affected = await applyParticipantShift(
|
||||
db,
|
||||
worldStateId,
|
||||
suspension.cutTick,
|
||||
BigInt(plan.alignedTick),
|
||||
BigInt(plan.targetRevision),
|
||||
targetGeneration,
|
||||
BigInt(plan.shiftTicks),
|
||||
projectionDeltaMilliseconds,
|
||||
resumeWallAt
|
||||
);
|
||||
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
||||
for (const participant of before) {
|
||||
const next = afterByKey.get(participant.key);
|
||||
if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`);
|
||||
if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) {
|
||||
throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`);
|
||||
}
|
||||
await db.clockReconciliationParticipant.upsert({
|
||||
where: {
|
||||
suspensionId_participantKey: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
update: {
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
const outboxPayload = {
|
||||
version: 1,
|
||||
profileName: options.profileName,
|
||||
suspensionId: suspension.id,
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
shiftTicks: plan.shiftTicks,
|
||||
projectionDeltaMilliseconds,
|
||||
clockBaseTime: world.clockBaseTime!.toISOString(),
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
};
|
||||
await db.clockProjectionOutbox.create({
|
||||
data: {
|
||||
worldStateId,
|
||||
suspensionId: suspension.id,
|
||||
targetRevision: BigInt(plan.targetRevision),
|
||||
status: 'PENDING',
|
||||
payload: asJson(outboxPayload),
|
||||
checksum: checksum(outboxPayload),
|
||||
},
|
||||
});
|
||||
await db.clockSuspension.update({
|
||||
where: { id: suspension.id },
|
||||
data: {
|
||||
status: 'RECONCILING',
|
||||
resumeWallAt,
|
||||
gapTicks: BigInt(plan.gapTicks),
|
||||
shiftTicks: BigInt(plan.shiftTicks),
|
||||
alignedTick: BigInt(plan.alignedTick),
|
||||
participantChecksumBefore: aggregateChecksum(before),
|
||||
participantChecksumAfter: aggregateChecksum(after),
|
||||
},
|
||||
});
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING',
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
gapTicks: plan.gapTicks,
|
||||
catchUpTicks: plan.catchUpTicks,
|
||||
shiftTicks: plan.shiftTicks,
|
||||
alignedTick: plan.alignedTick,
|
||||
resumeWallAt,
|
||||
};
|
||||
};
|
||||
|
||||
/** Finalizes an atomic unification workflow after an optional rate change. */
|
||||
export const refreshClockProjectionForFinalClockUnderHeldLocks = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
clockBaseTime: Date;
|
||||
tickSeconds: number;
|
||||
}): Promise<void> => {
|
||||
if (GAME_TICKS_PER_TURN % options.tickSeconds !== 0) {
|
||||
throw new Error(`Final clock rate cannot represent an integer tick: ${options.tickSeconds}.`);
|
||||
}
|
||||
const outbox = await options.db.clockProjectionOutbox.findFirstOrThrow({
|
||||
where: { suspensionId: options.suspensionId, status: 'PENDING' },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const payload =
|
||||
outbox.payload && typeof outbox.payload === 'object' && !Array.isArray(outbox.payload)
|
||||
? { ...(outbox.payload as Record<string, unknown>) }
|
||||
: null;
|
||||
if (!payload || payload.suspensionId !== options.suspensionId) {
|
||||
throw new Error('Unification clock projection outbox payload is invalid.');
|
||||
}
|
||||
payload.clockBaseTime = options.clockBaseTime.toISOString();
|
||||
payload.ticksPerSecond = GAME_TICKS_PER_TURN / options.tickSeconds;
|
||||
await options.db.clockProjectionOutbox.update({
|
||||
where: { id: outbox.id },
|
||||
data: { payload: asJson(payload), checksum: checksum(payload) },
|
||||
});
|
||||
};
|
||||
|
||||
export const reconcileClockSuspension = async (options: {
|
||||
db: GamePrismaClient;
|
||||
suspensionId: string;
|
||||
authority: ClockOperationAuthority;
|
||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||
testResumeWallAt?: Date;
|
||||
}): Promise<ClockReconciliationResult> =>
|
||||
runSerializableClockOperation(() =>
|
||||
options.db.$transaction(
|
||||
async (db) => {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
return reconcileClockSuspensionInTransaction({
|
||||
db,
|
||||
suspensionId: options.suspensionId,
|
||||
profileName: options.authority.profileName,
|
||||
authority: options.authority,
|
||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
||||
});
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
)
|
||||
);
|
||||
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
|
||||
type: z.literal('auctionFinalize'),
|
||||
auctionId: zFiniteNumber,
|
||||
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
expectedCloseTick: zSafeInteger.optional(),
|
||||
expectedCloseTick: zSafeInteger,
|
||||
});
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
|
||||
auctionId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
amount: zFiniteNumber,
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
tryExtendCloseDate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -132,6 +131,15 @@ const zMessageRespond = z.object({
|
||||
response: z.boolean(),
|
||||
});
|
||||
|
||||
const zSyncDiplomaticResponse = z.object({
|
||||
type: z.literal('syncDiplomaticResponse'),
|
||||
userId: z.string().min(1),
|
||||
generalId: z.number().int().positive(),
|
||||
messageId: z.number().int().positive(),
|
||||
nationIds: z.array(z.number().int().positive()).max(4),
|
||||
cityIds: z.array(z.number().int().positive()).max(256),
|
||||
});
|
||||
|
||||
const zVacation = z.object({
|
||||
type: z.literal('vacation'),
|
||||
userId: z.string().min(1),
|
||||
@@ -142,12 +150,11 @@ const zSetMySetting = z.object({
|
||||
type: z.literal('setMySetting'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
settings: z.object({
|
||||
settings: z.strictObject({
|
||||
tnmt: z.number().int().optional(),
|
||||
defence_train: z.number().int().optional(),
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().optional(),
|
||||
use_auto_nation_war: z.number().int().optional(),
|
||||
use_auto_nation_promotion: z.number().int().optional(),
|
||||
use_auto_nation_finance: z.number().int().optional(),
|
||||
@@ -428,7 +435,7 @@ const zSelectPoolReserve = z
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -611,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope)
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => {
|
||||
const command = parseWith(zSyncDiplomaticResponse, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
|
||||
const command = parseWith(zVacation, envelope.command);
|
||||
if (!command) {
|
||||
@@ -861,6 +876,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
buildNationCandidate: normalizeBuildNationCandidate,
|
||||
instantRetreat: normalizeInstantRetreat,
|
||||
messageRespond: normalizeMessageRespond,
|
||||
syncDiplomaticResponse: normalizeSyncDiplomaticResponse,
|
||||
vacation: normalizeVacation,
|
||||
setMySetting: normalizeSetMySetting,
|
||||
dropItem: normalizeDropItem,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
createGamePostgresConnector,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
writeReadModelChangeJournal,
|
||||
enqueuePrivateMessageWebPush,
|
||||
enqueueWebPushOutboxEvents,
|
||||
persistMessageEnvelope,
|
||||
type InputJsonValue,
|
||||
type ReadModelJournalWriteResult,
|
||||
type TurnEngineCityUpdateInput,
|
||||
@@ -54,12 +57,22 @@ import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
|
||||
import {
|
||||
persistClockSuspensionLedgerUnderHeldLocks,
|
||||
prepareClockSuspensionUnderHeldLocks,
|
||||
readClockDatabaseWall,
|
||||
refreshClockProjectionForFinalClockUnderHeldLocks,
|
||||
} from './clockReconciliation.js';
|
||||
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
|
||||
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
close(): Promise<void>;
|
||||
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
|
||||
synchronizeClockAuthority(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface CommittedReadModelChangeReceipt {
|
||||
@@ -121,6 +134,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
|
||||
'clock_base_time',
|
||||
'clockMode',
|
||||
'clock_mode',
|
||||
'clockPhase',
|
||||
'clock_phase',
|
||||
'clockRevision',
|
||||
'clock_revision',
|
||||
'clockTick',
|
||||
'clock_tick',
|
||||
'clockWallAnchor',
|
||||
@@ -134,6 +151,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
|
||||
'last_turn_tick',
|
||||
'lastTurnTime',
|
||||
'last_turn_time',
|
||||
'deadlineGeneration',
|
||||
'deadline_generation',
|
||||
'lease',
|
||||
'leaseOwner',
|
||||
'lease_owner',
|
||||
@@ -1121,9 +1140,19 @@ export const createDatabaseTurnHooks = async (
|
||||
clockMode: state.clockMode ?? 'manual',
|
||||
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
|
||||
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||
config: asJson(world.getWorldConfig()),
|
||||
meta: asJson(state.meta),
|
||||
};
|
||||
const writesGeneralAccess =
|
||||
accessScoreResetGeneralIds.length > 0 ||
|
||||
lifecycleEvents.length > 0 ||
|
||||
deletedGenerals.length > 0 ||
|
||||
generals.some(
|
||||
(general) => typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
|
||||
);
|
||||
const persist = async (
|
||||
prisma: GamePrisma.TransactionClient
|
||||
): Promise<{
|
||||
@@ -1156,6 +1185,129 @@ export const createDatabaseTurnHooks = async (
|
||||
// world mutation. A stale daemon can finish calculating, but it can
|
||||
// never commit after another owner has advanced the epoch.
|
||||
await options?.turnDaemonLease?.assertActive(prisma);
|
||||
await acquireGameSchemaAdvisoryXactLock(prisma, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
if (writesGeneralAccess) {
|
||||
// General-access API writers take this lock before touching
|
||||
// world rows. Clock operations use the same global order.
|
||||
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
}
|
||||
const persistedClock = await prisma.$queryRaw<
|
||||
Array<{
|
||||
clock_phase: string;
|
||||
clock_revision: bigint;
|
||||
deadline_generation: bigint;
|
||||
clock_initialized: boolean;
|
||||
opening_reached: boolean;
|
||||
}>
|
||||
>(GamePrisma.sql`
|
||||
SELECT clock_phase,
|
||||
clock_revision,
|
||||
deadline_generation,
|
||||
clock_base_time IS NOT NULL
|
||||
AND clock_tick IS NOT NULL
|
||||
AND clock_wall_anchor IS NOT NULL
|
||||
AND last_turn_tick IS NOT NULL AS clock_initialized,
|
||||
clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached
|
||||
FROM world_state
|
||||
WHERE id = ${state.id}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const durableClock = persistedClock[0];
|
||||
if (!durableClock) {
|
||||
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
|
||||
}
|
||||
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
||||
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
||||
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
|
||||
// Match worldLoader's dual-read boundary: a legacy row is not an
|
||||
// authoritative RUNNING clock merely because the newly-added phase
|
||||
// column has its database default. Its first fenced flush installs
|
||||
// the complete MANUAL snapshot atomically.
|
||||
const durablePhase = durableClock.clock_initialized ? durableClock.clock_phase : 'MANUAL';
|
||||
const stateMeta = asRecord(state.meta);
|
||||
const unificationSuspensionId =
|
||||
typeof stateMeta.unificationClockSuspensionId === 'string'
|
||||
? stateMeta.unificationClockSuspensionId
|
||||
: null;
|
||||
const openingPhaseTransition =
|
||||
durableClock.clock_initialized &&
|
||||
durablePhase === 'PREOPEN' &&
|
||||
expectedPhase === 'RUNNING' &&
|
||||
durableClock.opening_reached;
|
||||
const unificationSuspensionTransition =
|
||||
durableClock.clock_initialized &&
|
||||
durablePhase === 'RUNNING' &&
|
||||
expectedPhase === 'SUSPENDED' &&
|
||||
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
|
||||
Boolean(unificationSuspensionId);
|
||||
const completionPhaseTransition =
|
||||
durableClock.clock_initialized &&
|
||||
durablePhase === 'RUNNING' &&
|
||||
expectedPhase === 'COMPLETED' &&
|
||||
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
|
||||
if (
|
||||
(!openingPhaseTransition &&
|
||||
!unificationSuspensionTransition &&
|
||||
!completionPhaseTransition &&
|
||||
durablePhase !== expectedPhase) ||
|
||||
durableClock.clock_revision !== expectedRevision ||
|
||||
durableClock.deadline_generation !== expectedGeneration
|
||||
) {
|
||||
throw new Error(
|
||||
`Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` +
|
||||
`found ${durablePhase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.`
|
||||
);
|
||||
}
|
||||
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
|
||||
const unificationCutTick = unificationCutWallAt
|
||||
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
|
||||
: null;
|
||||
const suspensionPreparation =
|
||||
unificationCutTick !== null
|
||||
? await prepareClockSuspensionUnderHeldLocks({
|
||||
db: prisma,
|
||||
cutTick: unificationCutTick,
|
||||
cutWallAt: unificationCutWallAt!,
|
||||
})
|
||||
: null;
|
||||
if (commandCompletion) {
|
||||
const commandFence = await prisma.$queryRaw<
|
||||
Array<{
|
||||
status: string;
|
||||
processing_clock_revision: bigint | null;
|
||||
processing_deadline_generation: bigint | null;
|
||||
}>
|
||||
>(GamePrisma.sql`
|
||||
SELECT status,
|
||||
processing_clock_revision,
|
||||
processing_deadline_generation
|
||||
FROM input_event
|
||||
WHERE request_id = ${commandCompletion.requestId}
|
||||
AND target = 'ENGINE'::"InputEventTarget"
|
||||
FOR UPDATE
|
||||
`);
|
||||
const event = commandFence[0];
|
||||
const unificationRevisionTransition =
|
||||
commandCompletion.result.type === 'messageRespond' &&
|
||||
commandCompletion.result.ok &&
|
||||
commandCompletion.result.action === 'raiseInvader' &&
|
||||
expectedPhase === 'RECONCILING' &&
|
||||
event?.processing_clock_revision !== null &&
|
||||
event?.processing_deadline_generation !== null &&
|
||||
event?.processing_clock_revision + 1n === expectedRevision &&
|
||||
event?.processing_deadline_generation + 1n === expectedGeneration;
|
||||
if (
|
||||
!event ||
|
||||
event.status !== 'PROCESSING' ||
|
||||
(!unificationRevisionTransition &&
|
||||
(event.processing_clock_revision !== expectedRevision ||
|
||||
event.processing_deadline_generation !== expectedGeneration))
|
||||
) {
|
||||
throw new Error(
|
||||
`Input event processing clock fence changed before commit: ${commandCompletion.requestId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
let neutralAuctionsToCreate = pendingNeutralAuctions;
|
||||
if (pendingNeutralAuctions.length > 0) {
|
||||
const latestRegistrationKey =
|
||||
@@ -1260,6 +1412,35 @@ export const createDatabaseTurnHooks = async (
|
||||
END
|
||||
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
|
||||
`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_pool
|
||||
SET reserved_until = CASE
|
||||
WHEN reserved_until_tick IS NULL THEN reserved_until
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (reserved_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((reserved_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
|
||||
* INTERVAL '1 millisecond'
|
||||
END
|
||||
WHERE reserved_until_tick IS NOT NULL
|
||||
`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_npc_token
|
||||
SET valid_until = CASE
|
||||
WHEN valid_until_tick IS NULL THEN valid_until
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
|
||||
* INTERVAL '1 millisecond'
|
||||
END,
|
||||
pick_more_from = CASE
|
||||
WHEN pick_more_from_tick IS NULL THEN pick_more_from
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (pick_more_from_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((pick_more_from_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
|
||||
* INTERVAL '1 millisecond'
|
||||
END
|
||||
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
for (const betting of pendingNationBettingOpens) {
|
||||
@@ -1684,37 +1865,18 @@ export const createDatabaseTurnHooks = async (
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft: MessageRecordDraft) => {
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
try {
|
||||
return BigInt(world.dateToGameTick(date));
|
||||
} catch {
|
||||
// Legacy messages may use year 9999 as an
|
||||
// effectively-unbounded expiry, beyond the
|
||||
// safe JavaScript tick range.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (
|
||||
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
|
||||
)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${toTickOrNull(draft.time)},
|
||||
${draft.validUntil},
|
||||
${toTickOrNull(draft.validUntil)},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to persist turn message.');
|
||||
}
|
||||
const clock = world.getGameClockState();
|
||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||
const expiresGameTick =
|
||||
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
|
||||
? null
|
||||
: BigInt(world.dateToGameTick(draft.validUntil));
|
||||
const id = await persistMessageEnvelope(prisma, draft, {
|
||||
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||
expiresGameTick,
|
||||
});
|
||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||
persistedMessageMailboxes.push(draft.mailbox);
|
||||
return id;
|
||||
@@ -1727,6 +1889,41 @@ export const createDatabaseTurnHooks = async (
|
||||
if (options?.reservedTurns && persistedReservedTurnChanges) {
|
||||
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
||||
}
|
||||
if (suspensionPreparation && unificationSuspensionId) {
|
||||
const cutTick = unificationCutTick!;
|
||||
await prisma.worldState.update({
|
||||
where: { id: state.id },
|
||||
data: {
|
||||
clockTick: BigInt(cutTick),
|
||||
clockWallAnchor: suspensionPreparation.cutWallAt,
|
||||
},
|
||||
});
|
||||
await persistClockSuspensionLedgerUnderHeldLocks({
|
||||
db: prisma,
|
||||
suspensionId: unificationSuspensionId,
|
||||
worldStateId: state.id,
|
||||
profileName: options?.profileName ?? 'default',
|
||||
source: 'UNIFICATION_WAIT',
|
||||
cutTick,
|
||||
cutWallAt: suspensionPreparation.cutWallAt,
|
||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
|
||||
sourceRevision: state.clockRevision ?? 1,
|
||||
});
|
||||
}
|
||||
if (
|
||||
commandCompletion?.result.type === 'messageRespond' &&
|
||||
commandCompletion.result.ok &&
|
||||
commandCompletion.result.action === 'raiseInvader' &&
|
||||
unificationSuspensionId &&
|
||||
state.clockPhase === 'RECONCILING'
|
||||
) {
|
||||
await refreshClockProjectionForFinalClockUnderHeldLocks({
|
||||
db: prisma,
|
||||
suspensionId: unificationSuspensionId,
|
||||
clockBaseTime: state.clockBaseTime ?? state.lastTurnTime,
|
||||
tickSeconds: state.tickSeconds,
|
||||
});
|
||||
}
|
||||
if (commandCompletion) {
|
||||
await prisma.inputEvent.update({
|
||||
where: { requestId: commandCompletion.requestId },
|
||||
@@ -1812,6 +2009,11 @@ export const createDatabaseTurnHooks = async (
|
||||
},
|
||||
executeCommand: async (requestId, execute) => {
|
||||
const committed = await prisma.$transaction(async (transaction) => {
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
const leaseToken = options?.turnDaemonLease?.getToken();
|
||||
const directLogFloor =
|
||||
(
|
||||
await transaction.logEntry.findFirst({
|
||||
@@ -1819,7 +2021,19 @@ export const createDatabaseTurnHooks = async (
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ?? 0;
|
||||
const result = await execute({ db: transaction });
|
||||
const result = await execute({
|
||||
db: transaction,
|
||||
...(leaseToken
|
||||
? {
|
||||
clockOperationAuthority: {
|
||||
kind: 'DAEMON' as const,
|
||||
profileName: leaseToken.profile,
|
||||
ownerId: leaseToken.ownerId,
|
||||
fencingEpoch: leaseToken.fencingEpoch,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
|
||||
return { result, persisted };
|
||||
}, transactionOptions);
|
||||
@@ -1835,6 +2049,20 @@ export const createDatabaseTurnHooks = async (
|
||||
return takeCommittedReceipt()?.changes ?? null;
|
||||
},
|
||||
takeCommittedReadModelChangeReceipt: takeCommittedReceipt,
|
||||
applyClockProjection: async (redis, workerId) => {
|
||||
await applyNextClockProjection({ db: prisma, redis, workerId });
|
||||
const clock = await prisma.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true },
|
||||
});
|
||||
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
|
||||
},
|
||||
synchronizeClockAuthority: () =>
|
||||
prisma.$transaction(async (transaction) => {
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
}, transactionOptions),
|
||||
close: () => connector.disconnect(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
||||
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
|
||||
continue;
|
||||
}
|
||||
const terminal = result.status !== 'PARTIAL';
|
||||
const updated = await prisma.gatewayRuntimeAction.updateMany({
|
||||
where: {
|
||||
id: action.id,
|
||||
status: { in: ['REQUESTED', 'PARTIAL'] },
|
||||
},
|
||||
data: {
|
||||
status: result.status,
|
||||
detail: result.detail ?? null,
|
||||
handler: 'turn-daemon',
|
||||
handledAt: terminal ? new Date() : null,
|
||||
attempts: { increment: 1 },
|
||||
nextAttemptAt: terminal
|
||||
? null
|
||||
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
|
||||
},
|
||||
});
|
||||
if (terminal && updated.count > 0) {
|
||||
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
|
||||
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
|
||||
UPDATE gateway_runtime_action
|
||||
SET status = ${result.status}::"GatewayRuntimeActionStatus",
|
||||
detail = ${result.detail ?? null},
|
||||
handler = 'turn-daemon',
|
||||
handled_at = CASE
|
||||
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
attempts = attempts + 1,
|
||||
next_attempt_at = CASE
|
||||
WHEN ${terminal} THEN NULL
|
||||
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${retryDelayMs} * INTERVAL '1 millisecond'
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${action.id}
|
||||
AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus")
|
||||
RETURNING id
|
||||
`);
|
||||
if (terminal && updated.length > 0) {
|
||||
await options.onActionApplied?.(actionRecord, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -15,6 +17,7 @@ export interface GatewayProfileGate {
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_MS = 2000;
|
||||
const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as const;
|
||||
|
||||
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
|
||||
const connector = createGatewayPostgresConnector({
|
||||
@@ -42,7 +45,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
||||
return {
|
||||
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
||||
async shouldPause(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
|
||||
return cachedPause;
|
||||
}
|
||||
@@ -53,8 +56,11 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
||||
async markPaused(error?: unknown): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : error ? String(error) : null;
|
||||
try {
|
||||
await prisma.gatewayProfile.update({
|
||||
where: { profileName: options.profileName },
|
||||
await prisma.gatewayProfile.updateMany({
|
||||
where: {
|
||||
profileName: options.profileName,
|
||||
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
|
||||
},
|
||||
data: {
|
||||
status: 'PAUSED',
|
||||
lastError: message,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user