Merge branch 'main' into compare/scenario2601-200-parity-20260815

This commit is contained in:
2026-08-15 09:42:02 +00:00
99 changed files with 1052 additions and 1897 deletions
-55
View File
@@ -7,14 +7,6 @@ import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock
interface RedisSortedSetClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
zRem(key: string, values: string | string[]): Promise<number>;
}
export interface AuctionEventUpdate {
auctionId: number;
closeAt: Date;
eventId: string;
eventAt: Date;
}
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
@@ -58,50 +50,3 @@ export const seedAuctionTimers = async (
await redis.zAdd(keys.timerKey, payload);
return payload.length;
};
export const applyAuctionEvent = async (
db: DatabaseClient,
redis: RedisSortedSetClient,
keys: AuctionTimerKeys,
event: AuctionEventUpdate
): Promise<boolean> => {
const now = new Date();
const gameTime = await loadCurrentGameTime(db, now);
const closeTick = gameTime.dateToTick(event.closeAt);
const updated = await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET close_at = ${event.closeAt},
close_tick = ${closeTick === null ? null : BigInt(closeTick)},
latest_event_id = ${event.eventId},
latest_event_at = ${event.eventAt},
updated_at = ${now}
WHERE id = ${event.auctionId}
AND status = 'OPEN'
AND (
latest_event_at < ${event.eventAt}
OR (latest_event_at = ${event.eventAt} AND latest_event_id < ${event.eventId})
)
`
);
if (updated > 0) {
await redis.zAdd(keys.timerKey, [
{
score: resolveAuctionTimerScore(gameTime, event.closeAt, closeTick === null ? null : BigInt(closeTick)),
value: String(event.auctionId),
},
]);
return true;
}
return false;
};
export const removeAuctionTimer = async (
redis: RedisSortedSetClient,
keys: AuctionTimerKeys,
auctionId: number
): Promise<void> => {
await redis.zRem(keys.timerKey, String(auctionId));
};
-45
View File
@@ -1,45 +0,0 @@
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import { isAfter, isValid, parseISO } from 'date-fns';
import type { FlushStore } from './flushStore.js';
export interface GameTokenVerifier {
verify(token: string): GameSessionTokenPayload | null;
}
const parseDate = (value: string): Date | null => {
const parsed = parseISO(value);
return isValid(parsed) ? parsed : null;
};
export const createGameTokenVerifier = (options: {
secret: string;
profileName: string;
flushStore: FlushStore;
}): GameTokenVerifier => {
return {
verify: (token: string): GameSessionTokenPayload | null => {
const payload = decryptGameSessionToken(token, options.secret);
if (!payload) {
return null;
}
if (payload.profile !== options.profileName) {
return null;
}
const expiresAt = parseDate(payload.expiresAt);
const issuedAt = parseDate(payload.issuedAt);
if (!expiresAt || !issuedAt) {
return null;
}
if (isAfter(new Date(), expiresAt)) {
return null;
}
const flushedAt = options.flushStore.getFlushedAt(payload.user.id);
if (flushedAt && issuedAt <= flushedAt) {
return null;
}
return payload;
},
};
};
+3 -3
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import type { BattleSimRequestPayload } from './types.js';
export const zBattleSimGeneral = z.object({
const zBattleSimGeneral = z.object({
no: z.number().int().positive(),
name: z.string().min(1),
nation: z.number().int().positive(),
@@ -46,7 +46,7 @@ export const zBattleSimGeneral = z.object({
inheritBuff: z.union([z.record(z.string(), z.number()), z.array(z.number())]).optional(),
});
export const zBattleSimCity = z.object({
const zBattleSimCity = z.object({
city: z.number().int().positive(),
nation: z.number().int().min(0),
supply: z.number().int().min(0),
@@ -70,7 +70,7 @@ export const zBattleSimCity = z.object({
conflict: z.string(),
});
export const zBattleSimNation = z.object({
const zBattleSimNation = z.object({
type: z.string().min(1),
tech: z.number().min(0),
level: z.number().int().min(0),
-152
View File
@@ -1,152 +0,0 @@
import { randomUUID } from 'node:crypto';
import type { TurnDaemonStreamKeys } from './streamKeys.js';
import type { TurnDaemonTransport } from './transport.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonCommandResult,
TurnDaemonEventEnvelope,
TurnDaemonStatus,
} from './types.js';
interface RedisTurnDaemonTransportOptions {
keys: TurnDaemonStreamKeys;
requestTimeoutMs: number;
}
interface RedisClientLike {
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
xRead(streams: { key: string; id: string }, options?: { BLOCK?: number; COUNT?: number }): Promise<unknown>;
}
type RedisStreamReadResponse = Array<{
name: string;
messages: Array<{ id: string; message: Record<string, string> }>;
}>;
const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => {
const requestId = command.requestId ?? randomUUID();
return {
requestId,
sentAt: new Date().toISOString(),
command,
};
};
const parseEventEnvelope = (raw: string): TurnDaemonEventEnvelope | null => {
try {
const parsed = JSON.parse(raw) as Partial<TurnDaemonEventEnvelope>;
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (!parsed.event || typeof parsed.event !== 'object') {
return null;
}
if (typeof parsed.sentAt !== 'string') {
return null;
}
return parsed as TurnDaemonEventEnvelope;
} catch {
return null;
}
};
// 턴 데몬 제어 스트림을 Redis로 구현한 전송기.
export class RedisTurnDaemonTransport implements TurnDaemonTransport {
private readonly client: RedisClientLike;
private readonly keys: TurnDaemonStreamKeys;
private readonly requestTimeoutMs: number;
constructor(client: RedisClientLike, options: RedisTurnDaemonTransportOptions) {
this.client = client;
this.keys = options.keys;
this.requestTimeoutMs = options.requestTimeoutMs;
}
// Redis 스트림에 명령을 기록해서 턴 데몬에게 전달한다.
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const envelope = buildCommandEnvelope(command);
await this.client.xAdd(this.keys.commandStream, '*', {
payload: JSON.stringify(envelope),
});
return envelope.requestId;
}
async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '0-0';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (envelope.event.type === 'commandResult' && envelope.requestId === requestId) {
return envelope.event.result;
}
}
}
}
return null;
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
const requestId = randomUUID();
await this.sendCommand({ type: 'getStatus', requestId });
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '0-0';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (envelope.event.type === 'status' && envelope.requestId === requestId) {
return envelope.event.status;
}
}
}
}
return null;
}
}
-2
View File
@@ -14,9 +14,7 @@ export * from './daemon/transport.js';
export * from './daemon/databaseTransport.js';
export * from './daemon/idempotentTransport.js';
export * from './daemon/inMemoryTransport.js';
export * from './daemon/redisTransport.js';
export * from './auth/flushStore.js';
export * from './auth/tokenVerifier.js';
export * from './battleSim/types.js';
export * from './battleSim/transport.js';
export * from './battleSim/redisTransport.js';
+5 -1
View File
@@ -16,7 +16,11 @@ import {
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import {
getSelectionPoolStatus,
reserveSelectionPool,
resolveSelectionMaxGeneral,
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
ConflictingTurnDaemonCommandError,
RejectedNpcPossessionCommandError,
+1 -1
View File
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import { procedure, router } from '../../trpc.js';
export const lobbyRouter = router({
+2 -2
View File
@@ -210,9 +210,9 @@ export const resolveOfficerCity = (meta: Record<string, unknown>): number => {
return readMetaNumber(meta, 'officer_city', 0);
};
export const resolveBelong = (meta: Record<string, unknown>): number => readMetaNumber(meta, 'belong', 0);
const resolveBelong = (meta: Record<string, unknown>): number => readMetaNumber(meta, 'belong', 0);
export const resolvePermission = (meta: Record<string, unknown>): PermissionKind => {
const resolvePermission = (meta: Record<string, unknown>): PermissionKind => {
const value = meta.permission;
if (value === 'ambassador' || value === 'auditor') {
return value;
+8 -23
View File
@@ -70,14 +70,8 @@ export const resolveInheritConstants = (worldState: WorldStateRow): InheritConst
configConst.inheritBornTurntimePoint,
DEFAULT_INHERIT_CONST.inheritBornTurntimePoint
),
inheritBornCityPoint: asNumber(
configConst.inheritBornCityPoint,
DEFAULT_INHERIT_CONST.inheritBornCityPoint
),
inheritBornStatPoint: asNumber(
configConst.inheritBornStatPoint,
DEFAULT_INHERIT_CONST.inheritBornStatPoint
),
inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, DEFAULT_INHERIT_CONST.inheritBornCityPoint),
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
inheritItemUniqueMinPoint: asNumber(
configConst.inheritItemUniqueMinPoint,
DEFAULT_INHERIT_CONST.inheritItemUniqueMinPoint
@@ -152,18 +146,6 @@ export const setInheritancePoint = async (
});
};
export const addInheritancePoint = async (
db: DatabaseClient,
userId: string,
key: InheritPointKey,
delta: number
): Promise<number> => {
const current = await readInheritancePoint(db, userId, key);
const next = current + delta;
await setInheritancePoint(db, userId, key, next);
return next;
};
export const appendInheritanceLog = async (
db: DatabaseClient,
userId: string,
@@ -181,7 +163,7 @@ export const appendInheritanceLog = async (
});
};
export const readUserMetaValue = (meta: Record<string, unknown>, key: string): number => {
const readUserMetaValue = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
@@ -189,7 +171,7 @@ export const readUserMetaValue = (meta: Record<string, unknown>, key: string): n
return value;
};
export const computeDexPoint = (meta: Record<string, unknown>): number => {
const computeDexPoint = (meta: Record<string, unknown>): number => {
let total = 0;
for (const [key, value] of Object.entries(meta)) {
if (!key.startsWith('dex')) {
@@ -251,7 +233,10 @@ export const computeInheritanceItems = async (options: {
};
export const sumInheritanceItems = (items: Record<InheritPointKey, number>): number => {
return Object.entries(items).reduce((acc, [key, value]) => (key === 'previous' ? acc : acc + value), items.previous);
return Object.entries(items).reduce(
(acc, [key, value]) => (key === 'previous' ? acc : acc + value),
items.previous
);
};
export const readUserStateMeta = async (db: DatabaseClient, userId: string): Promise<Record<string, unknown>> => {
-12
View File
@@ -1,12 +0,0 @@
export {
buildSelectPoolSeed,
claimWeightedSelectionCandidates,
getSelectionPoolStatus,
isSelectionPoolWorld,
reserveSelectionPool,
resolveSelectionMaxGeneral,
SelectPoolError,
type SelectPoolCandidateDto,
type SelectPoolCandidateInfo,
type SelectPoolReservationDto,
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
+68 -46
View File
@@ -19,8 +19,6 @@ export type TournamentPrismaClient = {
$transaction: (actions: Promise<unknown>[]) => Promise<unknown[]>;
};
export const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10;
export const isPreBattleStage = (stage: number): boolean => stage >= 1 && stage <= 6;
@@ -50,7 +48,7 @@ export const resolveBettingCloseAt = (state: TournamentState): string => {
return new Date(resolveScheduledBaseMs(state) + Math.max(1000, bettingTermMs)).toISOString();
};
export const resolveStatValue = (
const resolveStatValue = (
type: TournamentType,
entry: { leadership: number; strength: number; intel: number }
): number => {
@@ -70,13 +68,34 @@ export const resolveStatValue = (
export const resolveGroupPair = (stage: number, phase: number): [number, number] | null => {
if (stage === 2) {
const pairMap: Array<[number, number]> = [
[0, 1], [2, 3], [4, 5], [6, 7],
[0, 2], [1, 3], [4, 6], [5, 7],
[0, 3], [1, 6], [2, 5], [4, 7],
[0, 4], [1, 5], [2, 6], [3, 7],
[0, 5], [1, 4], [2, 7], [3, 6],
[0, 6], [1, 7], [2, 4], [3, 5],
[0, 7], [1, 2], [3, 4], [5, 6],
[0, 1],
[2, 3],
[4, 5],
[6, 7],
[0, 2],
[1, 3],
[4, 6],
[5, 7],
[0, 3],
[1, 6],
[2, 5],
[4, 7],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
[0, 5],
[1, 4],
[2, 7],
[3, 6],
[0, 6],
[1, 7],
[2, 4],
[3, 5],
[0, 7],
[1, 2],
[3, 4],
[5, 6],
];
const basePair = pairMap[phase % 28];
if (!basePair) {
@@ -87,9 +106,12 @@ export const resolveGroupPair = (stage: number, phase: number): [number, number]
if (stage === 4) {
const pairMap: Array<[number, number]> = [
[0, 1], [2, 3],
[0, 2], [1, 3],
[0, 3], [1, 2],
[0, 1],
[2, 3],
[0, 2],
[1, 3],
[0, 3],
[1, 2],
];
return pairMap[phase % 6] ?? null;
}
@@ -133,10 +155,8 @@ export const assignGroupSlots = (
});
};
export const selectWeighted = <T>(
rng: ReturnType<typeof createTournamentRng>,
pool: Array<{ item: T; weight: number }>
): T => rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
export const fillParticipants = async (options: {
prisma: TournamentPrismaClient;
@@ -203,7 +223,10 @@ export const fillParticipants = async (options: {
while (result.length < limit && applicantPool.length > 0) {
const picked = selectWeighted(applicantRng, applicantPool);
applicantPool.splice(applicantPool.findIndex((entry) => entry.item.id === picked.id), 1);
applicantPool.splice(
applicantPool.findIndex((entry) => entry.item.id === picked.id),
1
);
takenIds.add(picked.id);
result.push(picked);
}
@@ -262,7 +285,10 @@ export const fillParticipants = async (options: {
while (result.length < limit && npcPool.length > 0) {
const picked = selectWeighted(npcRng, npcPool);
npcPool.splice(npcPool.findIndex((entry) => entry.item.id === picked.id), 1);
npcPool.splice(
npcPool.findIndex((entry) => entry.item.id === picked.id),
1
);
takenIds.add(picked.id);
result.push(picked);
}
@@ -344,28 +370,28 @@ export const applyGroupMatch = (
return {
participants: participants.map((entry) => {
if (entry.id !== attacker.id && entry.id !== defender.id) {
return entry;
}
const next = {
...entry,
win: entry.win ?? 0,
draw: entry.draw ?? 0,
lose: entry.lose ?? 0,
gl: entry.gl ?? 0,
};
if (result.draw) {
next.draw += 1;
if (entry.id !== attacker.id && entry.id !== defender.id) {
return entry;
}
const next = {
...entry,
win: entry.win ?? 0,
draw: entry.draw ?? 0,
lose: entry.lose ?? 0,
gl: entry.gl ?? 0,
};
if (result.draw) {
next.draw += 1;
return next;
}
if (result.winnerId === entry.id) {
next.win += 1;
next.gl += glDelta;
return next;
}
next.lose += 1;
next.gl -= glDelta;
return next;
}
if (result.winnerId === entry.id) {
next.win += 1;
next.gl += glDelta;
return next;
}
next.lose += 1;
next.gl -= glDelta;
return next;
}),
outcome,
};
@@ -573,7 +599,7 @@ export const buildTournamentRewardPayload = (
};
};
export const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -598,11 +624,7 @@ export const seedNpcBets = async (options: {
const matches = await store.getMatches();
const candidateIds = Array.from(
new Set(
matches
.filter((match) => match.stage === 7)
.flatMap((match) => [match.attackerId, match.defenderId])
)
new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]))
);
if (candidateIds.length === 0) {
return;
-7
View File
@@ -95,9 +95,6 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
export const accessProcedure: typeof procedure = t.procedure
.use(generalAccessEndpointMiddleware)
.use(inputEventMiddleware);
export const accessAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware)
@@ -119,10 +116,6 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
+3 -17
View File
@@ -1,7 +1,7 @@
import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js';
import { isRecord } from '@sammo-ts/common';
export const DEFAULT_TURN_ACTION = '휴식';
const DEFAULT_TURN_ACTION = '휴식';
export const MAX_GENERAL_TURNS = 30;
export const MAX_NATION_TURNS = 12;
@@ -157,7 +157,7 @@ const persistNationTurns = async (
});
};
export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnEntry[]> => {
const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnEntry[]> => {
const rows = await db.generalTurn.findMany({
where: { generalId },
orderBy: [{ turnIdx: 'asc' }],
@@ -165,11 +165,6 @@ export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): P
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
};
export const listGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnView[]> => {
const turns = await loadGeneralTurns(db, generalId);
return serializeTurnList(turns);
};
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow, general] = await Promise.all([
loadGeneralTurns(db, generalId),
@@ -187,7 +182,7 @@ export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: numb
};
};
export const loadNationTurns = async (
const loadNationTurns = async (
db: DatabaseClient,
nationId: number,
officerLevel: number
@@ -199,15 +194,6 @@ export const loadNationTurns = async (
return buildTurnListFromRows(rows, MAX_NATION_TURNS);
};
export const listNationTurns = async (
db: DatabaseClient,
nationId: number,
officerLevel: number
): Promise<ReservedTurnView[]> => {
const turns = await loadNationTurns(db, nationId, officerLevel);
return serializeTurnList(turns);
};
export const getNationTurnSnapshot = async (
db: DatabaseClient,
nationId: number,
+5 -27
View File
@@ -5,41 +5,19 @@ import { describe, expect, it } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
buildSelectPoolSeed,
claimWeightedSelectionCandidates,
} from '../src/services/selectPool.js';
import { buildSelectPoolSeed, claimWeightedSelectionCandidates } from '@sammo-ts/game-engine/turn/selectPoolService.js';
interface PoolResource {
data: Array<
[
string,
number,
number,
number,
string,
[number, number, number, number, number],
0 | 1,
string,
]
>;
data: Array<[string, number, number, number, string, [number, number, number, number, number], 0 | 1, string]>;
}
const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
const filePath = path.resolve(
import.meta.dirname,
'../../../resources/general-pool/SPoolUnderU30.json'
);
const filePath = path.resolve(import.meta.dirname, '../../../resources/general-pool/SPoolUnderU30.json');
const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource;
return resource.data.map((row, index) => [
{ id: index + 1 },
row[5].reduce((sum, value) => sum + value, 0),
]);
return resource.data.map((row, index) => [{ id: index + 1 }, row[5].reduce((sum, value) => sum + value, 0)]);
};
const drawVector = async (
hiddenSeed: string
): Promise<{ selected: number[]; draws: number[] }> => {
const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => {
const weighted = await loadWeightedRows();
const now = new Date('2026-07-30T03:34:56.000Z');
const draws: number[] = [];
-1
View File
@@ -1,7 +1,6 @@
import { runTurnDaemonCli } from './turn/cli.js';
export * from './lifecycle/types.js';
export * from './lifecycle/clock.js';
export * from './lifecycle/databaseCommandQueue.js';
export * from './lifecycle/databaseTurnDaemonLease.js';
export * from './lifecycle/inMemoryControlQueue.js';
-1
View File
@@ -1 +0,0 @@
export { ManualClock, StepClock, SystemClock } from '@sammo-ts/common';
+2 -2
View File
@@ -25,12 +25,12 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
const resolveMapRoot = (options?: MapLoaderOptions): string => options?.mapRoot ?? DEFAULT_MAP_ROOT;
export const resolveMapDefinitionPath = (mapName: string, options?: MapLoaderOptions): string => {
const resolveMapDefinitionPath = (mapName: string, options?: MapLoaderOptions): string => {
const prefix = options?.filePrefix ?? 'map_';
return path.resolve(resolveMapRoot(options), `${prefix}${mapName}.json`);
};
export const loadMapDefinition = async (mapPath: string): Promise<MapDefinition> => {
const loadMapDefinition = async (mapPath: string): Promise<MapDefinition> => {
const raw = await readJsonFile(mapPath);
return MapDefinitionSchema.parse(raw);
};
@@ -29,16 +29,16 @@ const resolveScenarioRoot = (options?: ScenarioLoaderOptions): string => options
export const resolveScenarioDefaultsPath = (options?: ScenarioLoaderOptions): string =>
path.resolve(resolveScenarioRoot(options), options?.defaultsFileName ?? 'default.json');
export const resolveScenarioPath = (options: ScenarioLoaderOptions | undefined, scenarioId: number): string =>
const resolveScenarioPath = (options: ScenarioLoaderOptions | undefined, scenarioId: number): string =>
path.resolve(resolveScenarioRoot(options), `scenario_${scenarioId}.json`);
export const loadScenarioDefaults = async (defaultsPath: string): Promise<ScenarioDefaults> => {
const loadScenarioDefaults = async (defaultsPath: string): Promise<ScenarioDefaults> => {
// 기본 시나리오 파일을 읽고 정규화한다.
const raw = await readJsonFile(defaultsPath);
return parseScenarioDefaults(raw);
};
export const loadScenarioDefinition = async (
const loadScenarioDefinition = async (
scenarioPath: string,
defaults: ScenarioDefaults
): Promise<ScenarioDefinition> => {
@@ -20,12 +20,12 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string => options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
export const resolveUnitSetDefinitionPath = (unitSetName: string, options?: UnitSetLoaderOptions): string => {
const resolveUnitSetDefinitionPath = (unitSetName: string, options?: UnitSetLoaderOptions): string => {
const prefix = options?.filePrefix ?? 'unitset_';
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
};
export const loadUnitSetDefinition = async (unitSetPath: string): Promise<UnitSetDefinition> => {
const loadUnitSetDefinition = async (unitSetPath: string): Promise<UnitSetDefinition> => {
const raw = await readJsonFile(unitSetPath);
return parseUnitSetDefinition(raw);
};
+3 -31
View File
@@ -1,7 +1,7 @@
import type { City, Nation } from '@sammo-ts/logic';
import { asRecord, isRecord } from '@sammo-ts/common';
import type { City } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
export { asRecord, isRecord };
export { asRecord };
export const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -16,21 +16,6 @@ export const readNumber = (value: unknown, fallback = 0): number => {
return fallback;
};
export const readBoolean = (value: unknown, fallback = false): boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
return value === 'true' || value === '1';
}
return fallback;
};
export const readString = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback);
export const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback = 0): number =>
readNumber(meta[key], fallback);
@@ -49,12 +34,6 @@ export const readRequiredMetaNumber = (meta: Record<string, unknown>, key: strin
throw new Error(`meta.${key} is required${suffix}.`);
};
export const readMetaString = (meta: Record<string, unknown>, key: string, fallback = ''): string =>
readString(meta[key], fallback);
export const readMetaBoolean = (meta: Record<string, unknown>, key: string, fallback = false): boolean =>
readBoolean(meta[key], fallback);
export const valueFit = (value: number, min?: number | null, max?: number | null): number => {
let next = value;
if (min !== null && min !== undefined && next < min) {
@@ -108,10 +87,3 @@ export const calcCityDevRatio = (city: City): number => {
}
return total / max;
};
export const readNationTech = (nation: Nation | null | undefined): number => {
if (!nation) {
return 0;
}
return readMetaNumber(asRecord(nation.meta), 'tech', 0);
};
@@ -31,8 +31,8 @@ import {
withCanonicalArgumentAliases,
} from '../aiUtils.js';
import { searchAllDistanceByNationList } from '@sammo-ts/logic/world/distance.js';
import { generalActionHandlers } from '../generalAiGeneralActions.js';
import { nationActionHandlers } from '../generalAiNationActions.js';
import { generalActionHandlers } from './general/index.js';
import { nationActionHandlers } from './nation/index.js';
import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js';
import { buildSeedBase } from './seed.js';
import { WorldStateView } from './worldStateView.js';
@@ -7,31 +7,6 @@ import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } fr
import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
import { do국가선택, do중립, do거병, do건국, do해산, do선양, do방랑군이동 } from './politicsActions.js';
export {
do일반내정,
do긴급내정,
do전쟁내정,
do금쌀구매,
do징병,
do전투준비,
do소집해제,
do출병,
do후방워프,
do전방워프,
do내정워프,
do귀환,
do집합,
doNPC헌납,
doNPC사망대비,
do국가선택,
do중립,
do거병,
do건국,
do해산,
do선양,
do방랑군이동,
};
export const generalActionHandlers: Record<
string,
(ai: GeneralAI) => ReturnType<GeneralAI['buildGeneralCandidate']> | null
@@ -12,29 +12,6 @@ import { do유저장긴급포상, do유저장포상, doNPC긴급포상, doNPC포
import { do불가침제의, do선전포고 } from './diplomacy.js';
import { do천도 } from './capital.js';
export {
do부대전방발령,
do부대후방발령,
do부대구출발령,
do부대유저장후방발령,
do유저장후방발령,
do유저장구출발령,
do유저장전방발령,
do유저장내정발령,
doNPC후방발령,
doNPC구출발령,
doNPC전방발령,
doNPC내정발령,
do유저장긴급포상,
do유저장포상,
doNPC긴급포상,
doNPC포상,
doNPC몰수,
do불가침제의,
do선전포고,
do천도,
};
export const nationActionHandlers: Record<
string,
(ai: GeneralAI) => ReturnType<GeneralAI['buildNationCandidate']> | null
@@ -1 +0,0 @@
export * from './generalAi/general/index.js';
@@ -1 +0,0 @@
export * from './generalAi/nation/index.js';
@@ -2,7 +2,7 @@ import type { TurnGeneral } from './types.js';
const DEX_LIMIT = 1_275_975;
export const STORED_INHERITANCE_KEYS = [
const STORED_INHERITANCE_KEYS = [
'lived_month',
'max_domestic_critical',
'active_action',
+471 -408
View File
@@ -6,6 +6,7 @@ import {
createEmptyRealtimeReadModelChanges,
GameClock,
hasRealtimeReadModelChanges,
SystemClock,
type GameClockMode,
type RealtimeEvent,
type RealtimeReadModelChanges,
@@ -13,7 +14,6 @@ import {
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
import { SystemClock } from '../lifecycle/clock.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js';
@@ -193,21 +193,18 @@ const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.
return resolveRedisConfigFromEnv(env);
};
const createTurnDaemonRuntimeWithLease = async (
options: TurnDaemonRuntimeOptions,
databaseFlushEnabled: boolean,
turnDaemonLease: DatabaseTurnDaemonLease | null
): Promise<TurnDaemonRuntime> => {
if (options.exclusiveFastForward && options.profileName) {
throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.');
}
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
const { state, snapshot } = await loadTurnWorldFromDatabase({
databaseUrl: options.databaseUrl,
mapOptions: options.mapOptions,
});
const clock = options.clock ?? new SystemClock();
type LoadedTurnWorld = Awaited<ReturnType<typeof loadTurnWorldFromDatabase>>;
type MonthlyActionModuleBundle = Awaited<ReturnType<typeof loadActionModuleBundle>>;
type NationTraitModuleMap = Map<string, Awaited<ReturnType<typeof loadNationTraitModules>>[number]>;
type ReservedTurnStoreHandle = Awaited<ReturnType<typeof createReservedTurnStore>>;
type RedisConnector = ReturnType<typeof createRedisConnector>;
type GamePostgresConnector = ReturnType<typeof createGamePostgresConnector>;
const resolveRuntimeState = (
state: LoadedTurnWorld['state'],
options: Pick<TurnDaemonRuntimeOptions, 'tickMinutes' | 'gameClockMode'>,
clock: Clock
) => {
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
const nextTickSeconds = tickMinutes * 60;
const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds;
@@ -231,22 +228,420 @@ const createTurnDaemonRuntimeWithLease = async (
...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}),
...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}),
};
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
const hasEventAction = (name: string): boolean =>
snapshot.events.some(
(event) =>
Array.isArray(event.action) &&
event.action.some((action) => Array.isArray(action) && action[0] === name)
return { tickMinutes, resolvedState };
};
const hasMonthlyEventAction = (events: LoadedTurnWorld['snapshot']['events'], name: string): boolean =>
events.some(
(event) =>
Array.isArray(event.action) && event.action.some((action) => Array.isArray(action) && action[0] === name)
);
const requiresReservedTurnStore = (events: LoadedTurnWorld['snapshot']['events']): boolean =>
[
'UpdateNationLevel',
'CreateManyNPC',
'RegNPC',
'RegNeutralNPC',
'RaiseNPCNation',
'RaiseInvader',
'AutoDeleteInvader',
'ProvideNPCTroopLeader',
].some((name) => hasMonthlyEventAction(events, name));
const createMonthlyEventActions = (options: {
databaseUrl: string;
snapshot: LoadedTurnWorld['snapshot'];
getWorld: () => InMemoryTurnWorld | null;
reservedTurnStoreHandle: ReservedTurnStoreHandle | null;
commandEnv: ReturnType<typeof buildCommandEnv>;
actionModules: MonthlyActionModuleBundle;
nationTraits: NationTraitModuleMap;
incomeHandler: ReturnType<typeof createIncomeHandler>;
}): Map<string, MonthlyEventActionHandler> => {
const eventActions = new Map<string, MonthlyEventActionHandler>();
eventActions.set(
'RandomizeCityTradeRate',
createRandomizeCityTradeRateHandler({
getWorld: options.getWorld,
})
);
eventActions.set(
'RaiseDisaster',
createRaiseDisasterHandler({
getWorld: options.getWorld,
generalActionModules: options.actionModules.general,
})
);
eventActions.set(
'UpdateCitySupply',
createUpdateCitySupplyHandler({
getWorld: options.getWorld,
map: options.snapshot.map,
})
);
eventActions.set(
'ProcessSemiAnnual',
createProcessSemiAnnualHandler({
getWorld: options.getWorld,
nationTraits: options.nationTraits,
})
);
eventActions.set(
'ProcessWarIncome',
createProcessWarIncomeHandler({
getWorld: options.getWorld,
nationTraits: options.nationTraits,
})
);
eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler());
if (options.reservedTurnStoreHandle) {
const reservedTurns = options.reservedTurnStoreHandle.store;
eventActions.set(
'CreateManyNPC',
createCreateManyNpcHandler({
getWorld: options.getWorld,
reservedTurns,
env: options.commandEnv,
})
);
const eventRequiresReservedTurns =
hasEventAction('UpdateNationLevel') ||
hasEventAction('CreateManyNPC') ||
hasEventAction('RegNPC') ||
hasEventAction('RegNeutralNPC') ||
hasEventAction('RaiseNPCNation') ||
hasEventAction('RaiseInvader') ||
hasEventAction('AutoDeleteInvader') ||
hasEventAction('ProvideNPCTroopLeader');
for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) {
eventActions.set(
actionName,
createRegisterNpcHandler({
actionName,
getWorld: options.getWorld,
reservedTurns,
env: options.commandEnv,
worldConfig: options.snapshot.worldConfig,
scenarioFiction: options.snapshot.scenarioMeta?.fiction,
})
);
}
eventActions.set(
'RaiseNPCNation',
createRaiseNpcNationHandler({
getWorld: options.getWorld,
reservedTurns,
env: options.commandEnv,
map: options.snapshot.map,
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
})
);
eventActions.set(
'RaiseInvader',
createRaiseInvaderHandler({
getWorld: options.getWorld,
reservedTurns,
env: options.commandEnv,
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
})
);
eventActions.set(
'AutoDeleteInvader',
createAutoDeleteInvaderHandler({
getWorld: options.getWorld,
reservedTurns,
})
);
eventActions.set(
'ProvideNPCTroopLeader',
createProvideNpcTroopLeaderHandler({
getWorld: options.getWorld,
reservedTurns,
env: options.commandEnv,
})
);
eventActions.set(
'UpdateNationLevel',
createUpdateNationLevelHandler({
getWorld: options.getWorld,
reservedTurns,
itemModules: options.actionModules.itemModules,
loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl),
})
);
}
eventActions.set('InvaderEnding', createInvaderEndingHandler({ getWorld: options.getWorld }));
eventActions.set('ChangeCity', createChangeCityHandler({ getWorld: options.getWorld }));
eventActions.set('OpenNationBetting', createOpenNationBettingHandler({ getWorld: options.getWorld }));
eventActions.set('FinishNationBetting', createFinishNationBettingHandler({ getWorld: options.getWorld }));
for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) {
eventActions.set(
actionName,
createScoutBlockHandler({
actionName,
getWorld: options.getWorld,
})
);
}
eventActions.set('AssignGeneralSpeciality', createAssignGeneralSpecialityHandler({ getWorld: options.getWorld }));
eventActions.set('AddGlobalBetray', createAddGlobalBetrayHandler({ getWorld: options.getWorld }));
eventActions.set(
'LostUniqueItem',
createLostUniqueItemHandler({
getWorld: options.getWorld,
itemModules: options.actionModules.itemModules,
})
);
eventActions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: options.getWorld }));
eventActions.set('ProcessIncome', createProcessIncomeActionHandler(options.incomeHandler));
eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: options.getWorld }));
eventActions.set('NewYear', createNewYearHandler({ getWorld: options.getWorld }));
eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: options.getWorld }));
return eventActions;
};
interface MonthlyRuntimeCache {
nationPowerRollCount: number;
tournamentRollConsumed: boolean;
}
const createMonthlyCalendarRuntime = async (options: {
databaseUrl: string;
profileName: string;
databaseFlushEnabled: boolean;
snapshot: LoadedTurnWorld['snapshot'];
currentYear: number;
commandEnv: ReturnType<typeof buildCommandEnv>;
incomeHandler: ReturnType<typeof createIncomeHandler>;
monthlyEventHandler: ReturnType<typeof createMonthlyEventHandler>;
hasEventAction: (name: string) => boolean;
calendarHandlerOverride?: TurnCalendarHandler;
getWorld: () => InMemoryTurnWorld | null;
getRedisClient: () => ReturnType<typeof createRedisConnector>['client'] | undefined;
clock: Clock;
}) => {
const cache: MonthlyRuntimeCache = {
nationPowerRollCount: options.snapshot.nations.length,
tournamentRollConsumed: false,
};
const unification = options.calendarHandlerOverride
? null
: createUnificationHandler({
profileName: options.profileName,
getWorld: options.getWorld,
loadPendingUniqueAuctions: options.databaseFlushEnabled
? () => loadPendingUnificationAuctionCancellations(options.databaseUrl)
: undefined,
dispatchUnitedEvents: (context) => options.monthlyEventHandler.dispatchTarget('united', context),
});
const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({
getWorld: options.getWorld,
startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear,
commandEnv: options.commandEnv,
});
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
getWorld: options.getWorld,
onNationPowerRollCount: (count) => {
cache.nationPowerRollCount = count;
},
});
const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
databaseUrl: options.databaseUrl,
profileName: options.profileName,
getWorld: options.getWorld,
getRedisClient: options.getRedisClient,
getWorldConfig: () => options.snapshot.worldConfig ?? null,
getNationPowerRollCount: () => cache.nationPowerRollCount,
getTournamentRollConsumed: () => cache.tournamentRollConsumed,
now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()),
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName,
getWorld: options.getWorld,
getRedisClient: options.getRedisClient,
getWorldConfig: () => options.snapshot.worldConfig ?? null,
getNationPowerRollCount: () => cache.nationPowerRollCount,
onTournamentRollConsumed: (consumed) => {
cache.tournamentRollConsumed = consumed;
},
// Deterministic/manual runtimes must schedule the tournament from the
// same clock that advances the game world. Production still falls
// back to the system clock.
now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()),
});
const calendarHandler = composeCalendarHandlers(
options.monthlyEventHandler,
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
monthlyBoundaryPreHandler,
createNationTurnMonthlyHandler({ getWorld: options.getWorld }),
monthlyNationStatsHandler,
createMonthlyDiplomacyHandler({ getWorld: options.getWorld }),
createMonthlyWarSettingHandler({ getWorld: options.getWorld }),
createMonthlyWanderHandler({
getWorld: options.getWorld,
startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear,
commandEnv: options.commandEnv,
}),
createMonthlyNationCountHandler({ getWorld: options.getWorld }),
options.calendarHandlerOverride ?? unification?.handler,
tournamentAutoStartHandler,
neutralAuctionRegistrar.handler,
createFrontStateHandler({ getWorld: options.getWorld, map: options.snapshot.map ?? null })
);
return { calendarHandler, neutralAuctionRegistrar, cache };
};
const createRealtimeRuntime = async (options: {
redisUrl?: string;
profileName: string;
hooks?: TurnDaemonHooks;
takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null;
}): Promise<{ redisConnector: RedisConnector | null; hooks?: TurnDaemonHooks }> => {
const redisConfig = resolveRedisConfig(options.redisUrl);
if (!redisConfig) {
return { redisConnector: null, hooks: options.hooks };
}
const redisConnector = createRedisConnector(redisConfig);
await redisConnector.connect();
const redisClient = redisConnector.client;
const realtimeChannel = buildGameEventChannel(options.profileName);
const revisionKey = buildGameReadModelRevisionKey(options.profileName);
const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName);
const publishRealtimeEvent = async (event: RealtimeEvent): Promise<void> => {
await redisClient.publish(realtimeChannel, JSON.stringify(event));
};
const publishReadModelChanges = async (changes: RealtimeReadModelChanges): Promise<number> => {
if (
changes.worldChanged ||
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
(changes.mapNationIds ?? changes.nationIds).length > 0
) {
await redisClient.hIncrBy(domainRevisionKey, 'world', 1);
}
return redisClient.incr(revisionKey);
};
const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise<number | undefined> => {
if (!hasRealtimeReadModelChanges(changes)) {
return undefined;
}
return publishReadModelChanges(changes);
};
const basePublishEvents = options.hooks?.publishEvents;
const basePublishCommandEvents = options.hooks?.publishCommandEvents;
const hooks: TurnDaemonHooks = {
...options.hooks,
publishEvents: async (result) => {
try {
const changes = options.takeCommittedReadModelChanges?.() ?? createEmptyRealtimeReadModelChanges();
if (result.processedTurns > 0) {
changes.worldChanged = true;
}
const revision = await publishCommittedChanges(changes);
await publishRealtimeEvent({
type: 'turnCompleted',
at: new Date().toISOString(),
lastTurnTime: result.lastTurnTime,
changes,
revision,
});
} catch {
// 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다.
}
await basePublishEvents?.(result);
},
publishCommandEvents: async (result) => {
try {
const changes = options.takeCommittedReadModelChanges?.();
if (changes && result.type === 'shiftSchedule' && result.ok) {
changes.lobbyChanged = true;
}
if (changes && hasRealtimeReadModelChanges(changes)) {
const revision = await publishCommittedChanges(changes);
if (revision !== undefined) {
await publishRealtimeEvent({
type: 'readModelChanged',
at: new Date().toISOString(),
changes,
revision,
});
}
}
} catch {
// 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다.
}
await basePublishCommandEvents?.(result);
},
};
return { redisConnector, hooks };
};
const createStartedAdminActionConsumer = async (options: {
runtimeOptions: TurnDaemonRuntimeOptions;
turnDaemonLease: DatabaseTurnDaemonLease | null;
commandConnector: GamePostgresConnector | null;
redisConnector: RedisConnector | null;
controlQueue: TurnDaemonControlQueue;
}) => {
const profileName = options.runtimeOptions.profileName;
if (!profileName) {
return null;
}
const consumer = await createGatewayAdminActionConsumer({
databaseUrl: options.runtimeOptions.databaseUrl,
gatewayDatabaseUrl: options.runtimeOptions.gatewayDatabaseUrl,
profileName,
pollIntervalMs: options.runtimeOptions.adminActionIntervalMs,
handler: async (action) => {
const reason = action.reason ?? `admin:${action.action ?? 'action'}`;
if (options.turnDaemonLease?.isLost()) {
return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' };
}
if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') {
return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' };
}
if (action.action === 'ACCELERATE' || action.action === 'DELAY') {
if (!options.commandConnector) {
return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' };
}
return applyRuntimeClockShift({
action,
profileName,
db: options.commandConnector.prisma,
redis: options.redisConnector?.client,
});
}
switch (action.action) {
case 'RESUME':
options.controlQueue.enqueue({ type: 'resume', reason });
return { status: 'APPLIED', detail: 'resume queued' };
case 'PAUSE':
options.controlQueue.enqueue({ type: 'pause', reason });
return { status: 'APPLIED', detail: 'pause queued' };
case 'STOP':
case 'SHUTDOWN':
options.controlQueue.enqueue({ type: 'shutdown', reason });
return { status: 'APPLIED', detail: 'shutdown queued' };
default:
return { status: 'IGNORED', detail: 'not implemented' };
}
},
});
consumer.start();
return consumer;
};
const createTurnDaemonRuntimeWithLease = async (
options: TurnDaemonRuntimeOptions,
databaseFlushEnabled: boolean,
turnDaemonLease: DatabaseTurnDaemonLease | null
): Promise<TurnDaemonRuntime> => {
if (options.exclusiveFastForward && options.profileName) {
throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.');
}
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
const { state, snapshot } = await loadTurnWorldFromDatabase({
databaseUrl: options.databaseUrl,
mapOptions: options.mapOptions,
});
const clock = options.clock ?? new SystemClock();
const { tickMinutes, resolvedState } = resolveRuntimeState(state, options, clock);
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
const hasEventAction = (name: string): boolean => hasMonthlyEventAction(snapshot.events, name);
const eventRequiresReservedTurns = requiresReservedTurnStore(snapshot.events);
const reservedTurnStoreHandle =
options.generalTurnHandler && !eventRequiresReservedTurns
? null
@@ -263,7 +658,7 @@ const createTurnDaemonRuntimeWithLease = async (
})
: await loadTurnCommandProfile());
let worldRef: InMemoryTurnWorld | null = null;
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
let redisConnector: RedisConnector | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(
@@ -276,263 +671,40 @@ const createTurnDaemonRuntimeWithLease = async (
scenarioConfig: snapshot.scenarioConfig,
nationTraits: nationTraitMap,
});
const eventActions = new Map<string, MonthlyEventActionHandler>();
eventActions.set(
'RandomizeCityTradeRate',
createRandomizeCityTradeRateHandler({
getWorld: () => worldRef,
})
);
eventActions.set(
'RaiseDisaster',
createRaiseDisasterHandler({
getWorld: () => worldRef,
generalActionModules: monthlyActionModules.general,
})
);
eventActions.set(
'UpdateCitySupply',
createUpdateCitySupplyHandler({
getWorld: () => worldRef,
map: snapshot.map,
})
);
eventActions.set(
'ProcessSemiAnnual',
createProcessSemiAnnualHandler({
getWorld: () => worldRef,
nationTraits: nationTraitMap,
})
);
eventActions.set(
'ProcessWarIncome',
createProcessWarIncomeHandler({
getWorld: () => worldRef,
nationTraits: nationTraitMap,
})
);
eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler());
if (reservedTurnStoreHandle) {
eventActions.set(
'CreateManyNPC',
createCreateManyNpcHandler({
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
env: monthlyCommandEnv,
})
);
for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) {
eventActions.set(
actionName,
createRegisterNpcHandler({
actionName,
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
env: monthlyCommandEnv,
worldConfig: snapshot.worldConfig,
scenarioFiction: snapshot.scenarioMeta?.fiction,
})
);
}
eventActions.set(
'RaiseNPCNation',
createRaiseNpcNationHandler({
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
env: monthlyCommandEnv,
map: snapshot.map,
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
})
);
eventActions.set(
'RaiseInvader',
createRaiseInvaderHandler({
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
env: monthlyCommandEnv,
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
})
);
eventActions.set(
'AutoDeleteInvader',
createAutoDeleteInvaderHandler({
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
})
);
eventActions.set(
'ProvideNPCTroopLeader',
createProvideNpcTroopLeaderHandler({
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
env: monthlyCommandEnv,
})
);
eventActions.set(
'UpdateNationLevel',
createUpdateNationLevelHandler({
getWorld: () => worldRef,
reservedTurns: reservedTurnStoreHandle.store,
itemModules: monthlyActionModules.itemModules,
loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl),
})
);
}
eventActions.set(
'InvaderEnding',
createInvaderEndingHandler({
getWorld: () => worldRef,
})
);
eventActions.set(
'ChangeCity',
createChangeCityHandler({
getWorld: () => worldRef,
})
);
eventActions.set(
'OpenNationBetting',
createOpenNationBettingHandler({
getWorld: () => worldRef,
})
);
eventActions.set(
'FinishNationBetting',
createFinishNationBettingHandler({
getWorld: () => worldRef,
})
);
for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) {
eventActions.set(
actionName,
createScoutBlockHandler({
actionName,
getWorld: () => worldRef,
})
);
}
eventActions.set(
'AssignGeneralSpeciality',
createAssignGeneralSpecialityHandler({
getWorld: () => worldRef,
})
);
eventActions.set(
'AddGlobalBetray',
createAddGlobalBetrayHandler({
getWorld: () => worldRef,
})
);
eventActions.set(
'LostUniqueItem',
createLostUniqueItemHandler({
getWorld: () => worldRef,
itemModules: monthlyActionModules.itemModules,
})
);
eventActions.set(
'MergeInheritPointRank',
createMergeInheritPointRankHandler({
getWorld: () => worldRef,
})
);
eventActions.set('ProcessIncome', createProcessIncomeActionHandler(incomeHandler));
eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: () => worldRef }));
eventActions.set('NewYear', createNewYearHandler({ getWorld: () => worldRef }));
eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: () => worldRef }));
const eventActions = createMonthlyEventActions({
databaseUrl: options.databaseUrl,
snapshot,
getWorld: () => worldRef,
reservedTurnStoreHandle,
commandEnv: monthlyCommandEnv,
actionModules: monthlyActionModules,
nationTraits: nationTraitMap,
incomeHandler,
});
const monthlyEventHandler = createMonthlyEventHandler({
getWorld: () => worldRef,
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
actions: eventActions,
});
const unification = options.calendarHandler
? null
: createUnificationHandler({
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
loadPendingUniqueAuctions: databaseFlushEnabled
? () => loadPendingUnificationAuctionCancellations(options.databaseUrl)
: undefined,
dispatchUnitedEvents: (context) => monthlyEventHandler.dispatchTarget('united', context),
});
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
getWorld: () => worldRef,
});
const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({
getWorld: () => worldRef,
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
commandEnv: monthlyCommandEnv,
});
let monthlyNationPowerRollCount = snapshot.nations.length;
let monthlyTournamentRollConsumed = false;
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
getWorld: () => worldRef,
onNationPowerRollCount: (count) => {
monthlyNationPowerRollCount = count;
},
});
const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({
getWorld: () => worldRef,
});
const monthlyNationCountHandler = createMonthlyNationCountHandler({
getWorld: () => worldRef,
});
const monthlyWarSettingHandler = createMonthlyWarSettingHandler({
getWorld: () => worldRef,
});
const monthlyWanderHandler = createMonthlyWanderHandler({
getWorld: () => worldRef,
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
commandEnv: monthlyCommandEnv,
});
const frontStateHandler = createFrontStateHandler({
getWorld: () => worldRef,
map: snapshot.map ?? null,
});
const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
const {
calendarHandler,
neutralAuctionRegistrar,
cache: monthlyRuntimeCache,
} = await createMonthlyCalendarRuntime({
databaseUrl: options.databaseUrl,
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
getNationPowerRollCount: () => monthlyNationPowerRollCount,
getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
getNationPowerRollCount: () => monthlyNationPowerRollCount,
onTournamentRollConsumed: (consumed) => {
monthlyTournamentRollConsumed = consumed;
},
// Deterministic/manual runtimes must schedule the tournament from the
// same clock that advances the game world. Production still falls
// back to the system clock.
now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
});
const yearbookHandler = createYearbookHandler({
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
});
const calendarHandler = composeCalendarHandlers(
databaseFlushEnabled,
snapshot,
currentYear: state.currentYear,
commandEnv: monthlyCommandEnv,
incomeHandler,
monthlyEventHandler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
yearbookHandler.handler,
monthlyBoundaryPreHandler,
nationTurnMonthlyHandler,
monthlyNationStatsHandler,
monthlyDiplomacyHandler,
monthlyWarSettingHandler,
monthlyWanderHandler,
monthlyNationCountHandler,
options.calendarHandler ?? unification?.handler,
tournamentAutoStartHandler,
neutralAuctionRegistrar.handler,
frontStateHandler
);
hasEventAction,
calendarHandlerOverride: options.calendarHandler,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
clock,
});
let occupiedAuctionUniqueItemKeys: string[] = [];
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
const prefetchedNationTurns = new Set<string>();
@@ -580,14 +752,14 @@ const createTurnDaemonRuntimeWithLease = async (
}
stateManager.register('runtimeCaches', {
capture: () => ({
monthlyNationPowerRollCount,
monthlyTournamentRollConsumed,
monthlyNationPowerRollCount: monthlyRuntimeCache.nationPowerRollCount,
monthlyTournamentRollConsumed: monthlyRuntimeCache.tournamentRollConsumed,
occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys],
prefetchedNationTurns: Array.from(prefetchedNationTurns),
}),
restore: (captured) => {
monthlyNationPowerRollCount = captured.monthlyNationPowerRollCount;
monthlyTournamentRollConsumed = captured.monthlyTournamentRollConsumed;
monthlyRuntimeCache.nationPowerRollCount = captured.monthlyNationPowerRollCount;
monthlyRuntimeCache.tournamentRollConsumed = captured.monthlyTournamentRollConsumed;
occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys];
prefetchedNationTurns.clear();
for (const key of captured.prefetchedNationTurns) {
@@ -642,8 +814,6 @@ const createTurnDaemonRuntimeWithLease = async (
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
let hooks: TurnDaemonHooks | undefined;
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
let publishReadModelChanges: ((changes: RealtimeReadModelChanges) => Promise<number>) | null = null;
let takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null = null;
let close = async () => {};
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
@@ -734,84 +904,14 @@ const createTurnDaemonRuntimeWithLease = async (
};
}
const redisConfig = resolveRedisConfig(options.redisUrl);
if (redisConfig) {
redisConnector = createRedisConnector(redisConfig);
await redisConnector.connect();
const redisClient = redisConnector.client;
const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
publishRealtimeEvent = async (event: RealtimeEvent) => {
await redisClient.publish(realtimeChannel, JSON.stringify(event));
};
const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile);
const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile);
publishReadModelChanges = async (changes) => {
if (
changes.worldChanged ||
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
(changes.mapNationIds ?? changes.nationIds).length > 0
) {
await redisClient.hIncrBy(domainRevisionKey, 'world', 1);
}
return redisClient.incr(revisionKey);
};
}
if (publishRealtimeEvent) {
const basePublishEvents = hooks?.publishEvents;
const basePublishCommandEvents = hooks?.publishCommandEvents;
const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise<number | undefined> => {
if (!hasRealtimeReadModelChanges(changes)) {
return undefined;
}
return publishReadModelChanges?.(changes);
};
// Durable mutation summaries invalidate only the affected read models.
hooks = {
...hooks,
publishEvents: async (result) => {
try {
const changes = takeCommittedReadModelChanges?.() ?? createEmptyRealtimeReadModelChanges();
if (result.processedTurns > 0) {
changes.worldChanged = true;
}
const revision = await publishCommittedChanges(changes);
await publishRealtimeEvent({
type: 'turnCompleted',
at: new Date().toISOString(),
lastTurnTime: result.lastTurnTime,
changes,
revision,
});
} catch {
// 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다.
}
await basePublishEvents?.(result);
},
publishCommandEvents: async (result) => {
try {
const changes = takeCommittedReadModelChanges?.();
if (changes && result.type === 'shiftSchedule' && result.ok) {
changes.lobbyChanged = true;
}
if (changes && hasRealtimeReadModelChanges(changes)) {
const revision = await publishCommittedChanges(changes);
if (revision !== undefined) {
await publishRealtimeEvent({
type: 'readModelChanged',
at: new Date().toISOString(),
changes,
revision,
});
}
}
} catch {
// 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다.
}
await basePublishCommandEvents?.(result);
},
};
}
const realtimeRuntime = await createRealtimeRuntime({
redisUrl: options.redisUrl,
profileName: options.profileName ?? options.profile,
hooks,
takeCommittedReadModelChanges,
});
redisConnector = realtimeRuntime.redisConnector;
hooks = realtimeRuntime.hooks;
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
@@ -880,50 +980,13 @@ const createTurnDaemonRuntimeWithLease = async (
{ profile: options.profile, defaultBudget }
);
if (options.profileName) {
adminActionConsumer = await createGatewayAdminActionConsumer({
databaseUrl: options.databaseUrl,
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName,
pollIntervalMs: options.adminActionIntervalMs,
handler: async (action) => {
const reason = action.reason ?? `admin:${action.action ?? 'action'}`;
if (turnDaemonLease?.isLost()) {
return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' };
}
if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') {
// 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다.
return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' };
}
if (action.action === 'ACCELERATE' || action.action === 'DELAY') {
if (!commandConnector) {
return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' };
}
return applyRuntimeClockShift({
action,
profileName: options.profileName!,
db: commandConnector.prisma,
redis: redisConnector?.client,
});
}
switch (action.action) {
case 'RESUME':
resolvedControlQueue.enqueue({ type: 'resume', reason });
return { status: 'APPLIED', detail: 'resume queued' };
case 'PAUSE':
resolvedControlQueue.enqueue({ type: 'pause', reason });
return { status: 'APPLIED', detail: 'pause queued' };
case 'STOP':
case 'SHUTDOWN':
resolvedControlQueue.enqueue({ type: 'shutdown', reason });
return { status: 'APPLIED', detail: 'shutdown queued' };
default:
return { status: 'IGNORED', detail: 'not implemented' };
}
},
});
adminActionConsumer.start();
}
adminActionConsumer = await createStartedAdminActionConsumer({
runtimeOptions: options,
turnDaemonLease,
commandConnector,
redisConnector,
controlQueue: resolvedControlQueue,
});
return {
lifecycle,
@@ -14,7 +14,7 @@ import { createNpcTaxHandler } from '../../src/turn/npcTaxHandler.js';
import { createFrontStateHandler } from '../../src/turn/frontStateHandler.js';
import { createNationTurnMonthlyHandler } from '../../src/turn/nationTurnMonthlyHandler.js';
export const createMockPrisma = (initialGeneralRows: any[] = []) => {
const createMockPrisma = (initialGeneralRows: any[] = []) => {
let generalRows = [...initialGeneralRows];
return {
generalTurn: {
@@ -47,7 +47,7 @@ export const createMockPrisma = (initialGeneralRows: any[] = []) => {
};
};
export 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);
export type TurnHarnessRunOptions = {
minutes?: number;
@@ -1,10 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { asRecord } from '@sammo-ts/common';
import { asRecord, ManualClock } from '@sammo-ts/common';
import {
InMemoryControlQueue,
EngineStateManager,
ManualClock,
TurnDaemonLifecycle,
type TurnDaemonCommandResult,
type TurnProcessor,
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import {
createItemInventoryFromSlots,
ITEM_KEYS,
LogCategory,
LogScope,
@@ -9,6 +8,7 @@ import {
type ItemModule,
type Nation,
} from '@sammo-ts/logic';
import { createItemInventoryFromSlots } from '@sammo-ts/logic/items/inventory.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createUpdateNationLevelHandler } from '../src/turn/monthlyNationLevelAction.js';
@@ -148,9 +148,7 @@ const buildHarness = async (
generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })],
cities: [
...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) =>
buildCity(cityCount + index + 1, 0)
),
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) => buildCity(cityCount + index + 1, 0)),
],
nations: [
...(options.neutralCityCount
@@ -12,7 +12,7 @@ import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn
import type { GeneralAiDebugState } from '../src/turn/ai/generalAi.js';
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { GeneralAI } from '../src/turn/ai/generalAi.js';
import { do징병 } from '../src/turn/ai/generalAiGeneralActions.js';
import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { round } from 'es-toolkit';
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { SystemClock } from '../src/lifecycle/clock.js';
import { SystemClock } from '@sammo-ts/common';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js';
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common';
import {
InMemoryControlQueue,
EngineStateManager,
ManualClock,
TurnDaemonLifecycle,
getNextTickTime,
type TurnProcessor,
-7
View File
@@ -46,13 +46,6 @@ const decodeImage = (input: string): Buffer => {
return buffer;
};
const SEOUL_OFFSET_MS = 9 * 60 * 60 * 1000;
export const kstDayStart = (value: Date): Date => {
const shifted = new Date(value.getTime() + SEOUL_OFFSET_MS);
return new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SEOUL_OFFSET_MS);
};
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
if (
user.picture !== 'default.jpg' &&
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
AdminUserListItem,
CreateUserInput,
@@ -24,7 +24,7 @@ const toAdminUserListItem = (user: UserRecord): AdminUserListItem => ({
});
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
export const createInMemoryUserRepository = (hasher: PasswordHasher = createPasswordHasher()): UserRepository => {
const usersByName = new Map<string, UserRecord>();
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
@@ -6,10 +6,10 @@ import type { KakaoOAuthClient, KakaoOAuthToken, KakaoUserInfo } from './kakaoCl
import type { OAuthSessionStore } from './oauthSessionStore.js';
import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js';
export const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
export const KAKAO_OTP_TTL_SECONDS = 180;
export const KAKAO_OTP_ATTEMPTS = 3;
export const KAKAO_TALK_VERIFICATION_DAYS = 10;
const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
const KAKAO_OTP_TTL_SECONDS = 180;
const KAKAO_OTP_ATTEMPTS = 3;
const KAKAO_TALK_VERIFICATION_DAYS = 10;
export type KakaoVerificationErrorCode =
| 'EMAIL_REQUIRED'
@@ -97,6 +97,3 @@ export const createPasswordHasher = (options: { legacyGlobalSalt?: string } = {}
return { ok: false, needsUpgrade: false };
},
});
// 기존 import 지점을 깨지 않되 새 계정은 Argon2id를 사용한다.
export const createSimplePasswordHasher = createPasswordHasher;
@@ -1,6 +1,6 @@
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
AdminUserListItem,
CreateUserInput,
@@ -158,7 +158,7 @@ const mapSpecialAccessGrant = (row: {
export const createPostgresUserRepository = (
prisma: GatewayPrismaClient,
hasher: PasswordHasher = createSimplePasswordHasher()
hasher: PasswordHasher = createPasswordHasher()
): UserRepository => {
return {
async findById(id: string): Promise<UserRecord | null> {
@@ -406,7 +406,9 @@ export const createPostgresUserRepository = (
if (result.count !== 1) {
return null;
}
return mapSpecialAccessGrant(await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } }));
return mapSpecialAccessGrant(
await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } })
);
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
@@ -21,7 +21,7 @@ const isWideCodePoint = (codePoint: number): boolean =>
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
export const legacyStringWidth = (value: string): number =>
const legacyStringWidth = (value: string): number =>
Array.from(value).reduce((width, character) => {
const codePoint = character.codePointAt(0) ?? 0;
return width + (isWideCodePoint(codePoint) ? 2 : 1);
+1 -2
View File
@@ -6,8 +6,7 @@ export * from './config.js';
export * from './context.js';
export * from './router.js';
export * from './server.js';
import { GatewayPrisma } from '@sammo-ts/infra';
export { GatewayPrisma };
import type { GatewayPrisma } from '@sammo-ts/infra';
export type JsonObject = GatewayPrisma.JsonObject;
export type JsonArray = GatewayPrisma.JsonArray;
export * from './orchestrator/profileRepository.js';
@@ -26,7 +26,7 @@ export interface BuildRunner {
}
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
export const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
export const resolveReleaseTurboConcurrency = (env?: Record<string, string>): number => {
const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim();
@@ -469,10 +469,10 @@ export const buildProcessDefinitions = (
const sanitizeArtifactName = (value: string): string => value.replace(/[^0-9A-Za-z._-]+/g, '_');
export const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string =>
const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string =>
path.join(workspaceRoot, '.release-dist', sanitizeArtifactName(profileName), 'game-frontend');
export const buildProfileFrontendCommands = (
const buildProfileFrontendCommands = (
workspaceRoot: string,
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
env?: Record<string, string>
@@ -2,8 +2,7 @@ import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
import type { GatewayOperationStatus, GatewaySourceMode } from './profileRepository.js';
export const GATEWAY_RELEASE_OPERATION_TYPES = ['DEPLOY', 'ROLLBACK'] as const;
export type GatewayReleaseOperationType = (typeof GATEWAY_RELEASE_OPERATION_TYPES)[number];
export type GatewayReleaseOperationType = 'DEPLOY' | 'ROLLBACK';
export interface GatewayReleaseStateRecord {
id: string;
@@ -46,7 +45,7 @@ export interface GatewayReleaseOperationCreateInput {
requestedBy: string;
}
export const GATEWAY_RELEASE_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
const GATEWAY_RELEASE_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
export type GatewayReleaseLogLevel = (typeof GATEWAY_RELEASE_LOG_LEVELS)[number];
export interface GatewayReleaseLogRecord {
@@ -14,14 +14,11 @@ export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
export const GATEWAY_OPERATION_TYPES = ['RESET', 'DEPLOY', 'START', 'STOP'] as const;
export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number];
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'START' | 'STOP';
export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const;
export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number];
export type GatewayOperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const;
export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number];
export type GatewaySourceMode = 'BRANCH' | 'COMMIT';
export interface GatewayOperationRecord {
id: string;
@@ -57,7 +54,7 @@ export interface GatewayOperationCreateInput {
scheduledAt?: string;
}
export const GATEWAY_OPERATION_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
const GATEWAY_OPERATION_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
export type GatewayOperationLogLevel = (typeof GATEWAY_OPERATION_LOG_LEVELS)[number];
export interface GatewayOperationLogRecord {
+1 -1
View File
@@ -20,7 +20,7 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName?
return gatewayProfileKoreanNames.get(profile) ?? profile;
};
export const compareGatewayProfiles = (
const compareGatewayProfiles = (
left: { profile: string; instanceKey: string },
right: { profile: string; instanceKey: string }
): number => {
@@ -18,6 +18,15 @@ type Operation = {
updatedAt: string;
};
type LogEntry = {
cursor: string;
operationId: string;
level: 'INFO' | 'OUTPUT' | 'ERROR';
phase: string;
message: string;
createdAt: string;
};
type FixtureState = {
operations: Operation[];
gatewayOperations: Array<{
@@ -39,8 +48,12 @@ type FixtureState = {
profileLogPollCount?: number;
profileLogProgress?: boolean;
profileLogsEmpty?: boolean;
profileLogBatches?: LogEntry[][];
profileLogPollGate?: (pollCount: number) => Promise<void>;
gatewayLogPollCount?: number;
gatewayLogsEmpty?: boolean;
gatewayLogBatches?: LogEntry[][];
gatewayLogPollGate?: (pollCount: number) => Promise<void>;
gatewayStateFailuresAfterRequest?: number;
gatewayStateFailuresRemaining?: number;
gatewayStateFailureCount?: number;
@@ -160,6 +173,12 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (names.includes('admin.operations.logs') && !state.profileLogProgress) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (names.includes('admin.operations.logs') && state.profileLogPollGate) {
await state.profileLogPollGate((state.profileLogPollCount ?? 0) + 1);
}
if (names.includes('admin.releases.logs') && state.gatewayLogPollGate) {
await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1);
}
const results = names.map((name) => {
if (route.request().method() === 'POST') {
state.requestBodies.push({ operation: name, body });
@@ -197,6 +216,20 @@ const installFixture = async (page: Page, state: FixtureState) => {
const operation = state.operations[0];
if (!operation) throw new Error('Profile operation fixture is missing');
state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1;
if (state.profileLogBatches) {
const entries = state.profileLogBatches[state.profileLogPollCount - 1] ?? [];
const completed = state.profileLogPollCount >= state.profileLogBatches.length;
const nextOperation = {
...operation,
status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const),
};
state.operations[0] = nextOperation;
return response({
operation: nextOperation,
entries,
nextCursor: entries.at(-1)?.cursor,
});
}
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) {
return response({ operation, entries: [] });
}
@@ -258,6 +291,20 @@ const installFixture = async (page: Page, state: FixtureState) => {
const releaseOperation = state.gatewayOperations[0];
if (!releaseOperation) throw new Error('Release operation fixture is missing');
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
if (state.gatewayLogBatches) {
const entries = state.gatewayLogBatches[state.gatewayLogPollCount - 1] ?? [];
const completed = state.gatewayLogPollCount >= state.gatewayLogBatches.length;
const nextOperation = {
...releaseOperation,
status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const),
};
state.gatewayOperations[0] = nextOperation;
return response({
operation: nextOperation,
entries,
nextCursor: entries.at(-1)?.cursor,
});
}
if (state.gatewayLogsEmpty) {
return response({ operation: releaseOperation, entries: [] });
}
@@ -408,6 +455,27 @@ const installFixture = async (page: Page, state: FixtureState) => {
});
};
const makeLogEntries = (operationId: string, prefix: string, startCursor: number, count: number): LogEntry[] =>
Array.from({ length: count }, (_, index) => {
const cursor = startCursor + index;
return {
cursor: String(cursor),
operationId,
level: 'OUTPUT',
phase: 'build',
message: `${prefix} ${cursor}`,
createdAt: '2026-08-01T01:00:00.000Z',
};
});
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
};
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
page,
}, testInfo) => {
@@ -634,6 +702,142 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
for (const viewportSize of [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'mobile', width: 390, height: 844 },
]) {
test(`follows new profile and Gateway logs only while each viewport is near the end on ${viewportSize.name}`, async ({
page,
}, testInfo) => {
await page.setViewportSize(viewportSize);
const profileOperationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const gatewayOperationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const profileSecondPoll = deferred();
const profileThirdPoll = deferred();
const gatewaySecondPoll = deferred();
const gatewayThirdPoll = deferred();
const state: FixtureState = {
operations: [
{
id: profileOperationId,
profileName: 'che:default',
type: 'DEPLOY',
status: 'RUNNING',
sourceMode: 'BRANCH',
sourceRef: 'main',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z',
},
],
gatewayOperations: [
{
id: gatewayOperationId,
type: 'DEPLOY',
status: 'RUNNING',
sourceMode: 'BRANCH',
sourceRef: 'main',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-01T02:00:00.000Z',
updatedAt: '2026-08-01T02:00:00.000Z',
},
],
runtimeRunning: true,
requestBodies: [],
profileLogProgress: true,
profileLogBatches: [
makeLogEntries(profileOperationId, 'profile history', 1, 80),
makeLogEntries(profileOperationId, 'profile while reading', 81, 1),
makeLogEntries(profileOperationId, 'profile near end', 82, 1),
],
profileLogPollGate: async (pollCount) => {
if (pollCount === 2) await profileSecondPoll.promise;
if (pollCount === 3) await profileThirdPoll.promise;
},
gatewayLogBatches: [
makeLogEntries(gatewayOperationId, 'gateway history', 1, 80),
makeLogEntries(gatewayOperationId, 'gateway while reading', 81, 1),
makeLogEntries(gatewayOperationId, 'gateway near end', 82, 1),
],
gatewayLogPollGate: async (pollCount) => {
if (pollCount === 2) await gatewaySecondPoll.promise;
if (pollCount === 3) await gatewayThirdPoll.promise;
},
};
await installFixture(page, state);
const verifyViewport = async (
url: string,
testId: 'profile-operation-log' | 'gateway-release-log',
historyText: string,
readingText: string,
nearEndText: string,
releaseSecondPoll: () => void,
releaseThirdPoll: () => void,
screenshotName: string
) => {
await page.goto(url);
const viewport = page.getByTestId(testId);
await expect(viewport).toContainText(historyText);
await expect
.poll(() =>
viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
)
.toBeLessThanOrEqual(1);
const readingPosition = await viewport.evaluate((element) => {
element.scrollTop = 200;
return element.scrollTop;
});
expect(readingPosition).toBe(200);
releaseSecondPoll();
await expect(viewport).toContainText(readingText);
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(readingPosition);
const nearEndGap = await viewport.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 20;
return element.scrollHeight - element.clientHeight - element.scrollTop;
});
expect(nearEndGap).toBeGreaterThan(0);
expect(nearEndGap).toBeLessThanOrEqual(40);
releaseThirdPoll();
await expect(viewport).toContainText(nearEndText);
await expect
.poll(() =>
viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
)
.toBeLessThanOrEqual(1);
await page.screenshot({
path: testInfo.outputPath(`${viewportSize.name}-${screenshotName}`),
fullPage: true,
});
};
await verifyViewport(
'admin/servers/che%3Adefault/version',
'profile-operation-log',
'profile history 80',
'profile while reading 81',
'profile near end 82',
profileSecondPoll.resolve,
profileThirdPoll.resolve,
'profile-log-scroll-follow.png'
);
await verifyViewport(
'admin/releases',
'gateway-release-log',
'gateway history 80',
'gateway while reading 81',
'gateway near end 82',
gatewaySecondPoll.resolve,
gatewayThirdPoll.resolve,
'gateway-log-scroll-follow.png'
);
});
}
test('loads server metadata defaults into the reset form and submits them', async ({ page }) => {
const state: FixtureState = {
operations: [],
@@ -115,6 +115,12 @@ let releaseLogLoopGeneration = 0;
let profileLogLoopGeneration = 0;
let componentMounted = false;
let gatewayReleaseTransitionActive = false;
const LOG_SCROLL_FOLLOW_THRESHOLD_PX = 40;
const isLogViewportNearEnd = (viewport?: HTMLElement): boolean => {
if (!viewport) return true;
return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= LOG_SCROLL_FOLLOW_THRESHOLD_PX;
};
const form = reactive({
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
@@ -326,10 +332,10 @@ const loadState = async (quiet = false) => {
}
};
const scrollProfileOperationLogToEnd = async () => {
const scrollProfileOperationLogToEnd = async (shouldFollow: boolean) => {
await nextTick();
const viewport = profileOperationLogViewport.value;
if (viewport) viewport.scrollTop = viewport.scrollHeight;
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
};
const pollProfileOperationLogs = async (operationId: string, generation: number) => {
@@ -349,11 +355,12 @@ const pollProfileOperationLogs = async (operationId: string, generation: number)
profileOperationLogConnection.value = 'connected';
const entries = result.entries as GatewayReleaseLog[];
if (entries.length) {
const shouldFollow = isLogViewportNearEnd(profileOperationLogViewport.value);
const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor));
profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
profileOperationLogs.value = profileOperationLogs.value.slice(-1_000);
profileOperationLogCursor.value = result.nextCursor;
await scrollProfileOperationLogToEnd();
await scrollProfileOperationLogToEnd(shouldFollow);
}
const operation = result.operation as Operation;
profileOperationLogStatus.value = operation.status;
@@ -381,10 +388,10 @@ const selectProfileOperation = (operationId: string) => {
selectedProfileOperationId.value = operationId;
};
const scrollReleaseLogToEnd = async () => {
const scrollReleaseLogToEnd = async (shouldFollow: boolean) => {
await nextTick();
const viewport = gatewayReleaseLogViewport.value;
if (viewport) viewport.scrollTop = viewport.scrollHeight;
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
};
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
@@ -404,11 +411,12 @@ const pollGatewayReleaseLogs = async (operationId: string, generation: number) =
gatewayReleaseLogConnection.value = 'connected';
const entries = result.entries as GatewayReleaseLog[];
if (entries.length) {
const shouldFollow = isLogViewportNearEnd(gatewayReleaseLogViewport.value);
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
gatewayReleaseLogCursor.value = result.nextCursor;
await scrollReleaseLogToEnd();
await scrollReleaseLogToEnd(shouldFollow);
}
const operation = result.operation as GatewayReleaseOperation;
gatewayReleaseLogStatus.value = operation.status;
@@ -25,7 +25,7 @@ const HEARTBEAT_INTERVAL_MS = 60_000;
const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
export const buildGatewayReleaseCommands = (
const buildGatewayReleaseCommands = (
workspaceRoot: string,
needsInstall: boolean,
config: ReleaseControllerConfig
+1 -1
View File
@@ -17,7 +17,7 @@ import { buildGatewayMigrationCommand } from './releaseController.js';
const CONTROLLER_PROCESS_NAME = 'sammo:release-controller';
export const buildReleaseControllerCommands = (
const buildReleaseControllerCommands = (
workspaceRoot: string,
needsInstall: boolean,
config: ReleaseControllerConfig