Merge branch 'main' into compare/scenario2601-200-parity-20260815
This commit is contained in:
@@ -7,14 +7,6 @@ import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock
|
|||||||
|
|
||||||
interface RedisSortedSetClient {
|
interface RedisSortedSetClient {
|
||||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
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 => {
|
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);
|
await redis.zAdd(keys.timerKey, payload);
|
||||||
return payload.length;
|
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));
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import type { BattleSimRequestPayload } from './types.js';
|
import type { BattleSimRequestPayload } from './types.js';
|
||||||
|
|
||||||
export const zBattleSimGeneral = z.object({
|
const zBattleSimGeneral = z.object({
|
||||||
no: z.number().int().positive(),
|
no: z.number().int().positive(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
nation: z.number().int().positive(),
|
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(),
|
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(),
|
city: z.number().int().positive(),
|
||||||
nation: z.number().int().min(0),
|
nation: z.number().int().min(0),
|
||||||
supply: z.number().int().min(0),
|
supply: z.number().int().min(0),
|
||||||
@@ -70,7 +70,7 @@ export const zBattleSimCity = z.object({
|
|||||||
conflict: z.string(),
|
conflict: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zBattleSimNation = z.object({
|
const zBattleSimNation = z.object({
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
tech: z.number().min(0),
|
tech: z.number().min(0),
|
||||||
level: z.number().int().min(0),
|
level: z.number().int().min(0),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,7 @@ export * from './daemon/transport.js';
|
|||||||
export * from './daemon/databaseTransport.js';
|
export * from './daemon/databaseTransport.js';
|
||||||
export * from './daemon/idempotentTransport.js';
|
export * from './daemon/idempotentTransport.js';
|
||||||
export * from './daemon/inMemoryTransport.js';
|
export * from './daemon/inMemoryTransport.js';
|
||||||
export * from './daemon/redisTransport.js';
|
|
||||||
export * from './auth/flushStore.js';
|
export * from './auth/flushStore.js';
|
||||||
export * from './auth/tokenVerifier.js';
|
|
||||||
export * from './battleSim/types.js';
|
export * from './battleSim/types.js';
|
||||||
export * from './battleSim/transport.js';
|
export * from './battleSim/transport.js';
|
||||||
export * from './battleSim/redisTransport.js';
|
export * from './battleSim/redisTransport.js';
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import {
|
|||||||
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||||
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
|
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.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 {
|
import {
|
||||||
ConflictingTurnDaemonCommandError,
|
ConflictingTurnDaemonCommandError,
|
||||||
RejectedNpcPossessionCommandError,
|
RejectedNpcPossessionCommandError,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
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';
|
import { procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
export const lobbyRouter = router({
|
export const lobbyRouter = router({
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ export const resolveOfficerCity = (meta: Record<string, unknown>): number => {
|
|||||||
return readMetaNumber(meta, 'officer_city', 0);
|
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;
|
const value = meta.permission;
|
||||||
if (value === 'ambassador' || value === 'auditor') {
|
if (value === 'ambassador' || value === 'auditor') {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -70,14 +70,8 @@ export const resolveInheritConstants = (worldState: WorldStateRow): InheritConst
|
|||||||
configConst.inheritBornTurntimePoint,
|
configConst.inheritBornTurntimePoint,
|
||||||
DEFAULT_INHERIT_CONST.inheritBornTurntimePoint
|
DEFAULT_INHERIT_CONST.inheritBornTurntimePoint
|
||||||
),
|
),
|
||||||
inheritBornCityPoint: asNumber(
|
inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, DEFAULT_INHERIT_CONST.inheritBornCityPoint),
|
||||||
configConst.inheritBornCityPoint,
|
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
|
||||||
DEFAULT_INHERIT_CONST.inheritBornCityPoint
|
|
||||||
),
|
|
||||||
inheritBornStatPoint: asNumber(
|
|
||||||
configConst.inheritBornStatPoint,
|
|
||||||
DEFAULT_INHERIT_CONST.inheritBornStatPoint
|
|
||||||
),
|
|
||||||
inheritItemUniqueMinPoint: asNumber(
|
inheritItemUniqueMinPoint: asNumber(
|
||||||
configConst.inheritItemUniqueMinPoint,
|
configConst.inheritItemUniqueMinPoint,
|
||||||
DEFAULT_INHERIT_CONST.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 (
|
export const appendInheritanceLog = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
userId: string,
|
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];
|
const value = meta[key];
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -189,7 +171,7 @@ export const readUserMetaValue = (meta: Record<string, unknown>, key: string): n
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const computeDexPoint = (meta: Record<string, unknown>): number => {
|
const computeDexPoint = (meta: Record<string, unknown>): number => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const [key, value] of Object.entries(meta)) {
|
for (const [key, value] of Object.entries(meta)) {
|
||||||
if (!key.startsWith('dex')) {
|
if (!key.startsWith('dex')) {
|
||||||
@@ -251,7 +233,10 @@ export const computeInheritanceItems = async (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sumInheritanceItems = (items: Record<InheritPointKey, number>): number => {
|
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>> => {
|
export const readUserStateMeta = async (db: DatabaseClient, userId: string): Promise<Record<string, unknown>> => {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -19,8 +19,6 @@ export type TournamentPrismaClient = {
|
|||||||
$transaction: (actions: Promise<unknown>[]) => Promise<unknown[]>;
|
$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 isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10;
|
||||||
export const isPreBattleStage = (stage: number): boolean => stage >= 1 && stage <= 6;
|
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();
|
return new Date(resolveScheduledBaseMs(state) + Math.max(1000, bettingTermMs)).toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveStatValue = (
|
const resolveStatValue = (
|
||||||
type: TournamentType,
|
type: TournamentType,
|
||||||
entry: { leadership: number; strength: number; intel: number }
|
entry: { leadership: number; strength: number; intel: number }
|
||||||
): number => {
|
): number => {
|
||||||
@@ -70,13 +68,34 @@ export const resolveStatValue = (
|
|||||||
export const resolveGroupPair = (stage: number, phase: number): [number, number] | null => {
|
export const resolveGroupPair = (stage: number, phase: number): [number, number] | null => {
|
||||||
if (stage === 2) {
|
if (stage === 2) {
|
||||||
const pairMap: Array<[number, number]> = [
|
const pairMap: Array<[number, number]> = [
|
||||||
[0, 1], [2, 3], [4, 5], [6, 7],
|
[0, 1],
|
||||||
[0, 2], [1, 3], [4, 6], [5, 7],
|
[2, 3],
|
||||||
[0, 3], [1, 6], [2, 5], [4, 7],
|
[4, 5],
|
||||||
[0, 4], [1, 5], [2, 6], [3, 7],
|
[6, 7],
|
||||||
[0, 5], [1, 4], [2, 7], [3, 6],
|
[0, 2],
|
||||||
[0, 6], [1, 7], [2, 4], [3, 5],
|
[1, 3],
|
||||||
[0, 7], [1, 2], [3, 4], [5, 6],
|
[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];
|
const basePair = pairMap[phase % 28];
|
||||||
if (!basePair) {
|
if (!basePair) {
|
||||||
@@ -87,9 +106,12 @@ export const resolveGroupPair = (stage: number, phase: number): [number, number]
|
|||||||
|
|
||||||
if (stage === 4) {
|
if (stage === 4) {
|
||||||
const pairMap: Array<[number, number]> = [
|
const pairMap: Array<[number, number]> = [
|
||||||
[0, 1], [2, 3],
|
[0, 1],
|
||||||
[0, 2], [1, 3],
|
[2, 3],
|
||||||
[0, 3], [1, 2],
|
[0, 2],
|
||||||
|
[1, 3],
|
||||||
|
[0, 3],
|
||||||
|
[1, 2],
|
||||||
];
|
];
|
||||||
return pairMap[phase % 6] ?? null;
|
return pairMap[phase % 6] ?? null;
|
||||||
}
|
}
|
||||||
@@ -133,10 +155,8 @@ export const assignGroupSlots = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectWeighted = <T>(
|
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
|
||||||
rng: ReturnType<typeof createTournamentRng>,
|
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
|
||||||
pool: Array<{ item: T; weight: number }>
|
|
||||||
): T => rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
|
|
||||||
|
|
||||||
export const fillParticipants = async (options: {
|
export const fillParticipants = async (options: {
|
||||||
prisma: TournamentPrismaClient;
|
prisma: TournamentPrismaClient;
|
||||||
@@ -203,7 +223,10 @@ export const fillParticipants = async (options: {
|
|||||||
|
|
||||||
while (result.length < limit && applicantPool.length > 0) {
|
while (result.length < limit && applicantPool.length > 0) {
|
||||||
const picked = selectWeighted(applicantRng, applicantPool);
|
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);
|
takenIds.add(picked.id);
|
||||||
result.push(picked);
|
result.push(picked);
|
||||||
}
|
}
|
||||||
@@ -262,7 +285,10 @@ export const fillParticipants = async (options: {
|
|||||||
|
|
||||||
while (result.length < limit && npcPool.length > 0) {
|
while (result.length < limit && npcPool.length > 0) {
|
||||||
const picked = selectWeighted(npcRng, npcPool);
|
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);
|
takenIds.add(picked.id);
|
||||||
result.push(picked);
|
result.push(picked);
|
||||||
}
|
}
|
||||||
@@ -344,28 +370,28 @@ export const applyGroupMatch = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
participants: participants.map((entry) => {
|
participants: participants.map((entry) => {
|
||||||
if (entry.id !== attacker.id && entry.id !== defender.id) {
|
if (entry.id !== attacker.id && entry.id !== defender.id) {
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
const next = {
|
const next = {
|
||||||
...entry,
|
...entry,
|
||||||
win: entry.win ?? 0,
|
win: entry.win ?? 0,
|
||||||
draw: entry.draw ?? 0,
|
draw: entry.draw ?? 0,
|
||||||
lose: entry.lose ?? 0,
|
lose: entry.lose ?? 0,
|
||||||
gl: entry.gl ?? 0,
|
gl: entry.gl ?? 0,
|
||||||
};
|
};
|
||||||
if (result.draw) {
|
if (result.draw) {
|
||||||
next.draw += 1;
|
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;
|
return next;
|
||||||
}
|
|
||||||
if (result.winnerId === entry.id) {
|
|
||||||
next.win += 1;
|
|
||||||
next.gl += glDelta;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
next.lose += 1;
|
|
||||||
next.gl -= glDelta;
|
|
||||||
return next;
|
|
||||||
}),
|
}),
|
||||||
outcome,
|
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) {
|
for (const key of keys) {
|
||||||
const value = source[key];
|
const value = source[key];
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -598,11 +624,7 @@ export const seedNpcBets = async (options: {
|
|||||||
|
|
||||||
const matches = await store.getMatches();
|
const matches = await store.getMatches();
|
||||||
const candidateIds = Array.from(
|
const candidateIds = Array.from(
|
||||||
new Set(
|
new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]))
|
||||||
matches
|
|
||||||
.filter((match) => match.stage === 7)
|
|
||||||
.flatMap((match) => [match.attackerId, match.defenderId])
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
if (candidateIds.length === 0) {
|
if (candidateIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -95,9 +95,6 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle
|
|||||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||||
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
|
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
|
||||||
export const accessProcedure: typeof procedure = t.procedure
|
|
||||||
.use(generalAccessEndpointMiddleware)
|
|
||||||
.use(inputEventMiddleware);
|
|
||||||
export const accessAuthedProcedure: typeof procedure = t.procedure
|
export const accessAuthedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(generalAccessEndpointMiddleware)
|
.use(generalAccessEndpointMiddleware)
|
||||||
@@ -119,10 +116,6 @@ export const sessionActivityProcedure = t.procedure;
|
|||||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
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()를
|
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
||||||
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
||||||
export const accessInputProcedure: typeof procedure.input = (input) =>
|
export const accessInputProcedure: typeof procedure.input = (input) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js';
|
import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js';
|
||||||
import { isRecord } from '@sammo-ts/common';
|
import { isRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
export const DEFAULT_TURN_ACTION = '휴식';
|
const DEFAULT_TURN_ACTION = '휴식';
|
||||||
export const MAX_GENERAL_TURNS = 30;
|
export const MAX_GENERAL_TURNS = 30;
|
||||||
export const MAX_NATION_TURNS = 12;
|
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({
|
const rows = await db.generalTurn.findMany({
|
||||||
where: { generalId },
|
where: { generalId },
|
||||||
orderBy: [{ turnIdx: 'asc' }],
|
orderBy: [{ turnIdx: 'asc' }],
|
||||||
@@ -165,11 +165,6 @@ export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): P
|
|||||||
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
|
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> => {
|
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
|
||||||
const [turns, revisionRow, general] = await Promise.all([
|
const [turns, revisionRow, general] = await Promise.all([
|
||||||
loadGeneralTurns(db, generalId),
|
loadGeneralTurns(db, generalId),
|
||||||
@@ -187,7 +182,7 @@ export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: numb
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadNationTurns = async (
|
const loadNationTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
officerLevel: number
|
officerLevel: number
|
||||||
@@ -199,15 +194,6 @@ export const loadNationTurns = async (
|
|||||||
return buildTurnListFromRows(rows, MAX_NATION_TURNS);
|
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 (
|
export const getNationTurnSnapshot = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
|
|||||||
@@ -5,41 +5,19 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import {
|
import { buildSelectPoolSeed, claimWeightedSelectionCandidates } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||||
buildSelectPoolSeed,
|
|
||||||
claimWeightedSelectionCandidates,
|
|
||||||
} from '../src/services/selectPool.js';
|
|
||||||
|
|
||||||
interface PoolResource {
|
interface PoolResource {
|
||||||
data: Array<
|
data: Array<[string, number, number, number, string, [number, number, number, number, number], 0 | 1, string]>;
|
||||||
[
|
|
||||||
string,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
string,
|
|
||||||
[number, number, number, number, number],
|
|
||||||
0 | 1,
|
|
||||||
string,
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
|
const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
|
||||||
const filePath = path.resolve(
|
const filePath = path.resolve(import.meta.dirname, '../../../resources/general-pool/SPoolUnderU30.json');
|
||||||
import.meta.dirname,
|
|
||||||
'../../../resources/general-pool/SPoolUnderU30.json'
|
|
||||||
);
|
|
||||||
const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource;
|
const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource;
|
||||||
return resource.data.map((row, index) => [
|
return resource.data.map((row, index) => [{ id: index + 1 }, row[5].reduce((sum, value) => sum + value, 0)]);
|
||||||
{ id: index + 1 },
|
|
||||||
row[5].reduce((sum, value) => sum + value, 0),
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawVector = async (
|
const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => {
|
||||||
hiddenSeed: string
|
|
||||||
): Promise<{ selected: number[]; draws: number[] }> => {
|
|
||||||
const weighted = await loadWeightedRows();
|
const weighted = await loadWeightedRows();
|
||||||
const now = new Date('2026-07-30T03:34:56.000Z');
|
const now = new Date('2026-07-30T03:34:56.000Z');
|
||||||
const draws: number[] = [];
|
const draws: number[] = [];
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { runTurnDaemonCli } from './turn/cli.js';
|
import { runTurnDaemonCli } from './turn/cli.js';
|
||||||
|
|
||||||
export * from './lifecycle/types.js';
|
export * from './lifecycle/types.js';
|
||||||
export * from './lifecycle/clock.js';
|
|
||||||
export * from './lifecycle/databaseCommandQueue.js';
|
export * from './lifecycle/databaseCommandQueue.js';
|
||||||
export * from './lifecycle/databaseTurnDaemonLease.js';
|
export * from './lifecycle/databaseTurnDaemonLease.js';
|
||||||
export * from './lifecycle/inMemoryControlQueue.js';
|
export * from './lifecycle/inMemoryControlQueue.js';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { ManualClock, StepClock, SystemClock } from '@sammo-ts/common';
|
|
||||||
@@ -25,12 +25,12 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
|
|||||||
|
|
||||||
const resolveMapRoot = (options?: MapLoaderOptions): string => options?.mapRoot ?? DEFAULT_MAP_ROOT;
|
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_';
|
const prefix = options?.filePrefix ?? 'map_';
|
||||||
return path.resolve(resolveMapRoot(options), `${prefix}${mapName}.json`);
|
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);
|
const raw = await readJsonFile(mapPath);
|
||||||
return MapDefinitionSchema.parse(raw);
|
return MapDefinitionSchema.parse(raw);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,16 +29,16 @@ const resolveScenarioRoot = (options?: ScenarioLoaderOptions): string => options
|
|||||||
export const resolveScenarioDefaultsPath = (options?: ScenarioLoaderOptions): string =>
|
export const resolveScenarioDefaultsPath = (options?: ScenarioLoaderOptions): string =>
|
||||||
path.resolve(resolveScenarioRoot(options), options?.defaultsFileName ?? 'default.json');
|
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`);
|
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);
|
const raw = await readJsonFile(defaultsPath);
|
||||||
return parseScenarioDefaults(raw);
|
return parseScenarioDefaults(raw);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadScenarioDefinition = async (
|
const loadScenarioDefinition = async (
|
||||||
scenarioPath: string,
|
scenarioPath: string,
|
||||||
defaults: ScenarioDefaults
|
defaults: ScenarioDefaults
|
||||||
): Promise<ScenarioDefinition> => {
|
): Promise<ScenarioDefinition> => {
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
|
|||||||
|
|
||||||
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string => options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
|
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_';
|
const prefix = options?.filePrefix ?? 'unitset_';
|
||||||
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
|
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);
|
const raw = await readJsonFile(unitSetPath);
|
||||||
return parseUnitSetDefinition(raw);
|
return parseUnitSetDefinition(raw);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { City, Nation } from '@sammo-ts/logic';
|
import type { City } from '@sammo-ts/logic';
|
||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
export { asRecord, isRecord };
|
export { asRecord };
|
||||||
|
|
||||||
export const readNumber = (value: unknown, fallback = 0): number => {
|
export const readNumber = (value: unknown, fallback = 0): number => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -16,21 +16,6 @@ export const readNumber = (value: unknown, fallback = 0): number => {
|
|||||||
return fallback;
|
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 =>
|
export const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback = 0): number =>
|
||||||
readNumber(meta[key], fallback);
|
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}.`);
|
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 => {
|
export const valueFit = (value: number, min?: number | null, max?: number | null): number => {
|
||||||
let next = value;
|
let next = value;
|
||||||
if (min !== null && min !== undefined && next < min) {
|
if (min !== null && min !== undefined && next < min) {
|
||||||
@@ -108,10 +87,3 @@ export const calcCityDevRatio = (city: City): number => {
|
|||||||
}
|
}
|
||||||
return total / max;
|
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,
|
withCanonicalArgumentAliases,
|
||||||
} from '../aiUtils.js';
|
} from '../aiUtils.js';
|
||||||
import { searchAllDistanceByNationList } from '@sammo-ts/logic/world/distance.js';
|
import { searchAllDistanceByNationList } from '@sammo-ts/logic/world/distance.js';
|
||||||
import { generalActionHandlers } from '../generalAiGeneralActions.js';
|
import { generalActionHandlers } from './general/index.js';
|
||||||
import { nationActionHandlers } from '../generalAiNationActions.js';
|
import { nationActionHandlers } from './nation/index.js';
|
||||||
import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js';
|
import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js';
|
||||||
import { buildSeedBase } from './seed.js';
|
import { buildSeedBase } from './seed.js';
|
||||||
import { WorldStateView } from './worldStateView.js';
|
import { WorldStateView } from './worldStateView.js';
|
||||||
|
|||||||
@@ -7,31 +7,6 @@ import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } fr
|
|||||||
import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
|
import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
|
||||||
import { do국가선택, do중립, do거병, do건국, do해산, do선양, do방랑군이동 } from './politicsActions.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<
|
export const generalActionHandlers: Record<
|
||||||
string,
|
string,
|
||||||
(ai: GeneralAI) => ReturnType<GeneralAI['buildGeneralCandidate']> | null
|
(ai: GeneralAI) => ReturnType<GeneralAI['buildGeneralCandidate']> | null
|
||||||
|
|||||||
@@ -12,29 +12,6 @@ import { do유저장긴급포상, do유저장포상, doNPC긴급포상, doNPC포
|
|||||||
import { do불가침제의, do선전포고 } from './diplomacy.js';
|
import { do불가침제의, do선전포고 } from './diplomacy.js';
|
||||||
import { do천도 } from './capital.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<
|
export const nationActionHandlers: Record<
|
||||||
string,
|
string,
|
||||||
(ai: GeneralAI) => ReturnType<GeneralAI['buildNationCandidate']> | null
|
(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;
|
const DEX_LIMIT = 1_275_975;
|
||||||
|
|
||||||
export const STORED_INHERITANCE_KEYS = [
|
const STORED_INHERITANCE_KEYS = [
|
||||||
'lived_month',
|
'lived_month',
|
||||||
'max_domestic_critical',
|
'max_domestic_critical',
|
||||||
'active_action',
|
'active_action',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createEmptyRealtimeReadModelChanges,
|
createEmptyRealtimeReadModelChanges,
|
||||||
GameClock,
|
GameClock,
|
||||||
hasRealtimeReadModelChanges,
|
hasRealtimeReadModelChanges,
|
||||||
|
SystemClock,
|
||||||
type GameClockMode,
|
type GameClockMode,
|
||||||
type RealtimeEvent,
|
type RealtimeEvent,
|
||||||
type RealtimeReadModelChanges,
|
type RealtimeReadModelChanges,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||||
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
|
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { SystemClock } from '../lifecycle/clock.js';
|
|
||||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||||
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
|
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
|
||||||
import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.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);
|
return resolveRedisConfigFromEnv(env);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTurnDaemonRuntimeWithLease = async (
|
type LoadedTurnWorld = Awaited<ReturnType<typeof loadTurnWorldFromDatabase>>;
|
||||||
options: TurnDaemonRuntimeOptions,
|
type MonthlyActionModuleBundle = Awaited<ReturnType<typeof loadActionModuleBundle>>;
|
||||||
databaseFlushEnabled: boolean,
|
type NationTraitModuleMap = Map<string, Awaited<ReturnType<typeof loadNationTraitModules>>[number]>;
|
||||||
turnDaemonLease: DatabaseTurnDaemonLease | null
|
type ReservedTurnStoreHandle = Awaited<ReturnType<typeof createReservedTurnStore>>;
|
||||||
): Promise<TurnDaemonRuntime> => {
|
type RedisConnector = ReturnType<typeof createRedisConnector>;
|
||||||
if (options.exclusiveFastForward && options.profileName) {
|
type GamePostgresConnector = ReturnType<typeof createGamePostgresConnector>;
|
||||||
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 resolveRuntimeState = (
|
||||||
|
state: LoadedTurnWorld['state'],
|
||||||
|
options: Pick<TurnDaemonRuntimeOptions, 'tickMinutes' | 'gameClockMode'>,
|
||||||
|
clock: Clock
|
||||||
|
) => {
|
||||||
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
|
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
|
||||||
const nextTickSeconds = tickMinutes * 60;
|
const nextTickSeconds = tickMinutes * 60;
|
||||||
const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds;
|
const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds;
|
||||||
@@ -231,22 +228,420 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}),
|
...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}),
|
||||||
...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}),
|
...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}),
|
||||||
};
|
};
|
||||||
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
|
return { tickMinutes, resolvedState };
|
||||||
const hasEventAction = (name: string): boolean =>
|
};
|
||||||
snapshot.events.some(
|
|
||||||
(event) =>
|
const hasMonthlyEventAction = (events: LoadedTurnWorld['snapshot']['events'], name: string): boolean =>
|
||||||
Array.isArray(event.action) &&
|
events.some(
|
||||||
event.action.some((action) => Array.isArray(action) && action[0] === name)
|
(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 =
|
for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) {
|
||||||
hasEventAction('UpdateNationLevel') ||
|
eventActions.set(
|
||||||
hasEventAction('CreateManyNPC') ||
|
actionName,
|
||||||
hasEventAction('RegNPC') ||
|
createRegisterNpcHandler({
|
||||||
hasEventAction('RegNeutralNPC') ||
|
actionName,
|
||||||
hasEventAction('RaiseNPCNation') ||
|
getWorld: options.getWorld,
|
||||||
hasEventAction('RaiseInvader') ||
|
reservedTurns,
|
||||||
hasEventAction('AutoDeleteInvader') ||
|
env: options.commandEnv,
|
||||||
hasEventAction('ProvideNPCTroopLeader');
|
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 =
|
const reservedTurnStoreHandle =
|
||||||
options.generalTurnHandler && !eventRequiresReservedTurns
|
options.generalTurnHandler && !eventRequiresReservedTurns
|
||||||
? null
|
? null
|
||||||
@@ -263,7 +658,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
})
|
})
|
||||||
: await loadTurnCommandProfile());
|
: await loadTurnCommandProfile());
|
||||||
let worldRef: InMemoryTurnWorld | null = null;
|
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 nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
||||||
const monthlyActionModules = await loadActionModuleBundle(
|
const monthlyActionModules = await loadActionModuleBundle(
|
||||||
@@ -276,263 +671,40 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
scenarioConfig: snapshot.scenarioConfig,
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
nationTraits: nationTraitMap,
|
nationTraits: nationTraitMap,
|
||||||
});
|
});
|
||||||
const eventActions = new Map<string, MonthlyEventActionHandler>();
|
const eventActions = createMonthlyEventActions({
|
||||||
eventActions.set(
|
databaseUrl: options.databaseUrl,
|
||||||
'RandomizeCityTradeRate',
|
snapshot,
|
||||||
createRandomizeCityTradeRateHandler({
|
getWorld: () => worldRef,
|
||||||
getWorld: () => worldRef,
|
reservedTurnStoreHandle,
|
||||||
})
|
commandEnv: monthlyCommandEnv,
|
||||||
);
|
actionModules: monthlyActionModules,
|
||||||
eventActions.set(
|
nationTraits: nationTraitMap,
|
||||||
'RaiseDisaster',
|
incomeHandler,
|
||||||
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 monthlyEventHandler = createMonthlyEventHandler({
|
const monthlyEventHandler = createMonthlyEventHandler({
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
|
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
|
||||||
actions: eventActions,
|
actions: eventActions,
|
||||||
});
|
});
|
||||||
const unification = options.calendarHandler
|
const {
|
||||||
? null
|
calendarHandler,
|
||||||
: createUnificationHandler({
|
neutralAuctionRegistrar,
|
||||||
profileName: options.profileName ?? options.profile,
|
cache: monthlyRuntimeCache,
|
||||||
getWorld: () => worldRef,
|
} = await createMonthlyCalendarRuntime({
|
||||||
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({
|
|
||||||
databaseUrl: options.databaseUrl,
|
databaseUrl: options.databaseUrl,
|
||||||
profileName: options.profileName ?? options.profile,
|
profileName: options.profileName ?? options.profile,
|
||||||
getWorld: () => worldRef,
|
databaseFlushEnabled,
|
||||||
getRedisClient: () => redisConnector?.client,
|
snapshot,
|
||||||
getWorldConfig: () => snapshot.worldConfig ?? null,
|
currentYear: state.currentYear,
|
||||||
getNationPowerRollCount: () => monthlyNationPowerRollCount,
|
commandEnv: monthlyCommandEnv,
|
||||||
getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
|
incomeHandler,
|
||||||
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(
|
|
||||||
monthlyEventHandler,
|
monthlyEventHandler,
|
||||||
hasEventAction('ProcessIncome') ? null : incomeHandler,
|
hasEventAction,
|
||||||
yearbookHandler.handler,
|
calendarHandlerOverride: options.calendarHandler,
|
||||||
monthlyBoundaryPreHandler,
|
getWorld: () => worldRef,
|
||||||
nationTurnMonthlyHandler,
|
getRedisClient: () => redisConnector?.client,
|
||||||
monthlyNationStatsHandler,
|
clock,
|
||||||
monthlyDiplomacyHandler,
|
});
|
||||||
monthlyWarSettingHandler,
|
|
||||||
monthlyWanderHandler,
|
|
||||||
monthlyNationCountHandler,
|
|
||||||
options.calendarHandler ?? unification?.handler,
|
|
||||||
tournamentAutoStartHandler,
|
|
||||||
neutralAuctionRegistrar.handler,
|
|
||||||
frontStateHandler
|
|
||||||
);
|
|
||||||
let occupiedAuctionUniqueItemKeys: string[] = [];
|
let occupiedAuctionUniqueItemKeys: string[] = [];
|
||||||
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
|
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
|
||||||
const prefetchedNationTurns = new Set<string>();
|
const prefetchedNationTurns = new Set<string>();
|
||||||
@@ -580,14 +752,14 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
}
|
}
|
||||||
stateManager.register('runtimeCaches', {
|
stateManager.register('runtimeCaches', {
|
||||||
capture: () => ({
|
capture: () => ({
|
||||||
monthlyNationPowerRollCount,
|
monthlyNationPowerRollCount: monthlyRuntimeCache.nationPowerRollCount,
|
||||||
monthlyTournamentRollConsumed,
|
monthlyTournamentRollConsumed: monthlyRuntimeCache.tournamentRollConsumed,
|
||||||
occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys],
|
occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys],
|
||||||
prefetchedNationTurns: Array.from(prefetchedNationTurns),
|
prefetchedNationTurns: Array.from(prefetchedNationTurns),
|
||||||
}),
|
}),
|
||||||
restore: (captured) => {
|
restore: (captured) => {
|
||||||
monthlyNationPowerRollCount = captured.monthlyNationPowerRollCount;
|
monthlyRuntimeCache.nationPowerRollCount = captured.monthlyNationPowerRollCount;
|
||||||
monthlyTournamentRollConsumed = captured.monthlyTournamentRollConsumed;
|
monthlyRuntimeCache.tournamentRollConsumed = captured.monthlyTournamentRollConsumed;
|
||||||
occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys];
|
occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys];
|
||||||
prefetchedNationTurns.clear();
|
prefetchedNationTurns.clear();
|
||||||
for (const key of captured.prefetchedNationTurns) {
|
for (const key of captured.prefetchedNationTurns) {
|
||||||
@@ -642,8 +814,6 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
|
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
|
||||||
|
|
||||||
let hooks: TurnDaemonHooks | undefined;
|
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 takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null = null;
|
||||||
let close = async () => {};
|
let close = async () => {};
|
||||||
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
||||||
@@ -734,84 +904,14 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const redisConfig = resolveRedisConfig(options.redisUrl);
|
const realtimeRuntime = await createRealtimeRuntime({
|
||||||
if (redisConfig) {
|
redisUrl: options.redisUrl,
|
||||||
redisConnector = createRedisConnector(redisConfig);
|
profileName: options.profileName ?? options.profile,
|
||||||
await redisConnector.connect();
|
hooks,
|
||||||
const redisClient = redisConnector.client;
|
takeCommittedReadModelChanges,
|
||||||
const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
|
});
|
||||||
publishRealtimeEvent = async (event: RealtimeEvent) => {
|
redisConnector = realtimeRuntime.redisConnector;
|
||||||
await redisClient.publish(realtimeChannel, JSON.stringify(event));
|
hooks = realtimeRuntime.hooks;
|
||||||
};
|
|
||||||
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 commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
||||||
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
||||||
@@ -880,50 +980,13 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
{ profile: options.profile, defaultBudget }
|
{ profile: options.profile, defaultBudget }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options.profileName) {
|
adminActionConsumer = await createStartedAdminActionConsumer({
|
||||||
adminActionConsumer = await createGatewayAdminActionConsumer({
|
runtimeOptions: options,
|
||||||
databaseUrl: options.databaseUrl,
|
turnDaemonLease,
|
||||||
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
|
commandConnector,
|
||||||
profileName: options.profileName,
|
redisConnector,
|
||||||
pollIntervalMs: options.adminActionIntervalMs,
|
controlQueue: resolvedControlQueue,
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { createNpcTaxHandler } from '../../src/turn/npcTaxHandler.js';
|
|||||||
import { createFrontStateHandler } from '../../src/turn/frontStateHandler.js';
|
import { createFrontStateHandler } from '../../src/turn/frontStateHandler.js';
|
||||||
import { createNationTurnMonthlyHandler } from '../../src/turn/nationTurnMonthlyHandler.js';
|
import { createNationTurnMonthlyHandler } from '../../src/turn/nationTurnMonthlyHandler.js';
|
||||||
|
|
||||||
export const createMockPrisma = (initialGeneralRows: any[] = []) => {
|
const createMockPrisma = (initialGeneralRows: any[] = []) => {
|
||||||
let generalRows = [...initialGeneralRows];
|
let generalRows = [...initialGeneralRows];
|
||||||
return {
|
return {
|
||||||
generalTurn: {
|
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 = {
|
export type TurnHarnessRunOptions = {
|
||||||
minutes?: number;
|
minutes?: number;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord, ManualClock } from '@sammo-ts/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InMemoryControlQueue,
|
InMemoryControlQueue,
|
||||||
EngineStateManager,
|
EngineStateManager,
|
||||||
ManualClock,
|
|
||||||
TurnDaemonLifecycle,
|
TurnDaemonLifecycle,
|
||||||
type TurnDaemonCommandResult,
|
type TurnDaemonCommandResult,
|
||||||
type TurnProcessor,
|
type TurnProcessor,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createItemInventoryFromSlots,
|
|
||||||
ITEM_KEYS,
|
ITEM_KEYS,
|
||||||
LogCategory,
|
LogCategory,
|
||||||
LogScope,
|
LogScope,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
type ItemModule,
|
type ItemModule,
|
||||||
type Nation,
|
type Nation,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
import { createItemInventoryFromSlots } from '@sammo-ts/logic/items/inventory.js';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { createUpdateNationLevelHandler } from '../src/turn/monthlyNationLevelAction.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 } })],
|
generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })],
|
||||||
cities: [
|
cities: [
|
||||||
...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
|
...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)),
|
||||||
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) =>
|
...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) => buildCity(cityCount + index + 1, 0)),
|
||||||
buildCity(cityCount + index + 1, 0)
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
nations: [
|
nations: [
|
||||||
...(options.neutralCityCount
|
...(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 { GeneralAiDebugState } from '../src/turn/ai/generalAi.js';
|
||||||
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { GeneralAI } from '../src/turn/ai/generalAi.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 { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js';
|
||||||
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
|
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
|
||||||
import { round } from 'es-toolkit';
|
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 { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
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 { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||||
import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js';
|
||||||
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ManualClock } from '@sammo-ts/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InMemoryControlQueue,
|
InMemoryControlQueue,
|
||||||
EngineStateManager,
|
EngineStateManager,
|
||||||
ManualClock,
|
|
||||||
TurnDaemonLifecycle,
|
TurnDaemonLifecycle,
|
||||||
getNextTickTime,
|
getNextTickTime,
|
||||||
type TurnProcessor,
|
type TurnProcessor,
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ const decodeImage = (input: string): Buffer => {
|
|||||||
return 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 => {
|
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
||||||
if (
|
if (
|
||||||
user.picture !== 'default.jpg' &&
|
user.picture !== 'default.jpg' &&
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||||
import type {
|
import type {
|
||||||
AdminUserListItem,
|
AdminUserListItem,
|
||||||
CreateUserInput,
|
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 usersByName = new Map<string, UserRecord>();
|
||||||
const usersByOauthId = new Map<string, UserRecord>();
|
const usersByOauthId = new Map<string, UserRecord>();
|
||||||
const usersByEmail = 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 { OAuthSessionStore } from './oauthSessionStore.js';
|
||||||
import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js';
|
import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js';
|
||||||
|
|
||||||
export const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
|
const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
|
||||||
export const KAKAO_OTP_TTL_SECONDS = 180;
|
const KAKAO_OTP_TTL_SECONDS = 180;
|
||||||
export const KAKAO_OTP_ATTEMPTS = 3;
|
const KAKAO_OTP_ATTEMPTS = 3;
|
||||||
export const KAKAO_TALK_VERIFICATION_DAYS = 10;
|
const KAKAO_TALK_VERIFICATION_DAYS = 10;
|
||||||
|
|
||||||
export type KakaoVerificationErrorCode =
|
export type KakaoVerificationErrorCode =
|
||||||
| 'EMAIL_REQUIRED'
|
| 'EMAIL_REQUIRED'
|
||||||
|
|||||||
@@ -97,6 +97,3 @@ export const createPasswordHasher = (options: { legacyGlobalSalt?: string } = {}
|
|||||||
return { ok: false, needsUpgrade: false };
|
return { ok: false, needsUpgrade: false };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기존 import 지점을 깨지 않되 새 계정은 Argon2id를 사용한다.
|
|
||||||
export const createSimplePasswordHasher = createPasswordHasher;
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||||
import type {
|
import type {
|
||||||
AdminUserListItem,
|
AdminUserListItem,
|
||||||
CreateUserInput,
|
CreateUserInput,
|
||||||
@@ -158,7 +158,7 @@ const mapSpecialAccessGrant = (row: {
|
|||||||
|
|
||||||
export const createPostgresUserRepository = (
|
export const createPostgresUserRepository = (
|
||||||
prisma: GatewayPrismaClient,
|
prisma: GatewayPrismaClient,
|
||||||
hasher: PasswordHasher = createSimplePasswordHasher()
|
hasher: PasswordHasher = createPasswordHasher()
|
||||||
): UserRepository => {
|
): UserRepository => {
|
||||||
return {
|
return {
|
||||||
async findById(id: string): Promise<UserRecord | null> {
|
async findById(id: string): Promise<UserRecord | null> {
|
||||||
@@ -406,7 +406,9 @@ export const createPostgresUserRepository = (
|
|||||||
if (result.count !== 1) {
|
if (result.count !== 1) {
|
||||||
return null;
|
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> {
|
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
|
||||||
await prisma.appUser.update({
|
await prisma.appUser.update({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const isWideCodePoint = (codePoint: number): boolean =>
|
|||||||
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
|
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
|
||||||
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
||||||
|
|
||||||
export const legacyStringWidth = (value: string): number =>
|
const legacyStringWidth = (value: string): number =>
|
||||||
Array.from(value).reduce((width, character) => {
|
Array.from(value).reduce((width, character) => {
|
||||||
const codePoint = character.codePointAt(0) ?? 0;
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
return width + (isWideCodePoint(codePoint) ? 2 : 1);
|
return width + (isWideCodePoint(codePoint) ? 2 : 1);
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ export * from './config.js';
|
|||||||
export * from './context.js';
|
export * from './context.js';
|
||||||
export * from './router.js';
|
export * from './router.js';
|
||||||
export * from './server.js';
|
export * from './server.js';
|
||||||
import { GatewayPrisma } from '@sammo-ts/infra';
|
import type { GatewayPrisma } from '@sammo-ts/infra';
|
||||||
export { GatewayPrisma };
|
|
||||||
export type JsonObject = GatewayPrisma.JsonObject;
|
export type JsonObject = GatewayPrisma.JsonObject;
|
||||||
export type JsonArray = GatewayPrisma.JsonArray;
|
export type JsonArray = GatewayPrisma.JsonArray;
|
||||||
export * from './orchestrator/profileRepository.js';
|
export * from './orchestrator/profileRepository.js';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface BuildRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
|
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 => {
|
export const resolveReleaseTurboConcurrency = (env?: Record<string, string>): number => {
|
||||||
const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim();
|
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, '_');
|
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');
|
path.join(workspaceRoot, '.release-dist', sanitizeArtifactName(profileName), 'game-frontend');
|
||||||
|
|
||||||
export const buildProfileFrontendCommands = (
|
const buildProfileFrontendCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
|||||||
|
|
||||||
import type { GatewayOperationStatus, GatewaySourceMode } from './profileRepository.js';
|
import type { GatewayOperationStatus, GatewaySourceMode } from './profileRepository.js';
|
||||||
|
|
||||||
export const GATEWAY_RELEASE_OPERATION_TYPES = ['DEPLOY', 'ROLLBACK'] as const;
|
export type GatewayReleaseOperationType = 'DEPLOY' | 'ROLLBACK';
|
||||||
export type GatewayReleaseOperationType = (typeof GATEWAY_RELEASE_OPERATION_TYPES)[number];
|
|
||||||
|
|
||||||
export interface GatewayReleaseStateRecord {
|
export interface GatewayReleaseStateRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +45,7 @@ export interface GatewayReleaseOperationCreateInput {
|
|||||||
requestedBy: string;
|
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 type GatewayReleaseLogLevel = (typeof GATEWAY_RELEASE_LOG_LEVELS)[number];
|
||||||
|
|
||||||
export interface GatewayReleaseLogRecord {
|
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 const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
||||||
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
||||||
|
|
||||||
export const GATEWAY_OPERATION_TYPES = ['RESET', 'DEPLOY', 'START', 'STOP'] as const;
|
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'START' | 'STOP';
|
||||||
export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number];
|
|
||||||
|
|
||||||
export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const;
|
export type GatewayOperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
||||||
export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number];
|
|
||||||
|
|
||||||
export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const;
|
export type GatewaySourceMode = 'BRANCH' | 'COMMIT';
|
||||||
export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number];
|
|
||||||
|
|
||||||
export interface GatewayOperationRecord {
|
export interface GatewayOperationRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -57,7 +54,7 @@ export interface GatewayOperationCreateInput {
|
|||||||
scheduledAt?: string;
|
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 type GatewayOperationLogLevel = (typeof GATEWAY_OPERATION_LOG_LEVELS)[number];
|
||||||
|
|
||||||
export interface GatewayOperationLogRecord {
|
export interface GatewayOperationLogRecord {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName?
|
|||||||
return gatewayProfileKoreanNames.get(profile) ?? profile;
|
return gatewayProfileKoreanNames.get(profile) ?? profile;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const compareGatewayProfiles = (
|
const compareGatewayProfiles = (
|
||||||
left: { profile: string; instanceKey: string },
|
left: { profile: string; instanceKey: string },
|
||||||
right: { profile: string; instanceKey: string }
|
right: { profile: string; instanceKey: string }
|
||||||
): number => {
|
): number => {
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ type Operation = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
cursor: string;
|
||||||
|
operationId: string;
|
||||||
|
level: 'INFO' | 'OUTPUT' | 'ERROR';
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type FixtureState = {
|
type FixtureState = {
|
||||||
operations: Operation[];
|
operations: Operation[];
|
||||||
gatewayOperations: Array<{
|
gatewayOperations: Array<{
|
||||||
@@ -39,8 +48,12 @@ type FixtureState = {
|
|||||||
profileLogPollCount?: number;
|
profileLogPollCount?: number;
|
||||||
profileLogProgress?: boolean;
|
profileLogProgress?: boolean;
|
||||||
profileLogsEmpty?: boolean;
|
profileLogsEmpty?: boolean;
|
||||||
|
profileLogBatches?: LogEntry[][];
|
||||||
|
profileLogPollGate?: (pollCount: number) => Promise<void>;
|
||||||
gatewayLogPollCount?: number;
|
gatewayLogPollCount?: number;
|
||||||
gatewayLogsEmpty?: boolean;
|
gatewayLogsEmpty?: boolean;
|
||||||
|
gatewayLogBatches?: LogEntry[][];
|
||||||
|
gatewayLogPollGate?: (pollCount: number) => Promise<void>;
|
||||||
gatewayStateFailuresAfterRequest?: number;
|
gatewayStateFailuresAfterRequest?: number;
|
||||||
gatewayStateFailuresRemaining?: number;
|
gatewayStateFailuresRemaining?: number;
|
||||||
gatewayStateFailureCount?: number;
|
gatewayStateFailureCount?: number;
|
||||||
@@ -160,6 +173,12 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
if (names.includes('admin.operations.logs') && !state.profileLogProgress) {
|
if (names.includes('admin.operations.logs') && !state.profileLogProgress) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
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) => {
|
const results = names.map((name) => {
|
||||||
if (route.request().method() === 'POST') {
|
if (route.request().method() === 'POST') {
|
||||||
state.requestBodies.push({ operation: name, body });
|
state.requestBodies.push({ operation: name, body });
|
||||||
@@ -197,6 +216,20 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
const operation = state.operations[0];
|
const operation = state.operations[0];
|
||||||
if (!operation) throw new Error('Profile operation fixture is missing');
|
if (!operation) throw new Error('Profile operation fixture is missing');
|
||||||
state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1;
|
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)) {
|
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) {
|
||||||
return response({ operation, entries: [] });
|
return response({ operation, entries: [] });
|
||||||
}
|
}
|
||||||
@@ -258,6 +291,20 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
const releaseOperation = state.gatewayOperations[0];
|
const releaseOperation = state.gatewayOperations[0];
|
||||||
if (!releaseOperation) throw new Error('Release operation fixture is missing');
|
if (!releaseOperation) throw new Error('Release operation fixture is missing');
|
||||||
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
|
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) {
|
if (state.gatewayLogsEmpty) {
|
||||||
return response({ operation: releaseOperation, entries: [] });
|
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 ({
|
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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);
|
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 }) => {
|
test('loads server metadata defaults into the reset form and submits them', async ({ page }) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
operations: [],
|
operations: [],
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ let releaseLogLoopGeneration = 0;
|
|||||||
let profileLogLoopGeneration = 0;
|
let profileLogLoopGeneration = 0;
|
||||||
let componentMounted = false;
|
let componentMounted = false;
|
||||||
let gatewayReleaseTransitionActive = 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({
|
const form = reactive({
|
||||||
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
|
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();
|
await nextTick();
|
||||||
const viewport = profileOperationLogViewport.value;
|
const viewport = profileOperationLogViewport.value;
|
||||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollProfileOperationLogs = async (operationId: string, generation: number) => {
|
const pollProfileOperationLogs = async (operationId: string, generation: number) => {
|
||||||
@@ -349,11 +355,12 @@ const pollProfileOperationLogs = async (operationId: string, generation: number)
|
|||||||
profileOperationLogConnection.value = 'connected';
|
profileOperationLogConnection.value = 'connected';
|
||||||
const entries = result.entries as GatewayReleaseLog[];
|
const entries = result.entries as GatewayReleaseLog[];
|
||||||
if (entries.length) {
|
if (entries.length) {
|
||||||
|
const shouldFollow = isLogViewportNearEnd(profileOperationLogViewport.value);
|
||||||
const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor));
|
const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor));
|
||||||
profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
||||||
profileOperationLogs.value = profileOperationLogs.value.slice(-1_000);
|
profileOperationLogs.value = profileOperationLogs.value.slice(-1_000);
|
||||||
profileOperationLogCursor.value = result.nextCursor;
|
profileOperationLogCursor.value = result.nextCursor;
|
||||||
await scrollProfileOperationLogToEnd();
|
await scrollProfileOperationLogToEnd(shouldFollow);
|
||||||
}
|
}
|
||||||
const operation = result.operation as Operation;
|
const operation = result.operation as Operation;
|
||||||
profileOperationLogStatus.value = operation.status;
|
profileOperationLogStatus.value = operation.status;
|
||||||
@@ -381,10 +388,10 @@ const selectProfileOperation = (operationId: string) => {
|
|||||||
selectedProfileOperationId.value = operationId;
|
selectedProfileOperationId.value = operationId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollReleaseLogToEnd = async () => {
|
const scrollReleaseLogToEnd = async (shouldFollow: boolean) => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const viewport = gatewayReleaseLogViewport.value;
|
const viewport = gatewayReleaseLogViewport.value;
|
||||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
|
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
|
||||||
@@ -404,11 +411,12 @@ const pollGatewayReleaseLogs = async (operationId: string, generation: number) =
|
|||||||
gatewayReleaseLogConnection.value = 'connected';
|
gatewayReleaseLogConnection.value = 'connected';
|
||||||
const entries = result.entries as GatewayReleaseLog[];
|
const entries = result.entries as GatewayReleaseLog[];
|
||||||
if (entries.length) {
|
if (entries.length) {
|
||||||
|
const shouldFollow = isLogViewportNearEnd(gatewayReleaseLogViewport.value);
|
||||||
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
|
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
|
||||||
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
||||||
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
|
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
|
||||||
gatewayReleaseLogCursor.value = result.nextCursor;
|
gatewayReleaseLogCursor.value = result.nextCursor;
|
||||||
await scrollReleaseLogToEnd();
|
await scrollReleaseLogToEnd(shouldFollow);
|
||||||
}
|
}
|
||||||
const operation = result.operation as GatewayReleaseOperation;
|
const operation = result.operation as GatewayReleaseOperation;
|
||||||
gatewayReleaseLogStatus.value = operation.status;
|
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 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;
|
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
|
||||||
|
|
||||||
export const buildGatewayReleaseCommands = (
|
const buildGatewayReleaseCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
needsInstall: boolean,
|
needsInstall: boolean,
|
||||||
config: ReleaseControllerConfig
|
config: ReleaseControllerConfig
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { buildGatewayMigrationCommand } from './releaseController.js';
|
|||||||
|
|
||||||
const CONTROLLER_PROCESS_NAME = 'sammo:release-controller';
|
const CONTROLLER_PROCESS_NAME = 'sammo:release-controller';
|
||||||
|
|
||||||
export const buildReleaseControllerCommands = (
|
const buildReleaseControllerCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
needsInstall: boolean,
|
needsInstall: boolean,
|
||||||
config: ReleaseControllerConfig
|
config: ReleaseControllerConfig
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ Frontend가 tRPC router shape를 참조할 때는 `import type`만 사용하고
|
|||||||
package를 `devDependencies`에 둡니다. 브라우저에서 실제 실행하는 공유 값만
|
package를 `devDependencies`에 둡니다. 브라우저에서 실제 실행하는 공유 값만
|
||||||
`common` 또는 `logic`의 browser-safe export에서 가져옵니다.
|
`common` 또는 `logic`의 browser-safe export에서 가져옵니다.
|
||||||
|
|
||||||
|
## 공개 export와 façade 기준
|
||||||
|
|
||||||
|
공개 export는 현재 package 밖의 호출자가 사용하는 값, 동적 module loader가
|
||||||
|
정해진 이름으로 읽는 값, 또는 `package.json`의 명시적 subpath 계약에 필요한
|
||||||
|
값만 둡니다. 같은 저장소 내부에서만 쓰는 helper와 schema 조각은 선언 파일에
|
||||||
|
남기지 않고 module 내부 값으로 둡니다. 정적 분석이 동적 import의
|
||||||
|
`ActionDefinition`, `commandSpec`, `actionContextBuilder`를 미사용으로 표시해도
|
||||||
|
이 이름들은 turn command protocol이므로 제거하지 않습니다.
|
||||||
|
|
||||||
|
소유 package의 안정적인 subpath를 그대로 전달하기만 하는 app-local 파일은
|
||||||
|
별도 정책이나 변환을 추가하지 않는 한 만들지 않습니다. 호출자는 실제 소유
|
||||||
|
subpath를 직접 import하고, 인증·validation·오류 변환 또는 런타임 주입처럼
|
||||||
|
경계 자체가 의미를 가질 때만 façade를 유지합니다. 루트 barrel의 `export *`와
|
||||||
|
동일 symbol을 다시 나열하는 중복 export도 두지 않습니다.
|
||||||
|
|
||||||
|
함수 분리는 line 수만을 기준으로 하지 않습니다. 계산·effect 적용·dirty-state
|
||||||
|
판정, 월간 handler 구성, 실시간 발행처럼 입력과 결과가 독립적인 단계는 별도
|
||||||
|
함수로 분리합니다. 반대로 daemon의 resource 획득과 역순 종료처럼 한 수명주기
|
||||||
|
계약을 이루는 orchestration은 호출 순서를 한곳에서 검토할 수 있게 유지합니다.
|
||||||
|
|
||||||
## 자동 검사
|
## 자동 검사
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const FEATURE_ALIASES: Record<SanctionFeature, ReadonlySet<string>> = {
|
|||||||
messages: new Set(['*', 'message', 'messages']),
|
messages: new Set(['*', 'message', 'messages']),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFutureSanctionDate = (value: string | undefined, now = new Date()): boolean => {
|
const isFutureSanctionDate = (value: string | undefined, now = new Date()): boolean => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,7 @@ export const resolveRealtimeReadModelInvalidation = (
|
|||||||
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
|
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
|
||||||
const ownCityChanged = contains(changes.cityIds, identity.cityId);
|
const ownCityChanged = contains(changes.cityIds, identity.cityId);
|
||||||
const ownNationChanged = contains(changes.nationIds, identity.nationId);
|
const ownNationChanged = contains(changes.nationIds, identity.nationId);
|
||||||
const ownFrontStatusNationChanged = contains(
|
const ownFrontStatusNationChanged = contains(changes.frontStatusNationIds ?? changes.nationIds, identity.nationId);
|
||||||
changes.frontStatusNationIds ?? changes.nationIds,
|
|
||||||
identity.nationId
|
|
||||||
);
|
|
||||||
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
|
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
|
||||||
const frontStatusGeneralChanged =
|
const frontStatusGeneralChanged =
|
||||||
changes.frontStatusGeneralIds !== undefined
|
changes.frontStatusGeneralIds !== undefined
|
||||||
@@ -122,8 +119,7 @@ export const resolveRealtimeReadModelInvalidation = (
|
|||||||
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
|
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
|
||||||
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
|
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
|
||||||
const mapEntitiesChanged =
|
const mapEntitiesChanged =
|
||||||
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
|
(changes.mapCityIds ?? changes.cityIds).length > 0 || (changes.mapNationIds ?? changes.nationIds).length > 0;
|
||||||
(changes.mapNationIds ?? changes.nationIds).length > 0;
|
|
||||||
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
|
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -167,39 +163,6 @@ export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges
|
|||||||
lobbyChanged: false,
|
lobbyChanged: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergeIds = (left: readonly number[], right: readonly number[]): number[] =>
|
|
||||||
[...new Set([...left, ...right])].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
export const mergeRealtimeReadModelChanges = (
|
|
||||||
left: RealtimeReadModelChanges,
|
|
||||||
right: RealtimeReadModelChanges
|
|
||||||
): RealtimeReadModelChanges => ({
|
|
||||||
generalIds: mergeIds(left.generalIds, right.generalIds),
|
|
||||||
cityIds: mergeIds(left.cityIds, right.cityIds),
|
|
||||||
nationIds: mergeIds(left.nationIds, right.nationIds),
|
|
||||||
mapGeneralIds: mergeIds(left.mapGeneralIds ?? left.generalIds, right.mapGeneralIds ?? right.generalIds),
|
|
||||||
mapCityIds: mergeIds(left.mapCityIds ?? left.cityIds, right.mapCityIds ?? right.cityIds),
|
|
||||||
mapNationIds: mergeIds(left.mapNationIds ?? left.nationIds, right.mapNationIds ?? right.nationIds),
|
|
||||||
frontStatusGeneralIds: mergeIds(
|
|
||||||
left.frontStatusGeneralIds ?? (left.contactsChanged ? left.generalIds : []),
|
|
||||||
right.frontStatusGeneralIds ?? (right.contactsChanged ? right.generalIds : [])
|
|
||||||
),
|
|
||||||
frontStatusNationIds: mergeIds(
|
|
||||||
left.frontStatusNationIds ?? left.nationIds,
|
|
||||||
right.frontStatusNationIds ?? right.nationIds
|
|
||||||
),
|
|
||||||
frontStatusActorIds: mergeIds(left.frontStatusActorIds ?? [], right.frontStatusActorIds ?? []),
|
|
||||||
lobbyGeneralIds: mergeIds(left.lobbyGeneralIds ?? left.generalIds, right.lobbyGeneralIds ?? right.generalIds),
|
|
||||||
reservedGeneralIds: mergeIds(left.reservedGeneralIds, right.reservedGeneralIds),
|
|
||||||
recordGeneralIds: mergeIds(left.recordGeneralIds, right.recordGeneralIds),
|
|
||||||
worldChanged: left.worldChanged || right.worldChanged,
|
|
||||||
globalRecordsChanged: left.globalRecordsChanged || right.globalRecordsChanged,
|
|
||||||
worldHistoryChanged: left.worldHistoryChanged || right.worldHistoryChanged,
|
|
||||||
contactsChanged: left.contactsChanged || right.contactsChanged,
|
|
||||||
frontStatusChanged: Boolean(left.frontStatusChanged) || Boolean(right.frontStatusChanged),
|
|
||||||
lobbyChanged: (left.lobbyChanged ?? left.contactsChanged) || (right.lobbyChanged ?? right.contactsChanged),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const hasRealtimeReadModelChanges = (changes: RealtimeReadModelChanges): boolean =>
|
export const hasRealtimeReadModelChanges = (changes: RealtimeReadModelChanges): boolean =>
|
||||||
changes.generalIds.length > 0 ||
|
changes.generalIds.length > 0 ||
|
||||||
changes.cityIds.length > 0 ||
|
changes.cityIds.length > 0 ||
|
||||||
@@ -251,7 +214,4 @@ export interface MessagesInvalidatedEvent {
|
|||||||
/** Events safe to expose to an authenticated browser over SSE. */
|
/** Events safe to expose to an authenticated browser over SSE. */
|
||||||
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
|
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
|
||||||
|
|
||||||
export type RealtimeEvent =
|
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent;
|
||||||
| TurnCompletedEvent
|
|
||||||
| ReadModelChangedEvent
|
|
||||||
| MessageCreatedEvent;
|
|
||||||
|
|||||||
@@ -32,6 +32,3 @@ const buildTournamentSeedKey = (baseSeed: string, context: TournamentRngContext)
|
|||||||
|
|
||||||
export const createTournamentRng = (baseSeed: string, context: TournamentRngContext): RandUtil =>
|
export const createTournamentRng = (baseSeed: string, context: TournamentRngContext): RandUtil =>
|
||||||
new RandUtil(LiteHashDRBG.build(buildTournamentSeedKey(baseSeed, context)));
|
new RandUtil(LiteHashDRBG.build(buildTournamentSeedKey(baseSeed, context)));
|
||||||
|
|
||||||
export const createTournamentSeedKey = (baseSeed: string, context: TournamentRngContext): string =>
|
|
||||||
buildTournamentSeedKey(baseSeed, context);
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function normalizeUint8Array(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|||||||
return out as Uint8Array<ArrayBuffer>;
|
return out as Uint8Array<ArrayBuffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sha512Bytes(data: BytesLike): Uint8Array<ArrayBuffer> {
|
function sha512Bytes(data: BytesLike): Uint8Array<ArrayBuffer> {
|
||||||
const input = convertBytesLikeToUint8Array(data);
|
const input = convertBytesLikeToUint8Array(data);
|
||||||
if (nodeCreateHash) {
|
if (nodeCreateHash) {
|
||||||
const digest = nodeCreateHash('sha512').update(input).digest();
|
const digest = nodeCreateHash('sha512').update(input).digest();
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
|
|
||||||
|
|
||||||
export interface ErrorLogQueryOptions {
|
|
||||||
limit?: number;
|
|
||||||
beforeId?: number;
|
|
||||||
category?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorLogCreateInput {
|
|
||||||
category: string;
|
|
||||||
message: string;
|
|
||||||
source?: string;
|
|
||||||
trace?: string;
|
|
||||||
context?: GamePrisma.InputJsonValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorLogView {
|
|
||||||
id: number;
|
|
||||||
category: string;
|
|
||||||
source: string | null;
|
|
||||||
message: string;
|
|
||||||
trace: string | null;
|
|
||||||
context: GamePrisma.JsonValue;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPaginationWhere = (
|
|
||||||
base: GamePrisma.ErrorLogWhereInput,
|
|
||||||
options: ErrorLogQueryOptions
|
|
||||||
): GamePrisma.ErrorLogWhereInput => {
|
|
||||||
if (options.beforeId) {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
id: { lt: options.beforeId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFindArgs = (
|
|
||||||
where: GamePrisma.ErrorLogWhereInput,
|
|
||||||
options: ErrorLogQueryOptions
|
|
||||||
): GamePrisma.ErrorLogFindManyArgs => ({
|
|
||||||
where: buildPaginationWhere(where, options),
|
|
||||||
orderBy: { id: 'desc' },
|
|
||||||
take: options.limit ?? 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
export class ErrorLogRepository {
|
|
||||||
constructor(private readonly prisma: GamePrismaClient) {}
|
|
||||||
|
|
||||||
async createErrorLog(input: ErrorLogCreateInput): Promise<ErrorLogView> {
|
|
||||||
return this.prisma.errorLog.create({
|
|
||||||
data: {
|
|
||||||
category: input.category,
|
|
||||||
source: input.source ?? null,
|
|
||||||
message: input.message,
|
|
||||||
trace: input.trace ?? null,
|
|
||||||
context: input.context ?? {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listErrorLogs(options: ErrorLogQueryOptions = {}): Promise<ErrorLogView[]> {
|
|
||||||
const base: GamePrisma.ErrorLogWhereInput = options.category ? { category: options.category } : {};
|
|
||||||
return this.prisma.errorLog.findMany(buildFindArgs(base, options));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,5 @@ export type { GamePrismaClient } from './gamePrisma.js';
|
|||||||
export { createGatewayPostgresConnector, GatewayPrisma } from './gatewayPrisma.js';
|
export { createGatewayPostgresConnector, GatewayPrisma } from './gatewayPrisma.js';
|
||||||
export type { GatewayPrismaClient } from './gatewayPrisma.js';
|
export type { GatewayPrismaClient } from './gatewayPrisma.js';
|
||||||
export * from './db.js';
|
export * from './db.js';
|
||||||
export * from './errorLogRepository.js';
|
|
||||||
export * from './logRepository.js';
|
|
||||||
export * from './redis.js';
|
export * from './redis.js';
|
||||||
export * from './turnEngineDb.js';
|
export * from './turnEngineDb.js';
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
|
||||||
|
|
||||||
import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
|
|
||||||
|
|
||||||
export interface LogQueryOptions {
|
|
||||||
limit?: number;
|
|
||||||
beforeId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogEntryView {
|
|
||||||
id: number;
|
|
||||||
scope: LogScope;
|
|
||||||
category: LogCategory;
|
|
||||||
subType: string | null;
|
|
||||||
text: string;
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
createdAt: Date;
|
|
||||||
generalId: number | null;
|
|
||||||
nationId: number | null;
|
|
||||||
userId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPaginationWhere = (
|
|
||||||
base: GamePrisma.LogEntryWhereInput,
|
|
||||||
options: LogQueryOptions
|
|
||||||
): GamePrisma.LogEntryWhereInput => {
|
|
||||||
if (options.beforeId) {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
id: { lt: options.beforeId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFindArgs = (
|
|
||||||
where: GamePrisma.LogEntryWhereInput,
|
|
||||||
options: LogQueryOptions
|
|
||||||
): GamePrisma.LogEntryFindManyArgs => ({
|
|
||||||
where: buildPaginationWhere(where, options),
|
|
||||||
orderBy: { id: 'desc' },
|
|
||||||
take: options.limit ?? 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
export class LogRepository {
|
|
||||||
constructor(private readonly prisma: GamePrismaClient) {}
|
|
||||||
|
|
||||||
// 전역(시스템) 로그 조회
|
|
||||||
async listSystemLogs(category: LogCategory, options: LogQueryOptions = {}): Promise<LogEntryView[]> {
|
|
||||||
return this.prisma.logEntry.findMany(
|
|
||||||
buildFindArgs(
|
|
||||||
{
|
|
||||||
scope: LogScope.SYSTEM,
|
|
||||||
category,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 국가 로그 조회
|
|
||||||
async listNationLogs(
|
|
||||||
nationId: number,
|
|
||||||
category: LogCategory,
|
|
||||||
options: LogQueryOptions = {}
|
|
||||||
): Promise<LogEntryView[]> {
|
|
||||||
return this.prisma.logEntry.findMany(
|
|
||||||
buildFindArgs(
|
|
||||||
{
|
|
||||||
scope: LogScope.NATION,
|
|
||||||
category,
|
|
||||||
nationId,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 장수 로그 조회
|
|
||||||
async listGeneralLogs(
|
|
||||||
generalId: number,
|
|
||||||
category: LogCategory,
|
|
||||||
options: LogQueryOptions = {}
|
|
||||||
): Promise<LogEntryView[]> {
|
|
||||||
return this.prisma.logEntry.findMany(
|
|
||||||
buildFindArgs(
|
|
||||||
{
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category,
|
|
||||||
generalId,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 유저 로그 조회
|
|
||||||
async listUserLogs(userId: number, options: LogQueryOptions & { subType?: string } = {}): Promise<LogEntryView[]> {
|
|
||||||
return this.prisma.logEntry.findMany(
|
|
||||||
buildFindArgs(
|
|
||||||
{
|
|
||||||
scope: LogScope.USER,
|
|
||||||
category: LogCategory.USER,
|
|
||||||
userId,
|
|
||||||
subType: options.subType,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RandomGenerator } from '@sammo-ts/common';
|
import type { RandomGenerator } from '@sammo-ts/common';
|
||||||
import { enablePatches, produceWithPatches, castDraft } from 'immer';
|
import { enablePatches, produceWithPatches, castDraft, type Draft, type Patch } from 'immer';
|
||||||
import type {
|
import type {
|
||||||
City,
|
City,
|
||||||
General,
|
General,
|
||||||
@@ -82,11 +82,6 @@ export interface LogEffect {
|
|||||||
entry: LogEntryDraft;
|
entry: LogEntryDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NextTurnOverrideEffect {
|
|
||||||
type: 'schedule:override';
|
|
||||||
nextTurnAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageAddEffect {
|
export interface MessageAddEffect {
|
||||||
type: 'message:add';
|
type: 'message:add';
|
||||||
draft: MessageDraft;
|
draft: MessageDraft;
|
||||||
@@ -100,8 +95,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
|
|||||||
| NationAddEffect
|
| NationAddEffect
|
||||||
| DiplomacyPatchEffect
|
| DiplomacyPatchEffect
|
||||||
| LogEffect
|
| LogEffect
|
||||||
| MessageAddEffect
|
| MessageAddEffect;
|
||||||
| NextTurnOverrideEffect;
|
|
||||||
|
|
||||||
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
effects: GeneralActionEffect<TriggerState>[];
|
effects: GeneralActionEffect<TriggerState>[];
|
||||||
@@ -229,10 +223,129 @@ export const createMessageEffect = (draft: MessageDraft): MessageAddEffect => ({
|
|||||||
draft,
|
draft,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({
|
const createActionLogSink = <TriggerState extends GeneralTriggerState>(
|
||||||
type: 'schedule:override',
|
context: GeneralActionResolveInputContext<TriggerState>,
|
||||||
nextTurnAt,
|
logs: LogEntryDraft[]
|
||||||
});
|
): GeneralActionResolveContext<TriggerState>['addLog'] => {
|
||||||
|
return (message, options = {}) => {
|
||||||
|
const entry: LogEntryDraft = {
|
||||||
|
scope: options.scope ?? LogScope.GENERAL,
|
||||||
|
category: options.category ?? LogCategory.ACTION,
|
||||||
|
text: message,
|
||||||
|
format: options.format ?? LogFormat.MONTH,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (entry.scope) {
|
||||||
|
case LogScope.GENERAL:
|
||||||
|
logs.push({
|
||||||
|
...entry,
|
||||||
|
generalId: entry.generalId ?? context.general.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case LogScope.NATION:
|
||||||
|
if (entry.nationId !== undefined) {
|
||||||
|
logs.push(entry);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (context.nation?.id !== undefined) {
|
||||||
|
logs.push({
|
||||||
|
...entry,
|
||||||
|
nationId: context.nation.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case LogScope.USER:
|
||||||
|
if (entry.userId) {
|
||||||
|
logs.push(entry);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case LogScope.SYSTEM:
|
||||||
|
default:
|
||||||
|
logs.push(entry);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ActionResolutionAccumulator {
|
||||||
|
createdGenerals: General[];
|
||||||
|
createdNations: Nation[];
|
||||||
|
patches: NonNullable<GeneralActionResolution['patches']>;
|
||||||
|
pendingEffects: GeneralActionEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyGeneralActionEffects = <TriggerState extends GeneralTriggerState>(options: {
|
||||||
|
effects: GeneralActionEffect<TriggerState>[];
|
||||||
|
draft: Draft<WorldState<TriggerState>>;
|
||||||
|
context: GeneralActionResolveInputContext<TriggerState>;
|
||||||
|
addLog: GeneralActionResolveContext<TriggerState>['addLog'];
|
||||||
|
accumulator: ActionResolutionAccumulator;
|
||||||
|
}): void => {
|
||||||
|
const { effects, draft, context, addLog, accumulator } = options;
|
||||||
|
for (const effect of effects) {
|
||||||
|
switch (effect.type) {
|
||||||
|
case 'log':
|
||||||
|
addLog(effect.entry.text, effect.entry);
|
||||||
|
break;
|
||||||
|
case 'general:add':
|
||||||
|
accumulator.createdGenerals.push(effect.general as General);
|
||||||
|
break;
|
||||||
|
case 'nation:add':
|
||||||
|
accumulator.createdNations.push(effect.nation as Nation);
|
||||||
|
break;
|
||||||
|
case 'diplomacy:patch':
|
||||||
|
case 'message:add':
|
||||||
|
accumulator.pendingEffects.push(effect);
|
||||||
|
break;
|
||||||
|
case 'general:patch':
|
||||||
|
if (effect.targetId !== undefined && effect.targetId !== context.general.id) {
|
||||||
|
accumulator.patches.generals.push({
|
||||||
|
id: effect.targetId,
|
||||||
|
patch: effect.patch as Partial<General>,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(draft.general, effect.patch);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'city:patch':
|
||||||
|
if (effect.targetId !== undefined && effect.targetId !== context.city?.id) {
|
||||||
|
accumulator.patches.cities.push({ id: effect.targetId, patch: effect.patch });
|
||||||
|
} else if (draft.city) {
|
||||||
|
Object.assign(draft.city, effect.patch);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'nation:patch':
|
||||||
|
if (effect.targetId !== undefined && effect.targetId !== context.nation?.id) {
|
||||||
|
accumulator.patches.nations.push({ id: effect.targetId, patch: effect.patch });
|
||||||
|
} else if (draft.nation) {
|
||||||
|
Object.assign(draft.nation, effect.patch);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDirtyState = <TriggerState extends GeneralTriggerState>(
|
||||||
|
context: GeneralActionResolveInputContext<TriggerState>,
|
||||||
|
worldPatches: readonly Patch[]
|
||||||
|
): NonNullable<GeneralActionResolution['dirty']> => {
|
||||||
|
const dirty: NonNullable<GeneralActionResolution['dirty']> = {
|
||||||
|
general: false,
|
||||||
|
city: false,
|
||||||
|
nation: false,
|
||||||
|
generalId: context.general.id,
|
||||||
|
};
|
||||||
|
if (context.city) dirty.cityId = context.city.id;
|
||||||
|
if (context.nation) dirty.nationId = context.nation.id;
|
||||||
|
|
||||||
|
for (const patch of worldPatches) {
|
||||||
|
if (patch.path[0] === 'general') dirty.general = true;
|
||||||
|
if (patch.path[0] === 'city') dirty.city = true;
|
||||||
|
if (patch.path[0] === 'nation') dirty.nation = true;
|
||||||
|
}
|
||||||
|
return dirty;
|
||||||
|
};
|
||||||
|
|
||||||
// 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다.
|
// 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다.
|
||||||
export const resolveGeneralAction = <TriggerState extends GeneralTriggerState = GeneralTriggerState, Args = unknown>(
|
export const resolveGeneralAction = <TriggerState extends GeneralTriggerState = GeneralTriggerState, Args = unknown>(
|
||||||
@@ -242,16 +355,12 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
args: Args
|
args: Args
|
||||||
): GeneralActionResolution => {
|
): GeneralActionResolution => {
|
||||||
const logs: LogEntryDraft[] = [];
|
const logs: LogEntryDraft[] = [];
|
||||||
let nextTurnAtOverride: Date | null = null;
|
const accumulator: ActionResolutionAccumulator = {
|
||||||
const createdGenerals: General[] = [];
|
createdGenerals: [],
|
||||||
const createdNations: Nation[] = [];
|
createdNations: [],
|
||||||
const patches: NonNullable<GeneralActionResolution['patches']> = {
|
patches: { generals: [], cities: [], nations: [] },
|
||||||
generals: [],
|
pendingEffects: [],
|
||||||
cities: [],
|
|
||||||
nations: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingEffects: GeneralActionEffect[] = [];
|
|
||||||
let outcome: GeneralActionOutcome<TriggerState> | undefined;
|
let outcome: GeneralActionOutcome<TriggerState> | undefined;
|
||||||
const [nextWorld, worldPatches] = produceWithPatches(
|
const [nextWorld, worldPatches] = produceWithPatches(
|
||||||
{
|
{
|
||||||
@@ -260,45 +369,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
nation: context.nation,
|
nation: context.nation,
|
||||||
} as WorldState<TriggerState>,
|
} as WorldState<TriggerState>,
|
||||||
(draft) => {
|
(draft) => {
|
||||||
const addLog = (message: string, options: Partial<Omit<LogEntryDraft, 'text'>> = {}) => {
|
const addLog = createActionLogSink(context, logs);
|
||||||
const entry: LogEntryDraft = {
|
|
||||||
scope: options.scope ?? LogScope.GENERAL,
|
|
||||||
category: options.category ?? LogCategory.ACTION,
|
|
||||||
text: message,
|
|
||||||
format: options.format ?? LogFormat.MONTH,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (entry.scope) {
|
|
||||||
case LogScope.GENERAL:
|
|
||||||
logs.push({
|
|
||||||
...entry,
|
|
||||||
generalId: entry.generalId ?? context.general.id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case LogScope.NATION:
|
|
||||||
if (entry.nationId !== undefined) {
|
|
||||||
logs.push(entry);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (context.nation?.id !== undefined) {
|
|
||||||
logs.push({
|
|
||||||
...entry,
|
|
||||||
nationId: context.nation.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case LogScope.USER:
|
|
||||||
if (entry.userId) {
|
|
||||||
logs.push(entry);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case LogScope.SYSTEM:
|
|
||||||
default:
|
|
||||||
logs.push(entry);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
outcome = resolver.resolve(
|
outcome = resolver.resolve(
|
||||||
{
|
{
|
||||||
@@ -312,85 +383,19 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
args
|
args
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const effect of outcome.effects) {
|
applyGeneralActionEffects({
|
||||||
switch (effect.type) {
|
effects: outcome.effects,
|
||||||
case 'log':
|
draft,
|
||||||
addLog(effect.entry.text, effect.entry);
|
context,
|
||||||
break;
|
addLog,
|
||||||
case 'schedule:override':
|
accumulator,
|
||||||
nextTurnAtOverride = effect.nextTurnAt;
|
});
|
||||||
break;
|
|
||||||
case 'general:add':
|
|
||||||
createdGenerals.push(effect.general as General);
|
|
||||||
break;
|
|
||||||
case 'nation:add':
|
|
||||||
createdNations.push(effect.nation as Nation);
|
|
||||||
break;
|
|
||||||
case 'diplomacy:patch':
|
|
||||||
case 'message:add':
|
|
||||||
pendingEffects.push(effect);
|
|
||||||
break;
|
|
||||||
case 'general:patch':
|
|
||||||
case 'city:patch':
|
|
||||||
case 'nation:patch':
|
|
||||||
// 타겟이 다른 경우 patches에 추가
|
|
||||||
if (
|
|
||||||
effect.type === 'general:patch' &&
|
|
||||||
effect.targetId !== undefined &&
|
|
||||||
effect.targetId !== context.general.id
|
|
||||||
) {
|
|
||||||
patches.generals.push({
|
|
||||||
id: effect.targetId,
|
|
||||||
patch: effect.patch as Partial<General>,
|
|
||||||
});
|
|
||||||
} else if (effect.type === 'general:patch') {
|
|
||||||
Object.assign(draft.general, effect.patch);
|
|
||||||
} else if (
|
|
||||||
effect.type === 'city:patch' &&
|
|
||||||
effect.targetId !== undefined &&
|
|
||||||
effect.targetId !== context.city?.id
|
|
||||||
) {
|
|
||||||
patches.cities.push({
|
|
||||||
id: effect.targetId,
|
|
||||||
patch: effect.patch,
|
|
||||||
});
|
|
||||||
} else if (effect.type === 'city:patch' && draft.city) {
|
|
||||||
Object.assign(draft.city, effect.patch);
|
|
||||||
} else if (
|
|
||||||
effect.type === 'nation:patch' &&
|
|
||||||
effect.targetId !== undefined &&
|
|
||||||
effect.targetId !== context.nation?.id
|
|
||||||
) {
|
|
||||||
patches.nations.push({
|
|
||||||
id: effect.targetId,
|
|
||||||
patch: effect.patch,
|
|
||||||
});
|
|
||||||
} else if (effect.type === 'nation:patch' && draft.nation) {
|
|
||||||
Object.assign(draft.nation, effect.patch);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextTurnAt = nextTurnAtOverride ?? getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
|
const nextTurnAt = getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
|
||||||
|
|
||||||
const dirty: NonNullable<GeneralActionResolution['dirty']> = {
|
const dirty = resolveDirtyState(context, worldPatches);
|
||||||
general: false,
|
|
||||||
city: false,
|
|
||||||
nation: false,
|
|
||||||
generalId: context.general.id,
|
|
||||||
};
|
|
||||||
if (context.city) dirty.cityId = context.city.id;
|
|
||||||
if (context.nation) dirty.nationId = context.nation.id;
|
|
||||||
|
|
||||||
// worldPatches를 분석하여 dirty 설정
|
|
||||||
for (const patch of worldPatches) {
|
|
||||||
if (patch.path[0] === 'general') dirty.general = true;
|
|
||||||
if (patch.path[0] === 'city') dirty.city = true;
|
|
||||||
if (patch.path[0] === 'nation') dirty.nation = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolution: GeneralActionResolution = {
|
const resolution: GeneralActionResolution = {
|
||||||
general: nextWorld.general as General,
|
general: nextWorld.general as General,
|
||||||
@@ -398,7 +403,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
completed: outcome?.completed !== false,
|
completed: outcome?.completed !== false,
|
||||||
nextTurnAt,
|
nextTurnAt,
|
||||||
logs,
|
logs,
|
||||||
effects: pendingEffects,
|
effects: accumulator.pendingEffects,
|
||||||
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
|
||||||
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
|
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
|
||||||
...(outcome?.reservedGeneralTurnPlans?.length
|
...(outcome?.reservedGeneralTurnPlans?.length
|
||||||
@@ -411,13 +416,17 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
|
|||||||
if (dirty.general || dirty.city || dirty.nation) {
|
if (dirty.general || dirty.city || dirty.nation) {
|
||||||
resolution.dirty = dirty;
|
resolution.dirty = dirty;
|
||||||
}
|
}
|
||||||
if (patches.generals.length > 0 || patches.cities.length > 0 || patches.nations.length > 0) {
|
if (
|
||||||
resolution.patches = patches;
|
accumulator.patches.generals.length > 0 ||
|
||||||
|
accumulator.patches.cities.length > 0 ||
|
||||||
|
accumulator.patches.nations.length > 0
|
||||||
|
) {
|
||||||
|
resolution.patches = accumulator.patches;
|
||||||
}
|
}
|
||||||
if (createdGenerals.length > 0 || createdNations.length > 0) {
|
if (accumulator.createdGenerals.length > 0 || accumulator.createdNations.length > 0) {
|
||||||
resolution.created = {
|
resolution.created = {
|
||||||
generals: createdGenerals,
|
generals: accumulator.createdGenerals,
|
||||||
...(createdNations.length > 0 ? { nations: createdNations } : {}),
|
...(accumulator.createdNations.length > 0 ? { nations: accumulator.createdNations } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,3 @@ export * from './turn/commandModule.js';
|
|||||||
export * from './turn/commandProfile.js';
|
export * from './turn/commandProfile.js';
|
||||||
export * from './turn/general/index.js';
|
export * from './turn/general/index.js';
|
||||||
export * from './turn/nation/index.js';
|
export * from './turn/nation/index.js';
|
||||||
export * from './instant/general/index.js';
|
|
||||||
export * from './instant/nation/index.js';
|
|
||||||
export * from './admin/index.js';
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export {
|
|
||||||
ActionDefinition as NonAggressionAcceptActionDefinition,
|
|
||||||
type NonAggressionAcceptArgs,
|
|
||||||
type NonAggressionAcceptContext,
|
|
||||||
} from './che_불가침수락.js';
|
|
||||||
export {
|
|
||||||
ActionDefinition as NonAggressionCancelAcceptActionDefinition,
|
|
||||||
type NonAggressionCancelAcceptArgs,
|
|
||||||
type NonAggressionCancelAcceptContext,
|
|
||||||
} from './che_불가침파기수락.js';
|
|
||||||
export {
|
|
||||||
ActionDefinition as StopWarAcceptActionDefinition,
|
|
||||||
type StopWarAcceptArgs,
|
|
||||||
type StopWarAcceptContext,
|
|
||||||
} from './che_종전수락.js';
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
reqGeneralValue,
|
reqGeneralValue,
|
||||||
reqEnvValue,
|
reqEnvValue,
|
||||||
readMetaNumberFromUnknown,
|
readMetaNumberFromUnknown,
|
||||||
alwaysFail,
|
denyWithReason,
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -318,7 +318,7 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||||
return [alwaysFail('예약 불가능 커맨드')];
|
return [denyWithReason('예약 불가능 커맨드')];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
alwaysFail,
|
denyWithReason,
|
||||||
reqCityCapacity,
|
reqCityCapacity,
|
||||||
reqCityTrader,
|
reqCityTrader,
|
||||||
reqGeneralGold,
|
reqGeneralGold,
|
||||||
@@ -131,13 +131,13 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
const currentItemCode = general.role.items[args.itemType];
|
const currentItemCode = general.role.items[args.itemType];
|
||||||
if (currentItemCode === args.itemCode) {
|
if (currentItemCode === args.itemCode) {
|
||||||
return alwaysFail('이미 가지고 있습니다.').test(ctx, view);
|
return denyWithReason('이미 가지고 있습니다.').test(ctx, view);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentItemCode) {
|
if (currentItemCode) {
|
||||||
const currentItem = readItem(this.env.itemCatalog, currentItemCode);
|
const currentItem = readItem(this.env.itemCatalog, currentItemCode);
|
||||||
if (currentItem && !currentItem.buyable) {
|
if (currentItem && !currentItem.buyable) {
|
||||||
return alwaysFail('이미 진귀한 것을 가지고 있습니다.').test(ctx, view);
|
return denyWithReason('이미 진귀한 것을 가지고 있습니다.').test(ctx, view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { kind: 'allow' };
|
return { kind: 'allow' };
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
|
||||||
import {
|
|
||||||
notBeNeutral,
|
|
||||||
notWanderingNation,
|
|
||||||
occupiedCity,
|
|
||||||
remainCityCapacity,
|
|
||||||
reqGeneralGold,
|
|
||||||
reqGeneralRice,
|
|
||||||
suppliedCity,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { clamp } from 'es-toolkit';
|
|
||||||
|
|
||||||
export interface CityDevelopmentArgs {}
|
|
||||||
|
|
||||||
export interface CityDevelopmentEnvironment {
|
|
||||||
develCost?: number;
|
|
||||||
amount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type NumberKeys<T> = { [K in keyof T]-?: T[K] extends number ? K : never }[keyof T];
|
|
||||||
|
|
||||||
export interface CityDevelopmentConfig {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
statKey: NumberKeys<City>;
|
|
||||||
maxKey: NumberKeys<City>;
|
|
||||||
label: string;
|
|
||||||
baseAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readNumber = (value: unknown): number | null =>
|
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
||||||
|
|
||||||
export class CityDevelopmentActionDefinition<
|
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
|
||||||
> implements GeneralActionDefinition<TriggerState, CityDevelopmentArgs> {
|
|
||||||
public readonly key: string;
|
|
||||||
public readonly name: string;
|
|
||||||
private readonly config: CityDevelopmentConfig;
|
|
||||||
private readonly env: CityDevelopmentEnvironment;
|
|
||||||
|
|
||||||
constructor(config: CityDevelopmentConfig, env: CityDevelopmentEnvironment) {
|
|
||||||
this.key = config.key;
|
|
||||||
this.name = config.name;
|
|
||||||
this.config = config;
|
|
||||||
this.env = env;
|
|
||||||
}
|
|
||||||
|
|
||||||
parseArgs(_raw: unknown): CityDevelopmentArgs | null {
|
|
||||||
void _raw;
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: CityDevelopmentArgs): Constraint[] {
|
|
||||||
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.develCost ?? 0;
|
|
||||||
|
|
||||||
return [
|
|
||||||
notBeNeutral(),
|
|
||||||
notWanderingNation(),
|
|
||||||
occupiedCity(),
|
|
||||||
suppliedCity(),
|
|
||||||
remainCityCapacity(this.config.statKey, this.config.label),
|
|
||||||
reqGeneralGold(getRequiredGold),
|
|
||||||
reqGeneralRice(() => 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(
|
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
|
||||||
_args: CityDevelopmentArgs
|
|
||||||
): GeneralActionOutcome<TriggerState> {
|
|
||||||
const general = context.general;
|
|
||||||
const city = context.city;
|
|
||||||
if (!city) {
|
|
||||||
context.addLog('도시 정보를 찾지 못했습니다.');
|
|
||||||
return { effects: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseAmount = this.env.amount ?? this.config.baseAmount;
|
|
||||||
const current = readNumber(city[this.config.statKey]);
|
|
||||||
const max = readNumber(city[this.config.maxKey]);
|
|
||||||
if (current === null || max === null) {
|
|
||||||
context.addLog('도시 정보를 찾지 못했습니다.');
|
|
||||||
return { effects: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextValue = clamp(current + baseAmount, 0, max);
|
|
||||||
const costGold = this.env.develCost ?? 0;
|
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
|
||||||
city[this.config.statKey] = nextValue;
|
|
||||||
general.gold = Math.max(0, general.gold - costGold);
|
|
||||||
|
|
||||||
const logMessage = `${this.config.label}이 ${nextValue - current} 증가했습니다.`;
|
|
||||||
context.addLog(logMessage);
|
|
||||||
|
|
||||||
return { effects: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -169,4 +169,4 @@ export const loadGeneralTurnCommandSpecs = async (
|
|||||||
return specs;
|
return specs;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
export { readLegacyCityTrust } from './legacyCityTrust.js';
|
||||||
|
|||||||
@@ -236,30 +236,6 @@ export const remainCityCapacity = (key: keyof City, label: string): Constraint =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remainCityCapacityByMax = (key: keyof City, maxKey: keyof City, label: string): Constraint => ({
|
|
||||||
name: 'remainCityCapacityByMax',
|
|
||||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
|
||||||
test: (ctx, view) => {
|
|
||||||
const city = readCity(view, ctx.cityId);
|
|
||||||
if (!city) {
|
|
||||||
if (ctx.cityId === undefined) {
|
|
||||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
|
||||||
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const current = city[key];
|
|
||||||
const max = city[maxKey];
|
|
||||||
if (typeof current !== 'number' || typeof max !== 'number') {
|
|
||||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
if (current < max) {
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
return { kind: 'deny', reason: `${label}이 충분합니다.` };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const reqCityCapacity = (key: keyof City, label: string, required: number | string): Constraint => ({
|
export const reqCityCapacity = (key: keyof City, label: string, required: number | string): Constraint => ({
|
||||||
name: 'reqCityCapacity',
|
name: 'reqCityCapacity',
|
||||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||||
@@ -506,7 +482,7 @@ export const hasRouteWithEnemy = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const beNeutralCity = (): Constraint => ({
|
export const neutralCity = (): Constraint => ({
|
||||||
name: 'beNeutralCity',
|
name: 'beNeutralCity',
|
||||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||||
test: (ctx, view) => {
|
test: (ctx, view) => {
|
||||||
@@ -534,8 +510,6 @@ export const beNeutralCity = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const neutralCity = (): Constraint => beNeutralCity();
|
|
||||||
|
|
||||||
export const constructableCity = (): Constraint => ({
|
export const constructableCity = (): Constraint => ({
|
||||||
name: 'constructableCity',
|
name: 'constructableCity',
|
||||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||||
@@ -558,25 +532,6 @@ export const constructableCity = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reqCityLevel = (levels: number[]): Constraint => ({
|
|
||||||
name: 'reqCityLevel',
|
|
||||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
|
||||||
test: (ctx, view) => {
|
|
||||||
const city = readCity(view, ctx.cityId);
|
|
||||||
if (!city) {
|
|
||||||
if (ctx.cityId === undefined) {
|
|
||||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
|
||||||
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
if (levels.includes(city.level)) {
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
return { kind: 'deny', reason: '규모가 맞지 않습니다.' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const nearCity = (maxDistance: number): Constraint => ({
|
export const nearCity = (maxDistance: number): Constraint => ({
|
||||||
name: 'nearCity',
|
name: 'nearCity',
|
||||||
requires: (ctx) => {
|
requires: (ctx) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Constraint, ConstraintContext, ConstraintResult, RequirementKey, StateView } from './types.js';
|
import type { Constraint, ConstraintContext, ConstraintResult, StateView } from './types.js';
|
||||||
|
|
||||||
export const evaluateConstraints = (
|
export const evaluateConstraints = (
|
||||||
constraints: Constraint[],
|
constraints: Constraint[],
|
||||||
@@ -24,14 +24,6 @@ export const evaluateConstraints = (
|
|||||||
return { kind: 'allow' };
|
return { kind: 'allow' };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const collectRequirements = (constraints: Constraint[], ctx: ConstraintContext): RequirementKey[] => {
|
|
||||||
const keys: RequirementKey[] = [];
|
|
||||||
for (const constraint of constraints) {
|
|
||||||
keys.push(...constraint.requires(ctx));
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ActionWithConstraints {
|
export interface ActionWithConstraints {
|
||||||
buildConstraints(ctx: ConstraintContext, args: unknown): Constraint[];
|
buildConstraints(ctx: ConstraintContext, args: unknown): Constraint[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,25 +89,6 @@ export const beChief = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const beMonarch = (): Constraint => ({
|
|
||||||
name: 'beMonarch',
|
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
|
||||||
test: (ctx, view) => {
|
|
||||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
|
||||||
if (!view.has(req)) {
|
|
||||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const general = view.get(req) as General | null;
|
|
||||||
if (!general) {
|
|
||||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
if (general.officerLevel === 12) {
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
return { kind: 'deny', reason: '군주가 아닙니다.' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const beLord = (): Constraint => ({
|
export const beLord = (): Constraint => ({
|
||||||
name: 'beLord',
|
name: 'beLord',
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
@@ -508,47 +489,6 @@ export const existsDestGeneral = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const destGeneralInDestNation = (): Constraint => ({
|
|
||||||
name: 'destGeneralInDestNation',
|
|
||||||
requires: (ctx) => {
|
|
||||||
const reqs: RequirementKey[] = [];
|
|
||||||
const destGeneralId = resolveDestGeneralId(ctx);
|
|
||||||
if (destGeneralId !== undefined) {
|
|
||||||
reqs.push({ kind: 'destGeneral', id: destGeneralId });
|
|
||||||
}
|
|
||||||
const destNationId = resolveDestNationId(ctx);
|
|
||||||
if (destNationId !== undefined) {
|
|
||||||
reqs.push({ kind: 'destNation', id: destNationId });
|
|
||||||
}
|
|
||||||
return reqs;
|
|
||||||
},
|
|
||||||
test: (ctx, view) => {
|
|
||||||
const destGeneral = readDestGeneral(ctx, view);
|
|
||||||
if (!destGeneral) {
|
|
||||||
const destGeneralId = resolveDestGeneralId(ctx);
|
|
||||||
if (destGeneralId === undefined) {
|
|
||||||
return unknownOrDeny(ctx, [], '장수 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const req: RequirementKey = {
|
|
||||||
kind: 'destGeneral',
|
|
||||||
id: destGeneralId,
|
|
||||||
};
|
|
||||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const destNationId = resolveDestNationId(ctx);
|
|
||||||
if (destNationId === undefined) {
|
|
||||||
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
if (destGeneral.nationId !== destNationId) {
|
|
||||||
return {
|
|
||||||
kind: 'deny',
|
|
||||||
reason: '제의 장수가 국가 소속이 아닙니다.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return allow();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const friendlyDestGeneral = (): Constraint => ({
|
export const friendlyDestGeneral = (): Constraint => ({
|
||||||
name: 'friendlyDestGeneral',
|
name: 'friendlyDestGeneral',
|
||||||
requires: (ctx) => {
|
requires: (ctx) => {
|
||||||
@@ -603,29 +543,6 @@ export const mustBeNPC = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notSameDestNation = (): Constraint => ({
|
|
||||||
name: 'notSameDestNation',
|
|
||||||
requires: (ctx) => {
|
|
||||||
const reqs: RequirementKey[] = [];
|
|
||||||
const destNationId = resolveDestNationId(ctx);
|
|
||||||
if (destNationId !== undefined) {
|
|
||||||
reqs.push({ kind: 'destNation', id: destNationId });
|
|
||||||
}
|
|
||||||
return reqs;
|
|
||||||
},
|
|
||||||
test: (ctx, _view) => {
|
|
||||||
const destNationId = resolveDestNationId(ctx);
|
|
||||||
if (destNationId === undefined) {
|
|
||||||
return unknownOrDeny(ctx, [], '목표 국가가 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.nationId === destNationId) {
|
|
||||||
return { kind: 'deny', reason: '이미 소속된 국가입니다.' };
|
|
||||||
}
|
|
||||||
return allow();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const notLord = (): Constraint => ({
|
export const notLord = (): Constraint => ({
|
||||||
name: 'notLord',
|
name: 'notLord',
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
@@ -640,18 +557,3 @@ export const notLord = (): Constraint => ({
|
|||||||
return { kind: 'deny', reason: '군주는 불가능합니다.' };
|
return { kind: 'deny', reason: '군주는 불가능합니다.' };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notChief = (): Constraint => ({
|
|
||||||
name: 'notChief',
|
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
|
||||||
test: (ctx, view) => {
|
|
||||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
|
||||||
const general = view.get(req) as General | null;
|
|
||||||
if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
|
||||||
|
|
||||||
if (general.officerLevel <= 4) {
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
return { kind: 'deny', reason: '수뇌입니다.' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ export const denyWithReason = (reason: string): Constraint => ({
|
|||||||
test: () => ({ kind: 'deny', reason }),
|
test: () => ({ kind: 'deny', reason }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: 점진 이전을 위해 유지. 신규 코드에서는 denyWithReason을 사용한다.
|
|
||||||
export const alwaysFail = denyWithReason;
|
|
||||||
|
|
||||||
export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({
|
export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({
|
||||||
name: 'notOpeningPart',
|
name: 'notOpeningPart',
|
||||||
requires: () => [],
|
requires: () => [],
|
||||||
|
|||||||
@@ -48,25 +48,6 @@ export const notWanderingNation = (): Constraint => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const beWanderingNation = (): Constraint => ({
|
|
||||||
name: 'beWanderingNation',
|
|
||||||
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
|
||||||
test: (ctx, view) => {
|
|
||||||
const nation = readNation(view, ctx.nationId);
|
|
||||||
if (!nation) {
|
|
||||||
if (ctx.nationId === undefined) {
|
|
||||||
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
const req: RequirementKey = { kind: 'nation', id: ctx.nationId };
|
|
||||||
return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.');
|
|
||||||
}
|
|
||||||
if (nation.level === 0) {
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
return { kind: 'deny', reason: '방랑군이 아닙니다.' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const wanderingNation = (): Constraint => ({
|
export const wanderingNation = (): Constraint => ({
|
||||||
name: 'wanderingNation',
|
name: 'wanderingNation',
|
||||||
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { actionModule as castleFirst } from './actions/che_성벽선제.js';
|
import { actionModule as castleFirst } from './actions/che_성벽선제.js';
|
||||||
import type { CrewTypeActionModule, CrewTypeActionRegistry } from './types.js';
|
import type { CrewTypeActionModule, CrewTypeActionRegistry } from './types.js';
|
||||||
|
|
||||||
export const CREW_TYPE_ACTION_KEYS = ['che_성벽선제'] as const;
|
|
||||||
|
|
||||||
export const createCrewTypeActionRegistry = (
|
export const createCrewTypeActionRegistry = (
|
||||||
modules: readonly CrewTypeActionModule[] = [castleFirst]
|
modules: readonly CrewTypeActionModule[] = [castleFirst]
|
||||||
): CrewTypeActionRegistry => new Map(modules.map((module) => [module.key, module]));
|
): CrewTypeActionRegistry => new Map(modules.map((module) => [module.key, module]));
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { DIPLOMACY_STATE } from './constants.js';
|
|||||||
|
|
||||||
export { DIPLOMACY_STATE } from './constants.js';
|
export { DIPLOMACY_STATE } from './constants.js';
|
||||||
|
|
||||||
export const DEFAULT_DECLARE_WAR_TERM = 24;
|
|
||||||
export const DEFAULT_WAR_TERM = 6;
|
export const DEFAULT_WAR_TERM = 6;
|
||||||
|
|
||||||
const MAX_WAR_TERM = 13;
|
const MAX_WAR_TERM = 13;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './nationIncome.js';
|
|
||||||
@@ -87,7 +87,7 @@ export const createIncomeActionContext = (nation: Nation): GeneralActionContext
|
|||||||
return { general, nation };
|
return { general, nation };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calcCityGoldIncomeBase = (
|
const calcCityGoldIncomeBase = (
|
||||||
context: NationIncomeContext,
|
context: NationIncomeContext,
|
||||||
city: CityIncomeSource,
|
city: CityIncomeSource,
|
||||||
officerCnt: number,
|
officerCnt: number,
|
||||||
@@ -112,7 +112,7 @@ export const calcCityGoldIncomeBase = (
|
|||||||
return Math.round(adjusted);
|
return Math.round(adjusted);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calcCityRiceIncomeBase = (
|
const calcCityRiceIncomeBase = (
|
||||||
context: NationIncomeContext,
|
context: NationIncomeContext,
|
||||||
city: CityIncomeSource,
|
city: CityIncomeSource,
|
||||||
officerCnt: number,
|
officerCnt: number,
|
||||||
@@ -137,7 +137,7 @@ export const calcCityRiceIncomeBase = (
|
|||||||
return Math.round(adjusted);
|
return Math.round(adjusted);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calcCityWallIncomeBase = (
|
const calcCityWallIncomeBase = (
|
||||||
context: NationIncomeContext,
|
context: NationIncomeContext,
|
||||||
city: CityIncomeSource,
|
city: CityIncomeSource,
|
||||||
officerCnt: number,
|
officerCnt: number,
|
||||||
@@ -161,7 +161,7 @@ export const calcCityWallIncomeBase = (
|
|||||||
return Math.round(adjusted);
|
return Math.round(adjusted);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calcCityWarGoldIncome = (context: NationIncomeContext, city: CityIncomeSource): number => {
|
const calcCityWarGoldIncome = (context: NationIncomeContext, city: CityIncomeSource): number => {
|
||||||
if (city.supplyState === 0) {
|
if (city.supplyState === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ export const getWarGoldIncome = (context: NationIncomeContext, cities: CityIncom
|
|||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveDedLevel = (dedication: number): number => {
|
const resolveDedLevel = (dedication: number): number => {
|
||||||
const level = Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10);
|
const level = Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10);
|
||||||
return Math.max(0, Math.min(MAX_DED_LEVEL, level));
|
return Math.max(0, Math.min(MAX_DED_LEVEL, level));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ export * from './auction/neutral.js';
|
|||||||
export * from './constraints/index.js';
|
export * from './constraints/index.js';
|
||||||
export * from './crewType/index.js';
|
export * from './crewType/index.js';
|
||||||
export * from './diplomacy/index.js';
|
export * from './diplomacy/index.js';
|
||||||
export * from './economy/index.js';
|
export * from './economy/nationIncome.js';
|
||||||
export * from './logging/index.js';
|
export * from './logging/index.js';
|
||||||
export * from './messages/index.js';
|
export * from './messages/message.js';
|
||||||
export * from './items/index.js';
|
export * from './items/index.js';
|
||||||
export { ITEM_KEYS, createItemActionModules, createItemModuleRegistry, loadItemModules } from './items/index.js';
|
|
||||||
export * from './rewards/uniqueLottery.js';
|
export * from './rewards/uniqueLottery.js';
|
||||||
export * from './inheritance/inheritBuff.js';
|
export * from './inheritance/inheritBuff.js';
|
||||||
export * from './resources/index.js';
|
export * from './resources/index.js';
|
||||||
@@ -20,7 +19,7 @@ export * from './ports/worldSnapshot.js';
|
|||||||
export * from './ports/trace.js';
|
export * from './ports/trace.js';
|
||||||
export * from './scenario/index.js';
|
export * from './scenario/index.js';
|
||||||
export * from './triggers/index.js';
|
export * from './triggers/index.js';
|
||||||
export * from './turn/index.js';
|
export * from './turn/calendar.js';
|
||||||
export * from './tournament/index.js';
|
export * from './tournament/index.js';
|
||||||
export * from './troop/management.js';
|
export * from './troop/management.js';
|
||||||
export * from './world/index.js';
|
export * from './world/index.js';
|
||||||
|
|||||||
@@ -754,26 +754,13 @@ export const createItemActionModules = <TriggerState extends GeneralTriggerState
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type { ItemModule, ItemModuleExport, ItemSlot } from './types.js';
|
export type { ItemModule, ItemModuleExport, ItemSlot } from './types.js';
|
||||||
export {
|
|
||||||
canAcquireItem,
|
|
||||||
isInventoryEnabled,
|
|
||||||
listEquippedItemKeys,
|
|
||||||
consumeItemRemain,
|
|
||||||
getItemRemain,
|
|
||||||
setItemRemain,
|
|
||||||
} from './utils.js';
|
|
||||||
export {
|
export {
|
||||||
cloneItemInventory,
|
cloneItemInventory,
|
||||||
consumeEquippedItemCharge,
|
|
||||||
createItemInventoryFromSlots,
|
|
||||||
ensureItemInventory,
|
ensureItemInventory,
|
||||||
equipNewItem,
|
equipNewItem,
|
||||||
getEquippedItemInstance,
|
getEquippedItemInstance,
|
||||||
parseItemInventory,
|
|
||||||
projectItemSlots,
|
projectItemSlots,
|
||||||
readItemInventory,
|
|
||||||
readItemInventoryFromMeta,
|
readItemInventoryFromMeta,
|
||||||
removeEquippedItem,
|
removeEquippedItem,
|
||||||
serializeItemInventory,
|
|
||||||
withSerializedItemInventory,
|
withSerializedItemInventory,
|
||||||
} from './inventory.js';
|
} from './inventory.js';
|
||||||
|
|||||||
@@ -1,33 +1,5 @@
|
|||||||
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
|
||||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { ItemModule } from './types.js';
|
import { consumeEquippedItemCharge, readItemInventory } from './inventory.js';
|
||||||
import {
|
|
||||||
consumeEquippedItemCharge,
|
|
||||||
ensureItemInventory,
|
|
||||||
getEquippedItemInstance,
|
|
||||||
readItemInventory,
|
|
||||||
} from './inventory.js';
|
|
||||||
|
|
||||||
const toBoolean = (value: unknown): boolean => {
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return value > 0;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
return normalized === 'true' || normalized === 'yes' || normalized === '1';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isInventoryEnabled = (config: ScenarioConfig): boolean => {
|
|
||||||
const constConfig = config.const ?? {};
|
|
||||||
return toBoolean(
|
|
||||||
constConfig['allowInventory'] ?? constConfig['inventoryEnabled'] ?? constConfig['enableInventory']
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
||||||
general: General<TriggerState>
|
general: General<TriggerState>
|
||||||
@@ -49,32 +21,6 @@ export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getItemRemain = <TriggerState extends GeneralTriggerState>(
|
|
||||||
general: General<TriggerState>,
|
|
||||||
itemKey: string
|
|
||||||
): number | null => {
|
|
||||||
const instance = getEquippedItemInstance(general, 'item');
|
|
||||||
const value = instance?.itemKey === itemKey ? instance.state.charges : undefined;
|
|
||||||
return typeof value === 'number' && value > 0 ? value : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setItemRemain = <TriggerState extends GeneralTriggerState>(
|
|
||||||
general: General<TriggerState>,
|
|
||||||
itemKey: string,
|
|
||||||
remain: number | null
|
|
||||||
): void => {
|
|
||||||
ensureItemInventory(general);
|
|
||||||
const instance = getEquippedItemInstance(general, 'item');
|
|
||||||
if (!instance || instance.itemKey !== itemKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (remain === null || remain <= 0) {
|
|
||||||
delete instance.state.charges;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
instance.state.charges = remain;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const consumeItemRemain = <TriggerState extends GeneralTriggerState>(
|
export const consumeItemRemain = <TriggerState extends GeneralTriggerState>(
|
||||||
general: General<TriggerState>,
|
general: General<TriggerState>,
|
||||||
itemKey: string,
|
itemKey: string,
|
||||||
@@ -82,27 +28,3 @@ export const consumeItemRemain = <TriggerState extends GeneralTriggerState>(
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
return consumeEquippedItemCharge(general, 'item', itemKey, fallbackRemain);
|
return consumeEquippedItemCharge(general, 'item', itemKey, fallbackRemain);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const canAcquireItem = <TriggerState extends GeneralTriggerState>(options: {
|
|
||||||
general: General<TriggerState>;
|
|
||||||
item: ItemModule;
|
|
||||||
config: ScenarioConfig;
|
|
||||||
registry: Map<string, ItemModule>;
|
|
||||||
}): boolean => {
|
|
||||||
const { general, item, config, registry } = options;
|
|
||||||
if (!item.unique) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (isInventoryEnabled(config)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const slotItemKey = general.role.items[item.slot];
|
|
||||||
if (!slotItemKey) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const slotItem = registry.get(slotItemKey);
|
|
||||||
if (!slotItem) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return !slotItem.unique;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './message.js';
|
|
||||||
@@ -45,9 +45,9 @@ export interface MessageStore {
|
|||||||
insertMessage(draft: MessageRecordDraft): Promise<number>;
|
insertMessage(draft: MessageRecordDraft): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isValidMailbox = (mailbox: number): boolean => mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC;
|
const isValidMailbox = (mailbox: number): boolean => mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC;
|
||||||
|
|
||||||
export const resolveReceiverMailbox = (draft: MessageDraft): number => {
|
const resolveReceiverMailbox = (draft: MessageDraft): number => {
|
||||||
switch (draft.msgType) {
|
switch (draft.msgType) {
|
||||||
case 'public':
|
case 'public':
|
||||||
return MESSAGE_MAILBOX_PUBLIC;
|
return MESSAGE_MAILBOX_PUBLIC;
|
||||||
@@ -59,7 +59,7 @@ export const resolveReceiverMailbox = (draft: MessageDraft): number => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveSenderMailbox = (draft: MessageDraft): number | null => {
|
const resolveSenderMailbox = (draft: MessageDraft): number | null => {
|
||||||
switch (draft.msgType) {
|
switch (draft.msgType) {
|
||||||
case 'public':
|
case 'public':
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const MapCityStatsSchema = z.object({
|
const MapCityStatsSchema = z.object({
|
||||||
population: z.number(),
|
population: z.number(),
|
||||||
agriculture: z.number(),
|
agriculture: z.number(),
|
||||||
commerce: z.number(),
|
commerce: z.number(),
|
||||||
@@ -9,7 +9,7 @@ export const MapCityStatsSchema = z.object({
|
|||||||
wall: z.number(),
|
wall: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MapCityDefinitionSchema = z.object({
|
const MapCityDefinitionSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
level: z.number(),
|
level: z.number(),
|
||||||
@@ -24,7 +24,7 @@ export const MapCityDefinitionSchema = z.object({
|
|||||||
meta: z.record(z.string(), z.unknown()).optional(),
|
meta: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MapDefaultsSchema = z
|
const MapDefaultsSchema = z
|
||||||
.object({
|
.object({
|
||||||
trust: z.number(),
|
trust: z.number(),
|
||||||
trade: z.number(),
|
trade: z.number(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js';
|
import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js';
|
||||||
|
|
||||||
export const ScenarioStatBlockSchema = z
|
const ScenarioStatBlockSchema = z
|
||||||
.object({
|
.object({
|
||||||
total: z.number(),
|
total: z.number(),
|
||||||
min: z.number(),
|
min: z.number(),
|
||||||
@@ -18,7 +18,7 @@ export const ScenarioDefaultsInputSchema = z.object({
|
|||||||
iconPath: z.string().optional(),
|
iconPath: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
|
const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
|
||||||
|
|
||||||
const ScenarioConstInputSchema = z
|
const ScenarioConstInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
const numericRecordSchema = z.record(z.string(), z.number());
|
const numericRecordSchema = z.record(z.string(), z.number());
|
||||||
const numericArraySchema = z.array(z.number());
|
const numericArraySchema = z.array(z.number());
|
||||||
|
|
||||||
export const CrewTypeRequirementSchema = z.union([
|
const CrewTypeRequirementSchema = z.union([
|
||||||
z.object({ type: z.literal('ReqTech'), tech: z.number() }),
|
z.object({ type: z.literal('ReqTech'), tech: z.number() }),
|
||||||
z.object({ type: z.literal('ReqRegions'), regions: z.array(z.string()) }),
|
z.object({ type: z.literal('ReqRegions'), regions: z.array(z.string()) }),
|
||||||
z.object({ type: z.literal('ReqCities'), cities: z.array(z.string()) }),
|
z.object({ type: z.literal('ReqCities'), cities: z.array(z.string()) }),
|
||||||
@@ -22,7 +22,7 @@ export const CrewTypeRequirementSchema = z.union([
|
|||||||
z.object({ type: z.string() }).passthrough(),
|
z.object({ type: z.string() }).passthrough(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const CrewTypeDefinitionInputSchema = z.object({
|
const CrewTypeDefinitionInputSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
armType: z.number(),
|
armType: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { equipNewItem } from '../items/inventory.js';
|
|||||||
|
|
||||||
export type UniqueItemPool = Record<string, Record<string, number>>;
|
export type UniqueItemPool = Record<string, Record<string, number>>;
|
||||||
|
|
||||||
export const UNIQUE_ACQUIRE_TYPES = ['아이템', '설문조사', '랜덤 임관', '건국'] as const;
|
export type UniqueAcquireType = '아이템' | '설문조사' | '랜덤 임관' | '건국';
|
||||||
export type UniqueAcquireType = (typeof UNIQUE_ACQUIRE_TYPES)[number];
|
|
||||||
|
|
||||||
export type UniqueLotteryRequest = {
|
export type UniqueLotteryRequest = {
|
||||||
acquireType: UniqueAcquireType;
|
acquireType: UniqueAcquireType;
|
||||||
@@ -322,7 +321,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
|||||||
return rng.choiceUsingWeightPair(availableUnique);
|
return rng.choiceUsingWeightPair(availableUnique);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
context: GeneralActionResolveContext<TriggerState>,
|
||||||
itemModule: ItemModule,
|
itemModule: ItemModule,
|
||||||
acquireType: UniqueAcquireType,
|
acquireType: UniqueAcquireType,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS = new Set<ScenarioEffectKey>([
|
|||||||
'event_MoreEffect',
|
'event_MoreEffect',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const isScenarioEffectKey = (value: string): value is ScenarioEffectKey =>
|
const isScenarioEffectKey = (value: string): value is ScenarioEffectKey =>
|
||||||
SCENARIO_EFFECT_KEYS.includes(value as ScenarioEffectKey);
|
SCENARIO_EFFECT_KEYS.includes(value as ScenarioEffectKey);
|
||||||
|
|
||||||
export const normalizeScenarioEffect = (value: unknown): ScenarioEffectKey | null => {
|
export const normalizeScenarioEffect = (value: unknown): ScenarioEffectKey | null => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface GeneralSkillActivation {
|
|||||||
activate(...keys: string[]): void;
|
activate(...keys: string[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGeneralSkillActivation = <TriggerState extends GeneralTriggerState>(
|
const createGeneralSkillActivation = <TriggerState extends GeneralTriggerState>(
|
||||||
general: General<TriggerState>
|
general: General<TriggerState>
|
||||||
): GeneralSkillActivation => ({
|
): GeneralSkillActivation => ({
|
||||||
has: (key: string) => Boolean(general.triggerState.flags[key]),
|
has: (key: string) => Boolean(general.triggerState.flags[key]),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const findCurrentEntryIndex = (minuteOfDay: number, entries: TurnScheduleEntries
|
|||||||
const getEntryAt = (entries: TurnScheduleEntries, index: number): TurnScheduleEntry =>
|
const getEntryAt = (entries: TurnScheduleEntries, index: number): TurnScheduleEntry =>
|
||||||
entries[Math.max(0, Math.min(entries.length - 1, index))] ?? entries[0];
|
entries[Math.max(0, Math.min(entries.length - 1, index))] ?? entries[0];
|
||||||
|
|
||||||
export const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => {
|
const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => {
|
||||||
const entries = normalizeEntries(schedule.entries);
|
const entries = normalizeEntries(schedule.entries);
|
||||||
const minuteOfDay = toMinuteOfDay(date);
|
const minuteOfDay = toMinuteOfDay(date);
|
||||||
const index = findCurrentEntryIndex(minuteOfDay, entries);
|
const index = findCurrentEntryIndex(minuteOfDay, entries);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './calendar.js';
|
|
||||||
@@ -8,19 +8,6 @@ import { che_필살발동, che_필살시도 } from './triggers/che_필살.js';
|
|||||||
import { che_회피발동, che_회피시도 } from './triggers/che_회피.js';
|
import { che_회피발동, che_회피시도 } from './triggers/che_회피.js';
|
||||||
import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js';
|
import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js';
|
||||||
|
|
||||||
export const CREW_TYPE_WAR_TRIGGER_KEYS = [
|
|
||||||
'che_성벽부상무효',
|
|
||||||
'che_기병병종전투',
|
|
||||||
'che_방어력증가5p',
|
|
||||||
'che_선제사격시도',
|
|
||||||
'che_선제사격발동',
|
|
||||||
'che_저지시도',
|
|
||||||
'che_저지발동',
|
|
||||||
'che_필살',
|
|
||||||
'che_회피',
|
|
||||||
'che_계략',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
||||||
che_성벽부상무효: (unit) => new che_성벽부상무효(unit),
|
che_성벽부상무효: (unit) => new che_성벽부상무효(unit),
|
||||||
che_기병병종전투: (unit) => new che_기병병종전투(unit),
|
che_기병병종전투: (unit) => new che_기병병종전투(unit),
|
||||||
@@ -31,6 +18,5 @@ export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
|||||||
che_저지발동: (unit) => new che_저지(unit),
|
che_저지발동: (unit) => new che_저지(unit),
|
||||||
che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)),
|
che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)),
|
||||||
che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
||||||
che_계략: (unit) =>
|
che_계략: (unit) => new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
||||||
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { JosaUtil } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { WarTriggerModule } from './types.js';
|
|
||||||
|
|
||||||
const MAGIC_TO_GENERAL = {
|
const MAGIC_TO_GENERAL = {
|
||||||
위보: [1.2, 1.1],
|
위보: [1.2, 1.1],
|
||||||
@@ -83,13 +82,7 @@ export class che_계략시도 extends BaseWarUnitTrigger {
|
|||||||
const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL;
|
const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL;
|
||||||
const magic = self.rng.choice(Object.keys(table));
|
const magic = self.rng.choice(Object.keys(table));
|
||||||
const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table];
|
const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table];
|
||||||
const successDamage = applyMagicDamageModifiers(
|
const successDamage = applyMagicDamageModifiers(self, oppose, 'warMagicSuccessDamage', rawSuccessDamage, magic);
|
||||||
self,
|
|
||||||
oppose,
|
|
||||||
'warMagicSuccessDamage',
|
|
||||||
rawSuccessDamage,
|
|
||||||
magic
|
|
||||||
);
|
|
||||||
|
|
||||||
self.activateSkill('계략시도', magic);
|
self.activateSkill('계략시도', magic);
|
||||||
if (self.rng.nextBool(successProbability)) {
|
if (self.rng.nextBool(successProbability)) {
|
||||||
@@ -145,7 +138,8 @@ export class che_계략실패 extends BaseWarUnitTrigger {
|
|||||||
selfEnv: Record<string, unknown>,
|
selfEnv: Record<string, unknown>,
|
||||||
_opposeEnv: Record<string, unknown>
|
_opposeEnv: Record<string, unknown>
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) return true;
|
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패'])
|
||||||
|
return true;
|
||||||
const magicState = readMagic(selfEnv);
|
const magicState = readMagic(selfEnv);
|
||||||
if (!magicState) return true;
|
if (!magicState) return true;
|
||||||
selfEnv['계략실패'] = true;
|
selfEnv['계략실패'] = true;
|
||||||
@@ -159,11 +153,3 @@ export class che_계략실패 extends BaseWarUnitTrigger {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const triggerModule: WarTriggerModule = {
|
|
||||||
key: 'che_계략',
|
|
||||||
name: '계략',
|
|
||||||
info: '[전투] 귀병의 계략 시도/성공/실패',
|
|
||||||
createTriggerList: (unit) =>
|
|
||||||
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||||
import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { WarTriggerModule } from './types.js';
|
|
||||||
|
|
||||||
export class che_회피시도 extends BaseWarUnitTrigger {
|
export class che_회피시도 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit) {
|
constructor(unit: WarUnit) {
|
||||||
@@ -41,10 +40,3 @@ export class che_회피발동 extends BaseWarUnitTrigger {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const triggerModule: WarTriggerModule = {
|
|
||||||
key: 'che_회피',
|
|
||||||
name: '회피',
|
|
||||||
info: '[전투] 페이즈마다 확률로 회피 발동',
|
|
||||||
createTriggerList: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
import type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
||||||
import type { WarTriggerRegistry } from '@sammo-ts/logic/war/triggers.js';
|
|
||||||
|
|
||||||
export const WAR_TRIGGER_KEYS = ['che_필살', 'che_의술'] as const;
|
export type WarTriggerKey = 'che_필살' | 'che_의술';
|
||||||
|
|
||||||
export type WarTriggerKey = (typeof WAR_TRIGGER_KEYS)[number];
|
|
||||||
|
|
||||||
export type WarTriggerImporter = () => Promise<WarTriggerModuleExport>;
|
export type WarTriggerImporter = () => Promise<WarTriggerModuleExport>;
|
||||||
|
|
||||||
@@ -12,9 +9,6 @@ const defaultImporters: Record<WarTriggerKey, WarTriggerImporter> = {
|
|||||||
che_의술: async () => import('./che_의술.js'),
|
che_의술: async () => import('./che_의술.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isWarTriggerKey = (value: string): value is WarTriggerKey =>
|
|
||||||
WAR_TRIGGER_KEYS.includes(value as WarTriggerKey);
|
|
||||||
|
|
||||||
export class WarTriggerLoader {
|
export class WarTriggerLoader {
|
||||||
private readonly cache = new Map<WarTriggerKey, Promise<WarTriggerModule>>();
|
private readonly cache = new Map<WarTriggerKey, Promise<WarTriggerModule>>();
|
||||||
|
|
||||||
@@ -60,12 +54,4 @@ export const loadWarTriggerModules = async (
|
|||||||
return modules;
|
return modules;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createWarTriggerRegistry = (modules: WarTriggerModule[]): WarTriggerRegistry => {
|
|
||||||
const registry: WarTriggerRegistry = {};
|
|
||||||
for (const module of modules) {
|
|
||||||
registry[module.key] = (unit) => module.createTriggerList(unit);
|
|
||||||
}
|
|
||||||
return registry;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
export type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { WarUnit, WAR_CRITICAL_RANGE, resolveNationTech } from './units/base.js';
|
export { WarUnit } from './units/base.js';
|
||||||
export { WarUnitGeneral } from './units/general.js';
|
export { WarUnitGeneral } from './units/general.js';
|
||||||
export { WarUnitCity } from './units/city.js';
|
export { WarUnitCity } from './units/city.js';
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const clamp = (value: number, min: number, max: number): number => Math.m
|
|||||||
|
|
||||||
export const clampMin = (value: number, min: number): number => (value < min ? min : value);
|
export const clampMin = (value: number, min: number): number => (value < min ? min : value);
|
||||||
|
|
||||||
export const clampMax = (value: number, max: number): number => (value > max ? max : value);
|
|
||||||
|
|
||||||
// REF-COMPAT:BEGIN ref-php-half-rounding
|
// REF-COMPAT:BEGIN ref-php-half-rounding
|
||||||
// PHP's round() compensates for small binary floating-point drift around a
|
// PHP's round() compensates for small binary floating-point drift around a
|
||||||
// half boundary and rounds halves away from zero. War state is persisted to
|
// half boundary and rounds halves away from zero. War state is persisted to
|
||||||
@@ -36,11 +34,6 @@ export const getMetaNumber = (meta: Record<string, TriggerValue>, key: string, f
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMetaString = (meta: Record<string, TriggerValue>, key: string): string | null => {
|
|
||||||
const value = meta[key];
|
|
||||||
return typeof value === 'string' ? value : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setMetaNumber = (meta: Record<string, TriggerValue>, key: string, value: number): void => {
|
export const setMetaNumber = (meta: Record<string, TriggerValue>, key: string, value: number): void => {
|
||||||
meta[key] = round(value);
|
meta[key] = round(value);
|
||||||
};
|
};
|
||||||
@@ -120,23 +113,6 @@ export const sortConflictEntries = (
|
|||||||
.map(([key, value]) => [key, value] as const);
|
.map(([key, value]) => [key, value] as const);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stringifyConflict = (conflict: Record<number, number> | null): string => {
|
|
||||||
if (!conflict) {
|
|
||||||
return '{}';
|
|
||||||
}
|
|
||||||
const sorted = Object.entries(conflict)
|
|
||||||
.map(([key, value]) => [Number(key), value] as const)
|
|
||||||
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
|
|
||||||
.sort(([, lhs], [, rhs]) => rhs - lhs);
|
|
||||||
|
|
||||||
const ordered: Record<string, number> = {};
|
|
||||||
for (const [key, value] of sorted) {
|
|
||||||
ordered[String(key)] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(ordered);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sortConflict = (
|
export const sortConflict = (
|
||||||
conflict: Record<number, number>,
|
conflict: Record<number, number>,
|
||||||
preferredOrder: number[] = []
|
preferredOrder: number[] = []
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './bootstrap.js';
|
export * from './bootstrap.js';
|
||||||
export * from './loader.js';
|
|
||||||
export * from './unitSet.js';
|
export * from './unitSet.js';
|
||||||
export * from './distance.js';
|
export * from './distance.js';
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { City, General, Nation, Troop } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { ScenarioConfig, ScenarioDiplomacy } from '@sammo-ts/logic/scenario/types.js';
|
|
||||||
import type { ScenarioConfigSource, WorldStateSnapshotSource } from '@sammo-ts/logic/ports/worldSnapshot.js';
|
|
||||||
import type { MapDefinition, ScenarioMeta, UnitSetDefinition, WorldSnapshot } from './types.js';
|
|
||||||
|
|
||||||
export interface WorldSnapshotLoadInput<
|
|
||||||
GeneralType extends General = General,
|
|
||||||
CityType extends City = City,
|
|
||||||
NationType extends Nation = Nation,
|
|
||||||
TroopType extends Troop = Troop,
|
|
||||||
> {
|
|
||||||
worldSource: WorldStateSnapshotSource<GeneralType, CityType, NationType, TroopType>;
|
|
||||||
scenarioConfig?: ScenarioConfig;
|
|
||||||
scenarioMeta?: ScenarioMeta;
|
|
||||||
scenarioSource?: ScenarioConfigSource;
|
|
||||||
map: MapDefinition;
|
|
||||||
unitSet?: UnitSetDefinition;
|
|
||||||
diplomacy?: ScenarioDiplomacy[];
|
|
||||||
events?: unknown[];
|
|
||||||
initialEvents?: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB 기반 월드 로더: 세계 상태와 시나리오 설정을 합쳐 스냅샷을 만든다.
|
|
||||||
export const loadWorldSnapshot = async <
|
|
||||||
GeneralType extends General,
|
|
||||||
CityType extends City,
|
|
||||||
NationType extends Nation,
|
|
||||||
TroopType extends Troop,
|
|
||||||
>(
|
|
||||||
input: WorldSnapshotLoadInput<GeneralType, CityType, NationType, TroopType>
|
|
||||||
): Promise<WorldSnapshot> => {
|
|
||||||
const { worldSource, scenarioSource } = input;
|
|
||||||
|
|
||||||
const scenarioConfig =
|
|
||||||
input.scenarioConfig ?? (scenarioSource ? await scenarioSource.loadScenarioConfig() : undefined);
|
|
||||||
if (!scenarioConfig) {
|
|
||||||
throw new Error('Scenario config is required to load world snapshot.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenarioMeta =
|
|
||||||
input.scenarioMeta ?? (scenarioSource?.loadScenarioMeta ? await scenarioSource.loadScenarioMeta() : undefined);
|
|
||||||
|
|
||||||
const [generals, cities, nations, troops] = await Promise.all([
|
|
||||||
worldSource.listGenerals(),
|
|
||||||
worldSource.listCities(),
|
|
||||||
worldSource.listNations(),
|
|
||||||
worldSource.listTroops(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
scenarioConfig,
|
|
||||||
...(scenarioMeta ? { scenarioMeta } : {}),
|
|
||||||
map: input.map,
|
|
||||||
...(input.unitSet ? { unitSet: input.unitSet } : {}),
|
|
||||||
nations,
|
|
||||||
cities,
|
|
||||||
generals,
|
|
||||||
troops,
|
|
||||||
diplomacy: input.diplomacy ?? [],
|
|
||||||
events: input.events ?? [],
|
|
||||||
initialEvents: input.initialEvents ?? [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { type MapDefinition, type MapCityDefinition } from '../../src/world/types.js';
|
import { type MapDefinition, type MapCityDefinition } from '../../src/world/types.js';
|
||||||
|
|
||||||
export const MINIMAL_MAP_CITIES: MapCityDefinition[] = [
|
const MINIMAL_MAP_CITIES: MapCityDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: '소성A',
|
name: '소성A',
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ import {
|
|||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
buildNeutralResourceAuctionPlan,
|
buildNeutralResourceAuctionPlan,
|
||||||
createItemInventoryFromSlots,
|
|
||||||
ItemLoader,
|
ItemLoader,
|
||||||
ITEM_KEYS,
|
ITEM_KEYS,
|
||||||
serializeItemInventory,
|
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
import {
|
||||||
|
createItemInventoryFromSlots,
|
||||||
|
serializeItemInventory,
|
||||||
|
} from '@sammo-ts/logic/items/inventory.js';
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ const migratePaged = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapGeneral = (row: SourceRow, options: CurrentSeasonFixtureOptions): TargetRow => {
|
const mapGeneral = (row: SourceRow, options: CurrentSeasonFixtureOptions): TargetRow => {
|
||||||
const id = toNumber(row.no, 'general.no');
|
const id = toNumber(row.no, 'general.no');
|
||||||
const aux = jsonObjectOrLegacyEmpty(row.aux, `general.${id}.aux`);
|
const aux = jsonObjectOrLegacyEmpty(row.aux, `general.${id}.aux`);
|
||||||
const meta: Record<string, JsonValue> = {
|
const meta: Record<string, JsonValue> = {
|
||||||
@@ -281,7 +281,7 @@ export const mapGeneral = (row: SourceRow, options: CurrentSeasonFixtureOptions)
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapNation = (row: SourceRow, nationEnv: Record<string, JsonValue>): TargetRow => {
|
const mapNation = (row: SourceRow, nationEnv: Record<string, JsonValue>): TargetRow => {
|
||||||
const id = toNumber(row.nation, 'nation.nation');
|
const id = toNumber(row.nation, 'nation.nation');
|
||||||
const aux = jsonObject(row.aux, `nation.${id}.aux`);
|
const aux = jsonObject(row.aux, `nation.${id}.aux`);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user