fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정
This commit is contained in:
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
|
|||||||
any: {
|
any: {
|
||||||
ban: { expire: 4_102_444_800, value: 1 },
|
ban: { expire: 4_102_444_800, value: 1 },
|
||||||
expired: { expire: 1, value: 9 },
|
expired: { expire: 1, value: 9 },
|
||||||
|
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
|
||||||
},
|
},
|
||||||
hwe: {
|
hwe: {
|
||||||
ban: { expire: 4_102_444_800, value: 2 },
|
ban: { expire: 4_102_444_800, value: 2 },
|
||||||
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
await seedScenarioToDatabase({
|
await seedScenarioToDatabase({
|
||||||
scenarioId: 2,
|
scenarioId: 2,
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
now: new Date('2026-07-30T12:00:00.000Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
|
openAt: new Date(Date.now() + 86_400_000),
|
||||||
turnTermMinutes: 5,
|
turnTermMinutes: 5,
|
||||||
npcMode: 0,
|
npcMode: 0,
|
||||||
showImgLevel: 3,
|
showImgLevel: 3,
|
||||||
@@ -289,6 +291,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
|
||||||
expect(created.affinity).toBeLessThanOrEqual(150);
|
expect(created.affinity).toBeLessThanOrEqual(150);
|
||||||
expect(created.meta).toMatchObject({
|
expect(created.meta).toMatchObject({
|
||||||
createdBy: 'join',
|
createdBy: 'join',
|
||||||
@@ -305,6 +308,9 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
throw new Error('created general must have an initial access timestamp');
|
throw new Error('created general must have an initial access timestamp');
|
||||||
}
|
}
|
||||||
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||||
|
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
|
||||||
|
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
|
||||||
|
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
|
||||||
expect(
|
expect(
|
||||||
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||||
acceptedEvent.createdAt.getTime()
|
acceptedEvent.createdAt.getTime()
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||||
const logs = await db.logEntry.findMany({
|
const logs = await db.logEntry.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -671,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
|
|
||||||
await db.npcSelectionToken.update({
|
await db.npcSelectionToken.update({
|
||||||
where: { ownerUserId: rejectedUserId },
|
where: { ownerUserId: rejectedUserId },
|
||||||
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') },
|
data: { validUntilTick: -1n },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
||||||
|
|||||||
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
await seedScenarioToDatabase({
|
await seedScenarioToDatabase({
|
||||||
scenarioId: 903,
|
scenarioId: 903,
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
now: new Date('2026-07-30T12:00:00.000Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
|
openAt: new Date(Date.now() + 86_400_000),
|
||||||
turnTermMinutes: 5,
|
turnTermMinutes: 5,
|
||||||
npcMode: 2,
|
npcMode: 2,
|
||||||
showImgLevel: 3,
|
showImgLevel: 3,
|
||||||
@@ -274,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
||||||
});
|
});
|
||||||
|
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
|
||||||
|
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
|
||||||
|
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
|
||||||
if (!initialAccess.lastRefresh) {
|
if (!initialAccess.lastRefresh) {
|
||||||
throw new Error('selected general must have an initial access timestamp');
|
throw new Error('selected general must have an initial access timestamp');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
|
|
||||||
async drain(): Promise<TurnDaemonCommand[]> {
|
async drain(): Promise<TurnDaemonCommand[]> {
|
||||||
const local = this.localQueue.splice(0, this.localQueue.length);
|
const local = this.localQueue.splice(0, this.localQueue.length);
|
||||||
|
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
|
||||||
|
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
|
||||||
|
if (local.some((command) => command.type === 'shutdown')) {
|
||||||
|
return local;
|
||||||
|
}
|
||||||
const remote = await this.claimPending();
|
const remote = await this.claimPending();
|
||||||
return local.concat(remote);
|
return local.concat(remote);
|
||||||
}
|
}
|
||||||
@@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
||||||
});
|
});
|
||||||
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
||||||
|
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
||||||
|
const gameplayAllowed =
|
||||||
|
!world ||
|
||||||
|
world.clockPhase === 'PREOPEN' ||
|
||||||
|
world.clockPhase === 'RUNNING' ||
|
||||||
|
world.clockPhase === 'MANUAL';
|
||||||
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
||||||
const currentRevision = world?.clockRevision ?? null;
|
const currentRevision = world?.clockRevision ?? null;
|
||||||
const maintenanceSuspended =
|
const maintenanceSuspended =
|
||||||
|
|||||||
@@ -158,6 +158,20 @@ export class TurnDaemonLifecycle {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nowMs = this.clock.nowMs();
|
||||||
|
const wallNow = new Date(nowMs);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
|
||||||
|
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
|
||||||
if (this.pendingRun) {
|
if (this.pendingRun) {
|
||||||
await this.runOnce(this.pendingRun);
|
await this.runOnce(this.pendingRun);
|
||||||
this.pendingRun = null;
|
this.pendingRun = null;
|
||||||
@@ -169,23 +183,6 @@ export class TurnDaemonLifecycle {
|
|||||||
await this.clock.sleepMs(200);
|
await this.clock.sleepMs(200);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowMs = this.clock.nowMs();
|
|
||||||
const wallNow = new Date(nowMs);
|
|
||||||
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') {
|
if (gameClock?.mode === 'manual') {
|
||||||
// Ref observes all generals due before one monthly boundary in
|
// Ref observes all generals due before one monthly boundary in
|
||||||
// a single snapshot. Manual mode advances directly to that
|
// a single snapshot. Manual mode advances directly to that
|
||||||
|
|||||||
@@ -1711,7 +1711,11 @@ export class InMemoryTurnWorld {
|
|||||||
// Rebasing is also the explicit resume checkpoint. Realtime mode
|
// Rebasing is also the explicit resume checkpoint. Realtime mode
|
||||||
// must not replay the operational downtime after an administrator
|
// must not replay the operational downtime after an administrator
|
||||||
// deliberately delays or accelerates the game schedule.
|
// deliberately delays or accelerates the game schedule.
|
||||||
clockWallAnchor: new Date(wallNow.getTime()),
|
// 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는
|
||||||
|
// 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다.
|
||||||
|
clockWallAnchor: new Date(
|
||||||
|
previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime()
|
||||||
|
),
|
||||||
lastTurnTime: nextLastTurnTime,
|
lastTurnTime: nextLastTurnTime,
|
||||||
meta: nextMeta,
|
meta: nextMeta,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
|
|||||||
export const resolveLegacyPenalty = (
|
export const resolveLegacyPenalty = (
|
||||||
rawPenalty: Record<string, unknown> | undefined,
|
rawPenalty: Record<string, unknown> | undefined,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
acceptedAt: Date
|
requestedAtWall: Date
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
if (!rawPenalty) {
|
if (!rawPenalty) {
|
||||||
return {};
|
return {};
|
||||||
@@ -105,7 +105,7 @@ export const resolveLegacyPenalty = (
|
|||||||
...asRecord(rawPenalty.any),
|
...asRecord(rawPenalty.any),
|
||||||
...asRecord(rawPenalty[profileId]),
|
...asRecord(rawPenalty[profileId]),
|
||||||
};
|
};
|
||||||
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
|
const acceptedAtSeconds = requestedAtWall.getTime() / 1000;
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const [key, rawEntry] of Object.entries(merged)) {
|
for (const [key, rawEntry] of Object.entries(merged)) {
|
||||||
const entry = asRecord(rawEntry);
|
const entry = asRecord(rawEntry);
|
||||||
@@ -654,12 +654,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
const generalId = world.getNextGeneralId();
|
const generalId = world.getNextGeneralId();
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
buildJoinCreateGeneralSeed(
|
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId)
|
||||||
hiddenSeed,
|
|
||||||
input.seedOwnerIdentity,
|
|
||||||
world.dateToGameTick(acceptedAt),
|
|
||||||
generalId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
||||||
@@ -805,7 +800,9 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
lastTurn: { command: DEFAULT_TURN_ACTION },
|
lastTurn: { command: DEFAULT_TURN_ACTION },
|
||||||
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
|
// 계정 제재 expire는 Ref member의 Unix wall timestamp다. 가오픈의
|
||||||
|
// 음수 GAME 좌표로 비교하면 현실에서 만료된 제재가 다시 적용된다.
|
||||||
|
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, operationalAcceptedAt),
|
||||||
inheritancePoints: {
|
inheritancePoints: {
|
||||||
previous: finalInheritancePoint,
|
previous: finalInheritancePoint,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||||
|
import type { TurnWorldState } from './types.js';
|
||||||
|
|
||||||
const DEFAULT_MIN_TURNS = 2;
|
const DEFAULT_MIN_TURNS = 2;
|
||||||
|
|
||||||
|
/** 표시용 opentime과 재투영된 lastTurnTime 대신 오픈 phase와 실행 cursor로 판정한다. */
|
||||||
|
export const hasStartedForPrestartActions = (
|
||||||
|
state: Pick<TurnWorldState, 'clockPhase' | 'lastTurnTick' | 'lastTurnTime' | 'meta'>
|
||||||
|
): boolean => {
|
||||||
|
if (state.clockPhase === 'PREOPEN') return false;
|
||||||
|
if (state.clockPhase === 'COMPLETED' || state.clockPhase === 'SUSPENDED' || state.clockPhase === 'RECONCILING') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Ref의 turntime == opentime 경계는 아직 허용하고 최초 월 진행 이후 닫는다.
|
||||||
|
if (state.lastTurnTick !== undefined) return state.lastTurnTick > 0;
|
||||||
|
const opentime = typeof state.meta.opentime === 'string' ? new Date(state.meta.opentime) : null;
|
||||||
|
return opentime !== null && state.lastTurnTime.getTime() > opentime.getTime();
|
||||||
|
};
|
||||||
|
|
||||||
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
|
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
|
||||||
const value = meta.prestart_delete_after;
|
const value = meta.prestart_delete_after;
|
||||||
if (typeof value !== 'string' || !value.trim()) {
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ import {
|
|||||||
} from './selectPoolService.js';
|
} from './selectPoolService.js';
|
||||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
import {
|
||||||
|
buildPrestartDeleteAfter,
|
||||||
|
formatPrestartDeleteAfter,
|
||||||
|
readPrestartDeleteAfter,
|
||||||
|
hasStartedForPrestartActions,
|
||||||
|
} from './prestartDeletion.js';
|
||||||
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
||||||
import { executeInheritanceAction } from './inheritanceActionService.js';
|
import { executeInheritanceAction } from './inheritanceActionService.js';
|
||||||
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
|
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
|
||||||
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
|
|||||||
throw new Error('Join world state is missing.');
|
throw new Error('Join world state is missing.');
|
||||||
}
|
}
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||||
|
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
|
||||||
|
}
|
||||||
|
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||||
|
const turnScheduleAt = ctx.world.gameTickToDate(
|
||||||
|
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'joinCreateGeneral',
|
type: 'joinCreateGeneral',
|
||||||
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
|
|||||||
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
||||||
}
|
}
|
||||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||||
const turnScheduleAt = acceptedAt;
|
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
|
||||||
|
const turnScheduleAt = ctx.world.gameTickToDate(
|
||||||
|
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
|
|||||||
const db = requireCommandDatabase(ctx);
|
const db = requireCommandDatabase(ctx);
|
||||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const worldState = ctx.world.getState();
|
const worldState = ctx.world.getState();
|
||||||
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
|
if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
|
||||||
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
|
|
||||||
return {
|
return {
|
||||||
type: 'ensureDieOnPrestartStatus',
|
type: 'ensureDieOnPrestartStatus',
|
||||||
generalId: command.generalId,
|
generalId: command.generalId,
|
||||||
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
|
|||||||
}
|
}
|
||||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const worldState = world.getState();
|
const worldState = world.getState();
|
||||||
const opentime = worldState.meta.opentime as string | undefined;
|
if (hasStartedForPrestartActions(worldState)) {
|
||||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
|
||||||
return {
|
return {
|
||||||
type: 'dieOnPrestart',
|
type: 'dieOnPrestart',
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
|
|||||||
await assertImmediateGeneralActionActor(ctx, command, general);
|
await assertImmediateGeneralActionActor(ctx, command, general);
|
||||||
|
|
||||||
const worldState = world.getState();
|
const worldState = world.getState();
|
||||||
const opentime = worldState.meta.opentime as string | undefined;
|
if (hasStartedForPrestartActions(worldState)) {
|
||||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
|
||||||
return {
|
return {
|
||||||
type: 'buildNationCandidate',
|
type: 'buildNationCandidate',
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ import {
|
|||||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||||
|
|
||||||
const enabled =
|
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
||||||
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' &&
|
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
|
||||||
Boolean(process.env.DATABASE_URL) &&
|
|
||||||
Boolean(process.env.REDIS_URL);
|
|
||||||
const describeIntegration = enabled ? describe : describe.skip;
|
const describeIntegration = enabled ? describe : describe.skip;
|
||||||
|
|
||||||
describeIntegration('durable clock reconciliation', () => {
|
describeIntegration('durable clock reconciliation', () => {
|
||||||
@@ -47,7 +45,7 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
disconnect = connector.disconnect;
|
disconnect = connector.disconnect;
|
||||||
redis = createRedisConnector({ url: process.env.REDIS_URL! });
|
redis = createRedisConnector({ url: process.env.REDIS_URL! });
|
||||||
@@ -225,16 +223,16 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
|
|
||||||
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
|
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
||||||
db.general.findMany({ orderBy: { id: 'asc' } }),
|
db.general.findMany({ orderBy: { id: 'asc' } }),
|
||||||
db.auction.findFirstOrThrow(),
|
db.auction.findFirstOrThrow(),
|
||||||
db.message.findFirstOrThrow(),
|
db.message.findFirstOrThrow(),
|
||||||
db.messageAction.findFirstOrThrow(),
|
db.messageAction.findFirstOrThrow(),
|
||||||
db.votePoll.findFirstOrThrow(),
|
db.votePoll.findFirstOrThrow(),
|
||||||
db.selectPoolEntry.findFirstOrThrow(),
|
db.selectPoolEntry.findFirstOrThrow(),
|
||||||
db.npcSelectionToken.findFirstOrThrow(),
|
db.npcSelectionToken.findFirstOrThrow(),
|
||||||
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
|
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
|
||||||
db.clockProjectionOutbox.findMany(),
|
db.clockProjectionOutbox.findMany(),
|
||||||
]);
|
]);
|
||||||
const alignedTick = BigInt(reconciled.alignedTick);
|
const alignedTick = BigInt(reconciled.alignedTick);
|
||||||
expect(afterWorld).toMatchObject({
|
expect(afterWorld).toMatchObject({
|
||||||
|
|||||||
@@ -121,6 +121,31 @@ integration('database command queue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves pending work immediately claimable by a replacement daemon when shutting down', async () => {
|
||||||
|
const requestId = 'integration:engine:shutdown-pending';
|
||||||
|
await db.inputEvent.create({
|
||||||
|
data: {
|
||||||
|
requestId,
|
||||||
|
target: 'ENGINE',
|
||||||
|
eventType: 'dieOnPrestart',
|
||||||
|
actorUserId: 'user-7',
|
||||||
|
payload: { type: 'dieOnPrestart', requestId, userId: 'user-7', generalId: 7 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||||
|
queue.enqueue({ type: 'shutdown', reason: 'replacement' });
|
||||||
|
expect(await queue.drain()).toEqual([{ type: 'shutdown', reason: 'replacement' }]);
|
||||||
|
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||||
|
status: 'PENDING',
|
||||||
|
attempts: 0,
|
||||||
|
lockedBy: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
});
|
||||||
|
expect(await new DatabaseTurnDaemonCommandQueue(db).drain()).toMatchObject([
|
||||||
|
{ type: 'dieOnPrestart', requestId },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers only an expired processing lease', async () => {
|
it('recovers only an expired processing lease', async () => {
|
||||||
const expiredId = 'integration:engine:expired';
|
const expiredId = 'integration:engine:expired';
|
||||||
const activeId = 'integration:engine:active';
|
const activeId = 'integration:engine:active';
|
||||||
@@ -313,6 +338,55 @@ integration('database command queue', () => {
|
|||||||
expect(mutation).not.toHaveBeenCalled();
|
expect(mutation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
||||||
|
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
||||||
|
async (phase) => {
|
||||||
|
await db.worldState.updateMany({
|
||||||
|
data: {
|
||||||
|
clockPhase: phase,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
clockWallAnchor: new Date(Date.now() + 3_600_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'buildNationCandidate'] as const;
|
||||||
|
await db.inputEvent.createMany({
|
||||||
|
data: types.map((type) => {
|
||||||
|
const requestId = `integration:engine:preopen:${type}`;
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
target: 'ENGINE' as const,
|
||||||
|
eventType: type,
|
||||||
|
actorUserId: 'user-7',
|
||||||
|
payload: { type, requestId, userId: 'user-7', generalId: 7 },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const commands = await new DatabaseTurnDaemonCommandQueue(db).drain();
|
||||||
|
const allowed = ['PREOPEN', 'RUNNING', 'MANUAL'].includes(phase);
|
||||||
|
expect(commands.map((command) => command.type)).toEqual(allowed ? types : []);
|
||||||
|
const events = await db.inputEvent.findMany({
|
||||||
|
where: { requestId: { startsWith: 'integration:engine:preopen:' } },
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
for (const event of events) {
|
||||||
|
expect(event.status).toBe(allowed ? 'PROCESSING' : 'PENDING');
|
||||||
|
expect(event.attempts).toBe(allowed ? 1 : 0);
|
||||||
|
expect(event.processingClockRevision).toBe(allowed ? 1n : null);
|
||||||
|
expect(event.processingDeadlineGeneration).toBe(allowed ? 1n : null);
|
||||||
|
if (phase === 'PREOPEN') {
|
||||||
|
expect(event.processingGameTick).toBeLessThan(0n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(await db.worldState.findFirst()).toMatchObject({
|
||||||
|
clockPhase: phase,
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
|
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
|
||||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||||
const world = existingWorld
|
const world = existingWorld
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { GamePrisma } from '@sammo-ts/infra';
|
|||||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
|
import {
|
||||||
|
buildPrestartDeleteAfter,
|
||||||
|
formatPrestartDeleteAfter,
|
||||||
|
hasStartedForPrestartActions,
|
||||||
|
} from '../src/turn/prestartDeletion.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
@@ -59,6 +63,8 @@ const buildFixture = (options: {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
|
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
|
||||||
|
lastTurnTick:
|
||||||
|
options.lastTurnTime && options.lastTurnTime > new Date('2026-08-01T00:00:00.000Z') ? 36_000_000 : 0,
|
||||||
meta: { opentime: '2026-08-01T00:00:00.000Z' },
|
meta: { opentime: '2026-08-01T00:00:00.000Z' },
|
||||||
};
|
};
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
@@ -118,6 +124,21 @@ const buildFixture = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('pre-start general deletion', () => {
|
describe('pre-start general deletion', () => {
|
||||||
|
it('uses the opening phase and executed tick despite shifted or ancient display projections', () => {
|
||||||
|
const state = { lastTurnTime: new Date('2099-01-01T00:00:00Z'), meta: { opentime: '2026-01-01T00:00:00Z' } };
|
||||||
|
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'PREOPEN', lastTurnTick: 0 })).toBe(false);
|
||||||
|
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'RUNNING', lastTurnTick: 0 })).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasStartedForPrestartActions({
|
||||||
|
...state,
|
||||||
|
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||||
|
clockPhase: 'RUNNING',
|
||||||
|
lastTurnTick: 1,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'COMPLETED', lastTurnTick: 0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
|
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
|
||||||
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
|
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { SystemClock } from '@sammo-ts/common';
|
import { SystemClock } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
@@ -97,6 +97,14 @@ const state: TurnWorldState = {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'),
|
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
|
clockBaseTime: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
|
clockTick: 0,
|
||||||
|
lastTurnTick: 0,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockPhase: 'PREOPEN',
|
||||||
|
clockWallAnchor: new Date(Date.now() + 86_400_000),
|
||||||
|
clockRevision: 1,
|
||||||
|
deadlineGeneration: 1,
|
||||||
meta: {
|
meta: {
|
||||||
hiddenSeed: 'immediate-action-integration',
|
hiddenSeed: 'immediate-action-integration',
|
||||||
killturn: 24,
|
killturn: 24,
|
||||||
@@ -155,8 +163,10 @@ integration('immediate general action persistence', () => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
disconnect = () => connector.disconnect();
|
disconnect = () => connector.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
beforeEach(async () => {
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
||||||
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
||||||
await db.logEntry.deleteMany({
|
await db.logEntry.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -181,6 +191,14 @@ integration('immediate general action persistence', () => {
|
|||||||
currentYear: state.currentYear,
|
currentYear: state.currentYear,
|
||||||
currentMonth: state.currentMonth,
|
currentMonth: state.currentMonth,
|
||||||
tickSeconds: state.tickSeconds,
|
tickSeconds: state.tickSeconds,
|
||||||
|
clockBaseTime: state.clockBaseTime,
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
clockMode: state.clockMode,
|
||||||
|
clockPhase: state.clockPhase,
|
||||||
|
clockWallAnchor: state.clockWallAnchor,
|
||||||
|
clockRevision: 1n,
|
||||||
|
deadlineGeneration: 1n,
|
||||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
@@ -257,7 +275,7 @@ integration('immediate general action persistence', () => {
|
|||||||
await disconnect?.();
|
await disconnect?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
||||||
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
||||||
await db.logEntry.deleteMany({
|
await db.logEntry.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -277,7 +295,7 @@ integration('immediate general action persistence', () => {
|
|||||||
await disconnect?.();
|
await disconnect?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flushes and reloads the nation, diplomacy, officer turns, logs, and general state together', async () => {
|
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: [general],
|
generals: [general],
|
||||||
cities: [
|
cities: [
|
||||||
@@ -376,10 +394,15 @@ integration('immediate general action persistence', () => {
|
|||||||
});
|
});
|
||||||
const stateStore = {
|
const stateStore = {
|
||||||
loadLastTurnTime: async () => new Date(state.lastTurnTime),
|
loadLastTurnTime: async () => new Date(state.lastTurnTime),
|
||||||
loadNextGeneralTurnTime: async () => null,
|
loadNextGeneralTurnTime: async () => general.turnTime,
|
||||||
saveLastTurnTime: async () => {},
|
saveLastTurnTime: async () => {},
|
||||||
loadCheckpoint: async () => undefined,
|
loadCheckpoint: async () => undefined,
|
||||||
saveCheckpoint: async () => {},
|
saveCheckpoint: async () => {},
|
||||||
|
loadGameClock: async () => ({
|
||||||
|
mode: 'realtime' as const,
|
||||||
|
phase: 'PREOPEN' as const,
|
||||||
|
now: world.getGameNow(new Date()),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
const processor = {
|
const processor = {
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -597,5 +620,108 @@ integration('immediate general action persistence', () => {
|
|||||||
chiefGeneralId: generalId,
|
chiefGeneralId: generalId,
|
||||||
rice: 2_000,
|
rice: 2_000,
|
||||||
});
|
});
|
||||||
|
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a neutral general after the wall deadline in PREOPEN and commits its durable result once', async () => {
|
||||||
|
const cutoff = new Date('2026-07-31T00:20:00.000Z');
|
||||||
|
await db.general.update({
|
||||||
|
where: { id: generalId },
|
||||||
|
data: { meta: { ...general.meta, prestart_delete_after: cutoff.toISOString() } },
|
||||||
|
});
|
||||||
|
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { schedule });
|
||||||
|
const handler = createTurnDaemonCommandHandler({ world });
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
const stateManager = new EngineStateManager();
|
||||||
|
stateManager.register('world', {
|
||||||
|
capture: () => world.captureState(),
|
||||||
|
restore: (value) => world.restoreState(value),
|
||||||
|
});
|
||||||
|
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||||
|
const processor = {
|
||||||
|
run: vi.fn(async () => {
|
||||||
|
throw new Error('PREOPEN must not execute scheduled turns');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const ids = [':status', ':early', ':delete'].map((suffix) => requestId + suffix);
|
||||||
|
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'dieOnPrestart'];
|
||||||
|
await db.inputEvent.createMany({
|
||||||
|
data: ids.map((id, index) => ({
|
||||||
|
requestId: id,
|
||||||
|
target: 'ENGINE',
|
||||||
|
eventType: types[index]!,
|
||||||
|
actorUserId: general.userId,
|
||||||
|
createdAt: new Date(cutoff.getTime() + (index === 2 ? 0 : -1)),
|
||||||
|
payload: { type: types[index], requestId: id, userId: general.userId, generalId },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
|
{
|
||||||
|
clock: new SystemClock(),
|
||||||
|
controlQueue: queue,
|
||||||
|
commandResponder: queue,
|
||||||
|
commandHandler: handler,
|
||||||
|
hooks: hooks.hooks,
|
||||||
|
stateManager,
|
||||||
|
processor,
|
||||||
|
getNextTickTime: () => general.turnTime,
|
||||||
|
stateStore: {
|
||||||
|
loadLastTurnTime: async () => state.lastTurnTime,
|
||||||
|
loadNextGeneralTurnTime: async () => general.turnTime,
|
||||||
|
saveLastTurnTime: async () => {},
|
||||||
|
loadCheckpoint: async () => undefined,
|
||||||
|
saveCheckpoint: async () => {},
|
||||||
|
loadGameClock: async () => ({
|
||||||
|
mode: 'realtime',
|
||||||
|
phase: 'PREOPEN',
|
||||||
|
now: world.getGameNow(new Date()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profile: 'immediate-action-integration',
|
||||||
|
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const loop = lifecycle.start();
|
||||||
|
try {
|
||||||
|
await vi.waitFor(
|
||||||
|
async () => {
|
||||||
|
const events = await db.inputEvent.findMany({
|
||||||
|
where: { requestId: { in: ids } },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
});
|
||||||
|
expect(events.map((event) => event.status)).toEqual(['SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED']);
|
||||||
|
expect(events[0]?.result).toMatchObject({
|
||||||
|
show: true,
|
||||||
|
available: false,
|
||||||
|
availableAt: cutoff.toISOString(),
|
||||||
|
});
|
||||||
|
expect(events[1]?.result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: expect.stringContaining('아직 삭제할 수 없습니다'),
|
||||||
|
});
|
||||||
|
expect(events[2]?.result).toMatchObject({ ok: true, generalId });
|
||||||
|
expect(
|
||||||
|
events.every((event) => event.processingGameTick !== null && event.processingGameTick < 0n)
|
||||||
|
).toBe(true);
|
||||||
|
expect(events.every((event) => event.attempts === 1)).toBe(true);
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await lifecycle.stop('pre-opening deletion checked');
|
||||||
|
await loop;
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
expect(processor.run).not.toHaveBeenCalled();
|
||||||
|
expect(await queue.drain()).toEqual([]);
|
||||||
|
expect(await db.general.findUnique({ where: { id: generalId } })).toBeNull();
|
||||||
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toBeUndefined();
|
||||||
|
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
|
||||||
|
expect(await db.logEntry.count({ where: { scope: 'SYSTEM', text: { contains: '홀연히 모습을' } } })).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ const buildImmediateActionWorld = (options: {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'),
|
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'),
|
||||||
|
lastTurnTick: options.lastTurnTime && options.lastTurnTime > new Date('0180-02-01T00:00:00Z') ? 36_000_000 : 0,
|
||||||
meta: {
|
meta: {
|
||||||
hiddenSeed: 'immediate-action-test',
|
hiddenSeed: 'immediate-action-test',
|
||||||
killturn: 24,
|
killturn: 24,
|
||||||
|
|||||||
@@ -212,6 +212,24 @@ describe('runtime clock shift', () => {
|
|||||||
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
|
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the formal wall opening when PREOPEN game display dates are shifted', () => {
|
||||||
|
const openAt = new Date('2026-09-06T00:00:00.000Z');
|
||||||
|
const now = new Date('2026-09-05T00:00:00.000Z');
|
||||||
|
const world = buildWorld({
|
||||||
|
clockBaseTime: new Date('2026-07-30T10:00:00.000Z'),
|
||||||
|
clockTick: 0,
|
||||||
|
clockMode: 'realtime',
|
||||||
|
clockWallAnchor: openAt,
|
||||||
|
lastTurnTick: 0,
|
||||||
|
clockPhase: 'PREOPEN',
|
||||||
|
});
|
||||||
|
world.shiftSchedule(15, now);
|
||||||
|
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt });
|
||||||
|
expect(world.promotePreopenAtOpening(now)).toBe(false);
|
||||||
|
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z'));
|
||||||
|
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects gameplay commits while the durable clock is suspended', async () => {
|
it('rejects gameplay commits while the durable clock is suspended', async () => {
|
||||||
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
|
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ import {
|
|||||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||||
|
|
||||||
describe('TurnDaemonLifecycle', () => {
|
describe('TurnDaemonLifecycle', () => {
|
||||||
|
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)(
|
||||||
|
'does not dispatch an explicit run while the clock phase is %s',
|
||||||
|
async (phase) => {
|
||||||
|
const now = new Date('2026-09-05T00:00:00.000Z');
|
||||||
|
const controlQueue = new InMemoryControlQueue();
|
||||||
|
controlQueue.enqueue({ type: 'run', reason: 'manual' });
|
||||||
|
const processor = { run: vi.fn() };
|
||||||
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
|
{
|
||||||
|
clock: new ManualClock(now.getTime()),
|
||||||
|
controlQueue,
|
||||||
|
processor,
|
||||||
|
getNextTickTime: (value) => addMinutes(value, 5),
|
||||||
|
stateStore: {
|
||||||
|
loadLastTurnTime: async () => now,
|
||||||
|
loadNextGeneralTurnTime: async () => now,
|
||||||
|
saveLastTurnTime: async () => {},
|
||||||
|
loadCheckpoint: async () => undefined,
|
||||||
|
saveCheckpoint: async () => {},
|
||||||
|
loadGameClock: async () => {
|
||||||
|
controlQueue.enqueue({ type: 'shutdown' });
|
||||||
|
return { mode: 'realtime', phase, now };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profile: 'clock-phase-run-gate',
|
||||||
|
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await lifecycle.start();
|
||||||
|
expect(processor.run).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('durably rebases a long realtime backlog before executing another turn', async () => {
|
it('durably rebases a long realtime backlog before executing another turn', async () => {
|
||||||
const wallNow = new Date('2026-08-23T01:35:00.000Z');
|
const wallNow = new Date('2026-08-23T01:35:00.000Z');
|
||||||
const clock = new ManualClock(wallNow.getTime());
|
const clock = new ManualClock(wallNow.getTime());
|
||||||
|
|||||||
@@ -316,7 +316,13 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
where:
|
where:
|
||||||
candidate.status === 'QUEUED'
|
candidate.status === 'QUEUED'
|
||||||
? { id: candidate.id, status: 'QUEUED' }
|
? { id: candidate.id, status: 'QUEUED' }
|
||||||
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil },
|
: {
|
||||||
|
id: candidate.id,
|
||||||
|
status: 'RUNNING',
|
||||||
|
// 마이크로초 lease를 Date로 왕복한 값과 equality CAS하면
|
||||||
|
// 만료된 행도 선점하지 못한다. 갱신 race는 만료 조건으로 막는다.
|
||||||
|
leaseUntil: candidate.leaseUntil ? { lt: now } : null,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
startedAt: candidate.startedAt ?? now,
|
startedAt: candidate.startedAt ?? now,
|
||||||
|
|||||||
@@ -802,7 +802,13 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
where:
|
where:
|
||||||
candidate.status === 'QUEUED'
|
candidate.status === 'QUEUED'
|
||||||
? { id: candidate.id, status: 'QUEUED' }
|
? { id: candidate.id, status: 'QUEUED' }
|
||||||
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil },
|
: {
|
||||||
|
id: candidate.id,
|
||||||
|
status: 'RUNNING',
|
||||||
|
// DB microseconds는 JS Date로 읽을 때 잘린다. timestamp
|
||||||
|
// 동등 비교 대신 갱신되지 않은 만료 lease인지 DB에서 재검사한다.
|
||||||
|
leaseUntil: candidate.leaseUntil ? { lt: now } : null,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
startedAt: candidate.startedAt ?? now,
|
startedAt: candidate.startedAt ?? now,
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ describeDatabase('gateway release operation persistence', () => {
|
|||||||
|
|
||||||
await connector.prisma.$executeRaw`
|
await connector.prisma.$executeRaw`
|
||||||
UPDATE "gateway_release_operation"
|
UPDATE "gateway_release_operation"
|
||||||
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
SET "lease_until" = date_trunc('milliseconds', CURRENT_TIMESTAMP)
|
||||||
|
- INTERVAL '1 second' + INTERVAL '123 microseconds'
|
||||||
WHERE "id" = ${operation.id}
|
WHERE "id" = ${operation.id}
|
||||||
`;
|
`;
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -417,7 +417,8 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
await connector.prisma.$executeRaw`
|
await connector.prisma.$executeRaw`
|
||||||
UPDATE "gateway_operation"
|
UPDATE "gateway_operation"
|
||||||
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
SET "lease_until" = date_trunc('milliseconds', CURRENT_TIMESTAMP)
|
||||||
|
- INTERVAL '1 second' + INTERVAL '123 microseconds'
|
||||||
WHERE "id" = ${operation.id}
|
WHERE "id" = ${operation.id}
|
||||||
`;
|
`;
|
||||||
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
|
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
|
|||||||
|
|
||||||
## 실행 모드
|
## 실행 모드
|
||||||
|
|
||||||
|
`PREOPEN`은 사용자 명령을 처리하는 가오픈 상태입니다. 장수 생성·선택·빙의,
|
||||||
|
사전 거병·삭제·예약과 설정은 durable ENGINE 명령으로 처리하지만, 자동 턴과
|
||||||
|
명시적 `run` 요청의 시간 진행은 `RUNNING`/`MANUAL`에서만 실행합니다.
|
||||||
|
`SUSPENDED`/`RECONCILING`/`COMPLETED`를 가오픈과 같은 상태로 취급하지 않습니다.
|
||||||
|
|
||||||
|
가오픈 삭제 대기는 생성 접수의 DB WALL_TIME에 설정한 턴 간격 배수를 더한
|
||||||
|
`prestart_delete_after`로 판정합니다. 음수 GAME tick이나 화면의 게임 날짜를
|
||||||
|
그 현실 기한과 비교하지 않습니다. 사전 거병·삭제의 개시 판정은 PREOPEN phase와
|
||||||
|
`last_turn_tick`을 사용하며, Ref의 개시 cursor 동등 경계는 유지합니다.
|
||||||
|
`opentime` 표시 문자열은 일정 재투영 뒤에도 이 판정의 권위가 아닙니다.
|
||||||
|
|
||||||
|
일반·선택 장수 생성의 GAME 효과와 RNG에는 daemon의 `processing_game_tick`을
|
||||||
|
사용하고, 최초 실행 턴은 PREOPEN에서 tick 0보다 앞에 놓지 않습니다. 계정에서
|
||||||
|
가져오는 제재의 Unix `expire`는 접수 DB WALL_TIME과 비교합니다.
|
||||||
|
|
||||||
- `GAME_CLOCK_MODE=realtime`: `clock_wall_anchor` 이후의 실제 경과시간을
|
- `GAME_CLOCK_MODE=realtime`: `clock_wall_anchor` 이후의 실제 경과시간을
|
||||||
game tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지
|
game tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지
|
||||||
않습니다.
|
않습니다.
|
||||||
@@ -66,6 +81,9 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용
|
|||||||
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
|
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
|
||||||
시각으로 다시 고정합니다.
|
시각으로 다시 고정합니다.
|
||||||
|
|
||||||
|
PREOPEN에서 이 투영 조정을 수행할 때는 예정된 `clock_wall_anchor`를 유지합니다.
|
||||||
|
GAME 표시 좌표를 옮기는 명령이 별도로 예약된 정식 WALL 오픈을 앞당기지 않습니다.
|
||||||
|
|
||||||
통일 후 이민족 선택을 기다리는 `UNIFICATION_WAIT`는 일반 maintenance 재개와
|
통일 후 이민족 선택을 기다리는 `UNIFICATION_WAIT`는 일반 maintenance 재개와
|
||||||
다릅니다. 운영상 STOP된 profile을 RESUME하면 Gateway는 프로세스만 다시 띄우고
|
다릅니다. 운영상 STOP된 profile을 RESUME하면 Gateway는 프로세스만 다시 띄우고
|
||||||
게임 clock은 `SUSPENDED`로 유지합니다. daemon이 수신자 소유권과 command fence를
|
게임 clock은 `SUSPENDED`로 유지합니다. daemon이 수신자 소유권과 command fence를
|
||||||
@@ -89,6 +107,10 @@ game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서
|
|||||||
적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME,
|
적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME,
|
||||||
프로세스 대기 budget은 monotonic time입니다.
|
프로세스 대기 budget은 monotonic time입니다.
|
||||||
|
|
||||||
|
daemon 종료가 이미 local queue에 있으면 새 DB 명령을 선점하지 않습니다.
|
||||||
|
실행하지 않을 명령을 `PROCESSING`으로 남겨 교체 daemon이 lease 만료를 기다리게
|
||||||
|
하지 않고, 기존 PENDING 명령은 즉시 다음 daemon이 claim할 수 있게 유지합니다.
|
||||||
|
|
||||||
경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME
|
경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME
|
||||||
사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고,
|
사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고,
|
||||||
optimistic 경합 판정은 임의 UUID의
|
optimistic 경합 판정은 임의 UUID의
|
||||||
@@ -97,3 +119,9 @@ optimistic 경합 판정은 임의 UUID의
|
|||||||
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
|
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
|
||||||
재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를
|
재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를
|
||||||
사용합니다.
|
사용합니다.
|
||||||
|
|
||||||
|
## 통합 검증
|
||||||
|
|
||||||
|
`CLOCK_RECONCILIATION_DATABASE_URL`과 `REDIS_URL`을 사용하는 실제 reconciliation
|
||||||
|
검증은 conditional integration registry의 core group에 포함합니다. 일반 단위
|
||||||
|
테스트의 조건부 skip을 실제 PostgreSQL/Redis 검증 통과로 간주하지 않습니다.
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ explicitly calls them game projections.
|
|||||||
|
|
||||||
## Game database inventory
|
## Game database inventory
|
||||||
|
|
||||||
|
PREOPEN user commands are executable even though scheduled turns are stopped.
|
||||||
|
Their authoritative processing coordinate remains signed GAME_TIME. Direct and
|
||||||
|
selection-pool creation clamp only the first runnable schedule to opening tick
|
||||||
|
zero; receipt audit, account sanctions, and pre-opening deletion use WALL_TIME.
|
||||||
|
|
||||||
|
| Pre-opening/account rule | Domain and authority | Reconciliation |
|
||||||
|
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||||
|
| `general.meta.prestart_delete_after` | WALL deadline from the creation `InputEvent.createdAt` plus the configured minimum-turn wall duration; legacy missing cutoffs use `general_access_log.last_refresh` once | excluded; never shift with GAME projections |
|
||||||
|
| account `legacyPenalty.*.*.expire` copied at general creation/possession | WALL Unix seconds compared with the durable request receipt | excluded; an expired account sanction must not reappear when GAME dates are in the past |
|
||||||
|
| pre-opening action availability | `clock_phase`, then executed `last_turn_tick` (zero equality retained); uninitialized legacy readers may compare old date fields | phase/cursor authority, not `meta.opentime` display text |
|
||||||
|
|
||||||
`Pause` means whether the rule continues to age during `SUSPENDED` or
|
`Pause` means whether the rule continues to age during `SUSPENDED` or
|
||||||
`RECONCILING`. `Projection` means a non-authoritative compatibility/display
|
`RECONCILING`. `Projection` means a non-authoritative compatibility/display
|
||||||
representation.
|
representation.
|
||||||
@@ -173,3 +184,12 @@ Migration `20260903201500_complete_invader_game_clock` moves historical
|
|||||||
unchosen invader actions at `max(created_game_tick, world_state.clock_tick)`.
|
unchosen invader actions at `max(created_game_tick, world_state.clock_tick)`.
|
||||||
Neither migration shifts a WALL occurrence or turns one rule into a clock
|
Neither migration shifts a WALL occurrence or turns one rule into a clock
|
||||||
fallback.
|
fallback.
|
||||||
|
|
||||||
|
## WALL lease precision and query parameters
|
||||||
|
|
||||||
|
Gateway operation/release lease recovery rechecks expiration in the database
|
||||||
|
under the existing claim lock. It does not compare a JavaScript Date to the
|
||||||
|
original `timestamptz(6)` value: that round trip loses microseconds and can prevent
|
||||||
|
expired jobs from being reclaimed. Owner fences and renewal checks remain active.
|
||||||
|
Read-model outbox dispatch casts an injected WALL timestamp explicitly before
|
||||||
|
subtracting a retry interval; production still defaults to the database WALL clock.
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ export interface ReadModelOutboxDispatchResult {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeLimit = (value: number | undefined): number =>
|
const normalizeLimit = (value: number | undefined): number => Math.min(500, Math.max(1, Math.floor(value ?? 50)));
|
||||||
Math.min(500, Math.max(1, Math.floor(value ?? 50)));
|
|
||||||
|
|
||||||
const normalizeDuration = (value: number | undefined, fallback: number): number =>
|
const normalizeDuration = (value: number | undefined, fallback: number): number =>
|
||||||
Math.max(1, Math.floor(value ?? fallback));
|
Math.max(1, Math.floor(value ?? fallback));
|
||||||
@@ -59,7 +58,9 @@ export const claimReadModelOutboxBatch = async (
|
|||||||
const limit = normalizeLimit(options.limit);
|
const limit = normalizeLimit(options.limit);
|
||||||
const leaseMs = normalizeDuration(options.leaseMs, 30_000);
|
const leaseMs = normalizeDuration(options.leaseMs, 30_000);
|
||||||
const nowSql = options.now
|
const nowSql = options.now
|
||||||
? GamePrisma.sql`${options.now}`
|
? // Date bind는 interval 뺄셈에서 interval로 추론될 수 있으므로
|
||||||
|
// 저장 column과 같은 UTC wall timestamp 타입을 명시한다.
|
||||||
|
GamePrisma.sql`${options.now}::timestamp`
|
||||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||||
const rows = await db.$queryRaw<ClaimedRow[]>(GamePrisma.sql`
|
const rows = await db.$queryRaw<ClaimedRow[]>(GamePrisma.sql`
|
||||||
WITH candidates AS (
|
WITH candidates AS (
|
||||||
@@ -170,7 +171,11 @@ export const dispatchReadModelOutboxBatch = async (
|
|||||||
owner: options.owner,
|
owner: options.owner,
|
||||||
error,
|
error,
|
||||||
...(testNow
|
...(testNow
|
||||||
? { availableAt: new Date(testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)) }
|
? {
|
||||||
|
availableAt: new Date(
|
||||||
|
testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)
|
||||||
|
),
|
||||||
|
}
|
||||||
: { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }),
|
: { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# Environment variable Execution mode
|
# Environment variable Execution mode
|
||||||
|
CLOCK_RECONCILIATION_DATABASE_URL core
|
||||||
CREATE_GENERAL_DATABASE_URL create_general
|
CREATE_GENERAL_DATABASE_URL create_general
|
||||||
CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture
|
CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture
|
||||||
GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
|
GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
|
||||||
|
|||||||
|
@@ -596,6 +596,7 @@ database_url=$(build_database_url "$integration_schema")
|
|||||||
export POSTGRES_SCHEMA=$integration_schema
|
export POSTGRES_SCHEMA=$integration_schema
|
||||||
export DATABASE_URL=$database_url
|
export DATABASE_URL=$database_url
|
||||||
export INPUT_EVENT_DATABASE_URL=$database_url
|
export INPUT_EVENT_DATABASE_URL=$database_url
|
||||||
|
export CLOCK_RECONCILIATION_DATABASE_URL=$database_url
|
||||||
export GENERAL_LIFECYCLE_DATABASE_URL=$database_url
|
export GENERAL_LIFECYCLE_DATABASE_URL=$database_url
|
||||||
export TURN_DAEMON_LEASE_DATABASE_URL=$database_url
|
export TURN_DAEMON_LEASE_DATABASE_URL=$database_url
|
||||||
export TURN_DIFFERENTIAL_DATABASE_URL=$database_url
|
export TURN_DIFFERENTIAL_DATABASE_URL=$database_url
|
||||||
|
|||||||
Reference in New Issue
Block a user