feat: 응답 가능한 서신과 턴 시간 호환 이관

등용장과 이민족 선택 응답을 turn daemon transaction으로 연결하고 Ref의 통일 이후 상태 전이와 수신자별 메시지 저장 규칙을 보존한다.\n\n유산 턴 시간 변경을 nextTurnTimeBase 기반 결정적 계산으로 바로잡고 API, 엔진, Chromium 회귀를 추가한다.
This commit is contained in:
2026-08-19 18:03:56 +00:00
parent 9390195d5f
commit 81279e76b5
26 changed files with 1405 additions and 224 deletions
@@ -0,0 +1,291 @@
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type MessageDraft, type MessagePayload } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { createRaiseInvaderHandler } from './monthlyInvaderAction.js';
import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import type { TurnEvent } from './types.js';
type ActionableMessageType = 'scout' | 'raiseInvader';
interface MessageRow {
id: number;
mailbox: number;
type: string;
validUntil: Date;
message: unknown;
}
export interface ActionableMessageResponseResult {
ok: boolean;
action?: ActionableMessageType;
reason: string;
}
const parsePayload = (value: unknown): MessagePayload =>
(typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload;
const systemTarget: MessageDraft['src'] = {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
};
const queuePrivateNotice = (
world: InMemoryTurnWorld,
destination: MessagePayload['dest'],
text: string,
time: Date
): void => {
world.queueMessage({
msgType: 'private',
src: systemTarget,
dest: destination,
text,
time,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: {},
sendDestOnly: true,
});
};
const invalidateMessageIds = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
ids: number[],
now: Date
): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
if (uniqueIds.length === 0) return;
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: {
validUntil: now,
validUntilTick: BigInt(world.dateToGameTick(now)),
},
});
};
const validateActor = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
requestId?: string;
userId: string;
generalId: number;
}): Promise<Date> => {
const actor = options.world.getGeneralById(options.generalId);
if (!actor || actor.userId !== options.userId) {
throw new Error('messageRespond general owner does not match command user.');
}
if (!options.requestId) return new Date();
const event = await options.db.inputEvent.findUnique({
where: { requestId: options.requestId },
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
});
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
throw new Error('ENGINE input event actor or type does not match messageRespond.');
}
return event.createdAt;
};
const fetchMessageForUpdate = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
messageId: number,
now: Date
): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, valid_until AS "validUntil", message
FROM message
WHERE id = ${messageId}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
LIMIT 1
FOR UPDATE
`);
return rows[0] ?? null;
};
const respondToScout = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
executor: ImmediateGeneralActionExecutor;
actorId: number;
response: boolean;
row: MessageRow;
payload: MessagePayload;
now: Date;
}): Promise<ActionableMessageResponseResult> => {
const { db, world, executor, actorId, response, row, payload, now } = options;
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
}
if (asRecord(payload.option).used === true) {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
}
const sourceNationName = payload.src.nationName;
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
if (response) {
const execution = await executor.execute({
actionKey: 'che_등용수락',
generalId: actorId,
args: {
destNationId: payload.src.nationId,
destGeneralId: payload.src.generalId,
},
rng: new RandUtil(new LiteHashDRBG(`messageRespond:scout:${row.id}`)),
});
if (!execution.ok) {
return { ok: true, action: 'scout', reason: execution.reason ?? '등용 수락 불가.' };
}
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT id
FROM message
WHERE mailbox = ${payload.src.generalId}
AND type = 'private'
AND dest = mailbox
AND id <> ${row.id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
AND message->'option'->>'action' = 'scout'
FOR UPDATE
`);
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
world.queueMessage({
msgType: 'private',
src: payload.src,
dest: payload.dest,
text: `${sourceNationName}${sourceNationJosaRo} 등용 제의 수락`,
time: now,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: { delete: row.id },
sendDestOnly: true,
});
return { ok: true, action: 'scout', reason: 'success' };
}
const destinationJosaYi = JosaUtil.pick(payload.dest.generalName, '이');
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
generalId: actorId,
text: `${sourceNationName}${sourceNationJosaRo} 망명을 거부했습니다.`,
});
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
generalId: payload.src.generalId,
text: `<Y>${payload.dest.generalName}</>${destinationJosaYi} 등용을 거부했습니다.`,
});
await invalidateMessageIds(db, world, [row.id], now);
world.queueMessage({
msgType: 'private',
src: payload.src,
dest: payload.dest,
text: `${sourceNationName}${sourceNationJosaRo} 등용 제의 거부`,
time: now,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: { delete: row.id },
sendDestOnly: true,
});
return { ok: true, action: 'scout', reason: 'success' };
};
const respondToRaiseInvader = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
reservedTurns?: InMemoryReservedTurnStore;
actorId: number;
response: boolean;
row: MessageRow;
payload: MessagePayload;
now: Date;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
}): Promise<ActionableMessageResponseResult> => {
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' };
}
if (asRecord(payload.option).used === true) {
return { ok: false, action: 'raiseInvader', reason: '이미 사용하였습니다.' };
}
if (!response) {
await invalidateMessageIds(db, world, [row.id], now);
return { ok: true, action: 'raiseInvader', reason: 'success' };
}
const state = world.getState();
if (asNumber(state.meta.isunited ?? state.meta.isUnited, 0) !== 2) {
const reason = '천하통일이 되지 않았습니다.';
queuePrivateNotice(world, payload.dest, `${reason} 이민족 등장 불가.`, now);
return { ok: false, action: 'raiseInvader', reason };
}
if (!reservedTurns) {
throw new Error('RaiseInvader message response requires the reserved-turn store.');
}
const args = asRecord(payload.option).args;
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
}
const handler = createRaiseInvaderHandler({
getWorld: () => world,
reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
});
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler(
args,
{
year: state.currentYear,
month: state.currentMonth,
startyear: asNumber(state.meta.startYear, state.currentYear),
currentEventID: 0,
turnTime: now,
},
event
);
return { ok: true, action: 'raiseInvader', reason: 'success' };
};
export const respondToActionableMessage = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
reservedTurns?: InMemoryReservedTurnStore;
executor: ImmediateGeneralActionExecutor;
requestId?: string;
userId: string;
generalId: number;
messageId: number;
response: boolean;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
}): Promise<ActionableMessageResponseResult> => {
const acceptedAt = await validateActor(options);
const now = options.world.getGameNow(acceptedAt);
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message);
const action = asRecord(payload.option).action;
if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
}
if (action === 'raiseInvader') {
return await respondToRaiseInvader({ ...options, actorId: options.generalId, row, payload, now });
}
return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
};
@@ -111,6 +111,14 @@ const zInstantRetreat = z.object({
generalId: zFiniteNumber,
});
const zMessageRespond = z.object({
type: z.literal('messageRespond'),
userId: z.string().min(1),
generalId: z.number().int().positive(),
messageId: z.number().int().positive(),
response: z.boolean(),
});
const zVacation = z.object({
type: z.literal('vacation'),
generalId: zFiniteNumber,
@@ -488,6 +496,14 @@ const normalizeInstantRetreat: CommandNormalizer<'instantRetreat'> = (envelope)
return { ...command, requestId: envelope.requestId };
};
const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope) => {
const command = parseWith(zMessageRespond, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
const command = parseWith(zVacation, envelope.command);
if (!command) {
@@ -705,6 +721,7 @@ const normalizers: CommandNormalizerMap = {
ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus,
buildNationCandidate: normalizeBuildNationCandidate,
instantRetreat: normalizeInstantRetreat,
messageRespond: normalizeMessageRespond,
vacation: normalizeVacation,
setMySetting: normalizeSetMySetting,
dropItem: normalizeDropItem,
+53 -1
View File
@@ -30,6 +30,7 @@ import {
import {
asRecord,
ChangeJournal,
GAME_TICKS_PER_TURN,
type CommittedReadModelInvalidation,
type ReadModelDomain,
type RealtimeReadModelChanges,
@@ -1037,6 +1038,7 @@ export const createDatabaseTurnHooks = async (
const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 };
const readModelBaseline = createRealtimeReadModelBaseline(world);
let worldReadModelBaseline = createWorldReadModelSignature(world);
let persistedTickSeconds = world.getState().tickSeconds;
const committedReceipts = new Map<bigint, CommittedReadModelChangeReceipt>();
const enqueueCommittedReceipt = (
@@ -1159,6 +1161,54 @@ export const createDatabaseTurnHooks = async (
data: worldStateUpdate,
});
if (
state.tickSeconds !== persistedTickSeconds &&
commandCompletion?.result.type !== 'updateRuntimeSettings'
) {
const ticksPerSecond = BigInt(GAME_TICKS_PER_TURN / state.tickSeconds);
const baseTime = state.clockBaseTime ?? state.lastTurnTime;
await prisma.$executeRaw(GamePrisma.sql`
UPDATE auction
SET close_at = CAST(${baseTime} AS timestamp)
+ (close_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((close_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond',
updated_at = NOW()
WHERE close_tick IS NOT NULL
`);
await prisma.$executeRaw(GamePrisma.sql`
UPDATE message
SET time = CASE
WHEN time_tick IS NULL THEN time
ELSE CAST(${baseTime} AS timestamp)
+ (time_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((time_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
END,
valid_until = CASE
WHEN valid_until_tick IS NULL THEN valid_until
ELSE CAST(${baseTime} AS timestamp)
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
END
WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL
`);
await prisma.$executeRaw(GamePrisma.sql`
UPDATE vote_poll
SET start_at = CASE
WHEN start_tick IS NULL THEN start_at
ELSE CAST(${baseTime} AS timestamp)
+ (start_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((start_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
END,
end_at = CASE
WHEN end_tick IS NULL THEN end_at
ELSE CAST(${baseTime} AS timestamp)
+ (end_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((end_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
END
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
`);
}
for (const betting of pendingNationBettingOpens) {
await persistNationBettingOpen(prisma, betting);
}
@@ -1530,7 +1580,8 @@ export const createDatabaseTurnHooks = async (
return id;
},
},
message
message,
{ sendDestOnly: message.sendDestOnly }
);
}
if (options?.reservedTurns && persistedReservedTurnChanges) {
@@ -1598,6 +1649,7 @@ export const createDatabaseTurnHooks = async (
}
applyRealtimeReadModelBaseline(readModelBaseline, changes);
worldReadModelBaseline = persisted.worldReadModelSignature;
persistedTickSeconds = state.tickSeconds;
},
readModelChanges: persisted.readModelChanges,
journalWrite: persisted.journalWrite,
@@ -27,7 +27,9 @@ const resolveTickMinutes = (world: InMemoryTurnWorld, override?: number): number
const isWorldUnited = (world: InMemoryTurnWorld): boolean => {
const meta = asRecord(world.getState().meta);
return asNumber(meta.isunited ?? meta.isUnited, 0) !== 0;
// Ref keeps the event game running at isunited=1. Only the post-unification
// choice wait (2) and the completed invader game (3) stop month progress.
return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2;
};
export class InMemoryTurnProcessor implements TurnProcessor {
+33 -21
View File
@@ -2149,13 +2149,14 @@ export const createReservedTurnHandler = async (options: {
};
};
export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환';
export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환' | 'che_등용수락';
export type ImmediateGeneralActionExecutor = {
execute(input: {
actionKey: ImmediateGeneralActionKey;
generalId: number;
rng: RandUtil;
args?: Record<string, unknown>;
refreshKillturn?: boolean;
}): Promise<{ ok: boolean; reason?: string }>;
};
@@ -2182,7 +2183,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
});
const generalModuleLoader = new GeneralTurnCommandLoader();
const contextBuilders = new Map<string, ActionContextBuilder>();
for (const actionKey of ['che_거병', 'che_접경귀환'] as const) {
for (const actionKey of ['che_거병', 'che_접경귀환', 'che_등용수락'] as const) {
if (!definitions.has(actionKey)) {
continue;
}
@@ -2202,10 +2203,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
if (!definition) {
return {
ok: false,
reason:
input.actionKey === 'che_거병'
? '거병할 수 없는 모드입니다.'
: '접경귀환을 사용할 수 없는 모드입니다.',
reason: `${input.actionKey}을 실행할 수 없는 모드입니다.`,
};
}
@@ -2215,7 +2213,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
}
const city = options.world.getCityById(general.cityId) ?? undefined;
const nation = general.nationId > 0 ? options.world.getNationById(general.nationId) : null;
const args = definition.parseArgs({});
const args = definition.parseArgs(input.args ?? {});
if (args === null) {
return { ok: false, reason: '인자가 올바르지 않습니다.' };
}
@@ -2246,7 +2244,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
const failureText =
definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ??
`${reason} ${definition.name} 실패.`;
if (input.actionKey === 'che_접경귀환') {
if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') {
options.world.pushLog({
...createActionLog(failureText),
generalId: general.id,
@@ -2334,23 +2332,34 @@ export const createImmediateGeneralActionExecutor = async (options: {
const progressionLogs: LogEntryDraft[] = [];
let nextGeneral = resolution.general as TurnGeneral;
if (input.actionKey === 'che_거병') {
const activeActionAmount =
(
definition as GeneralActionDefinition & {
getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number;
}
).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0;
const nextMeta = {
...nextGeneral.meta,
inherit_active_action:
readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount,
...(input.refreshKillturn ? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) } : {}),
const activeActionAmount =
(
definition as GeneralActionDefinition & {
getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number;
}
).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0;
if (
Number.isFinite(activeActionAmount) &&
activeActionAmount !== 0 &&
nextGeneral.userId &&
nextGeneral.npcState < 2
) {
nextGeneral = {
...nextGeneral,
meta: {
...nextGeneral.meta,
inherit_active_action:
readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount,
...(input.refreshKillturn
? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) }
: {}),
},
};
}
if (input.actionKey === 'che_거병') {
nextGeneral = applyLegacyGeneralProgression(
{
...nextGeneral,
meta: nextMeta,
lastTurn: {
command: definition.name,
arg: extractArgsRecord(args),
@@ -2396,6 +2405,9 @@ export const createImmediateGeneralActionExecutor = async (options: {
options.world.queueMessage(effect.draft);
}
}
for (const troopId of resolution.deletedTroopIds ?? []) {
options.world.removeTroop(troopId);
}
for (const log of [...resolution.logs, ...progressionLogs]) {
options.world.pushLog(log);
}
+1
View File
@@ -958,6 +958,7 @@ const createTurnDaemonRuntimeWithLease = async (
auctionFinalizer: auctionFinalizer ?? undefined,
auctionBidder: auctionBidder ?? undefined,
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
});
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
@@ -6,6 +6,11 @@ import type { PendingUnificationAuctionCancellation } from './types.js';
import { queueYearbookSnapshot } from './yearbookHandler.js';
const UNIFIER_POINT = 2000;
const INVADER_MESSAGE_OPTIONS = [
{ args: [-2, -1.2, 15_000, -1], difficulty: '어려움' },
{ args: [-2, -1.2, -1, -0.5], difficulty: '보통' },
{ args: [-1, -1, -0.8, 0], difficulty: '쉬움' },
] as const;
const buildUnificationLog = (nationName: string): LogEntryDraft => ({
scope: LogScope.SYSTEM,
@@ -118,6 +123,47 @@ export const createUnificationHandler = (options: {
}
world.pushLog(buildUnificationLog(winner.name));
if (cities.some((city) => city.level === 4)) {
const eligibleGenerals = world
.listGenerals()
.filter(
(general) => Boolean(general.userId) && general.nationId === winner.id && general.npcState < 2
)
.sort((left, right) => left.id - right.id);
const recipients: (typeof eligibleGenerals)[number][] = [];
for (let officerLevel = 12; officerLevel >= 5 && recipients.length < 2; officerLevel -= 1) {
const recipient = eligibleGenerals.find((general) => general.officerLevel === officerLevel);
if (recipient) recipients.push(recipient);
}
for (const recipient of recipients) {
for (const invader of INVADER_MESSAGE_OPTIONS) {
world.queueMessage({
msgType: 'private',
src: {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
},
dest: {
generalId: recipient.id,
generalName: recipient.name,
nationId: winner.id,
nationName: winner.name,
color: winner.color,
icon: recipient.picture ?? '',
},
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
time: context.turnTime,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: { action: 'raiseInvader', args: [...invader.args], used: false },
});
}
}
}
queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth);
world.queueUnificationFinalization({
generationKey: `unification:${serverId}`,
@@ -60,6 +60,7 @@ import {
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -136,6 +137,8 @@ interface CommandHandlerContext {
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
getImmediateGeneralActionExecutor?: () => Promise<ImmediateGeneralActionExecutor>;
reservedTurns?: InMemoryReservedTurnStore;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
}
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
@@ -1604,6 +1607,35 @@ async function handleInstantRetreat(
};
}
async function handleMessageRespond(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'messageRespond' }>
): Promise<TurnDaemonCommandResult> {
if (!ctx.getImmediateGeneralActionExecutor) {
throw new Error('Immediate general action runtime is not configured.');
}
const result = await respondToActionableMessage({
db: requireCommandDatabase(ctx) as GamePrisma.TransactionClient,
world: ctx.world,
reservedTurns: ctx.reservedTurns,
executor: await ctx.getImmediateGeneralActionExecutor(),
requestId: command.requestId,
userId: command.userId,
generalId: command.generalId,
messageId: command.messageId,
response: command.response,
loadArchivedNationMaxId: ctx.loadArchivedNationMaxId,
});
return {
type: 'messageRespond',
ok: result.ok,
generalId: command.generalId,
messageId: command.messageId,
...(result.action ? { action: result.action } : {}),
reason: result.reason,
};
}
async function handleVacation(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'vacation' }>
@@ -2532,6 +2564,7 @@ export const createTurnDaemonCommandHandler = (options: {
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
}): TurnDaemonCommandHandler => {
let immediateGeneralActionExecutor: Promise<ImmediateGeneralActionExecutor> | null = null;
const ctx: CommandHandlerContext = {
@@ -2539,6 +2572,8 @@ export const createTurnDaemonCommandHandler = (options: {
auctionFinalizer: options.auctionFinalizer,
auctionBidder: options.auctionBidder,
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
reservedTurns: options.reservedTurns,
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
getImmediateGeneralActionExecutor: () => {
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
world: options.world,
@@ -2588,6 +2623,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
instantRetreat: (command) =>
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
messageRespond: (command) =>
handleMessageRespond(ctx, command as Extract<TurnDaemonCommand, { type: 'messageRespond' }>),
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
setMySetting: (command) =>
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import type { MessagePayload } from '@sammo-ts/logic';
import { respondToActionableMessage } from '../src/turn/actionableMessageResponse.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { ImmediateGeneralActionExecutor } from '../src/turn/reservedTurnHandler.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const actor: TurnGeneral = {
id: 7,
userId: 'user-7',
name: '수신자',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
turnTime: new Date('0200-01-01T00:10:00.000Z'),
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
penalty: {},
officerLevel: 1,
experience: 100,
dedication: 100,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
};
const buildWorld = (): InMemoryTurnWorld => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { hiddenSeed: 'actionable-message-test', isunited: 2 },
};
const snapshot: TurnWorldSnapshot = {
generals: [actor],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
const source = {
generalId: 8,
generalName: '제안자',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
};
const destination = {
generalId: actor.id,
generalName: actor.name,
nationId: actor.nationId,
nationName: '위',
color: '#ffffff',
icon: '',
};
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
id: 29,
mailbox: actor.id,
type: 'private',
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: {
src: source,
dest: destination,
text: '응답할 메시지',
option: { action, used: false, ...(action === 'raiseInvader' ? { args: [-2, -1.2, -1, -0.5] } : {}) },
...overrides,
} satisfies MessagePayload,
});
const buildDb = (rows: unknown[][]) => {
const queryRaw = vi.fn(async () => rows.shift() ?? []);
const updateMany = vi.fn(async () => ({ count: 1 }));
return {
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
queryRaw,
updateMany,
};
};
const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
execute: vi.fn(async () => (ok ? { ok: true } : { ok: false, reason: '등용 수락 불가.' })),
});
describe('actionable message response', () => {
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
const world = buildWorld();
const row = buildRow('scout');
const { db, updateMany } = buildDb([[row], [{ id: 31 }]]);
const executor = buildExecutor();
const result = await respondToActionableMessage({
db,
world,
executor,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: true,
});
expect(result).toEqual({ ok: true, action: 'scout', reason: 'success' });
expect(executor.execute).toHaveBeenCalledWith(
expect.objectContaining({
actionKey: 'che_등용수락',
generalId: actor.id,
args: { destNationId: source.nationId, destGeneralId: source.generalId },
})
);
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [row.id, 31] } } }));
expect(world.peekDirtyState().messages).toEqual([
expect.objectContaining({
msgType: 'private',
text: '촉으로 등용 제의 수락',
sendDestOnly: true,
option: { delete: row.id },
}),
]);
});
it('keeps a recruitment letter valid when the legacy accept constraints reject it', async () => {
const world = buildWorld();
const row = buildRow('scout');
const { db, updateMany } = buildDb([[row]]);
const result = await respondToActionableMessage({
db,
world,
executor: buildExecutor(false),
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: true,
});
expect(result).toEqual({ ok: true, action: 'scout', reason: '등용 수락 불가.' });
expect(updateMany).not.toHaveBeenCalled();
expect(world.peekDirtyState().messages).toHaveLength(0);
});
it('does not invalidate an invader prompt before validating its receiver', async () => {
const world = buildWorld();
const row = { ...buildRow('raiseInvader'), mailbox: 99 };
const { db, updateMany } = buildDb([[row]]);
const result = await respondToActionableMessage({
db,
world,
executor: buildExecutor(),
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: false,
});
expect(result).toEqual({ ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' });
expect(updateMany).not.toHaveBeenCalled();
});
it('invalidates a valid invader prompt when it is declined', async () => {
const world = buildWorld();
const row = buildRow('raiseInvader');
const { db, updateMany } = buildDb([[row]]);
const result = await respondToActionableMessage({
db,
world,
executor: buildExecutor(),
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
response: false,
});
expect(result).toEqual({ ok: true, action: 'raiseInvader', reason: 'success' });
expect(updateMany).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const buildWorld = (isunited: number): InMemoryTurnWorld => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { isunited },
};
const snapshot: TurnWorldSnapshot = {
generals: [],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
describe('invader event-game month progression', () => {
it('continues monthly processing while the invader game is active', async () => {
const world = buildWorld(1);
const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), {
budgetMs: 1_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(result.processedTurns).toBe(1);
expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 2 });
});
it.each([2, 3])('stops monthly processing at terminal united state %s', async (isunited) => {
const world = buildWorld(isunited);
const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), {
budgetMs: 1_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(result.processedTurns).toBe(0);
expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 1 });
});
});
@@ -73,7 +73,7 @@ const nation: Nation = {
gold: 1000,
rice: 2000,
power: 3000,
level: 1,
level: 4,
typeCode: 'test',
meta: {},
};
@@ -82,7 +82,7 @@ const city: City = {
id: 1,
name: '통일도시',
nationId: 1,
level: 1,
level: 4,
state: 0,
population: 1000,
populationMax: 2000,
@@ -202,6 +202,33 @@ describe('unification handler', () => {
expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([
expect.objectContaining({ auctionCancellations: [auctionCancellation, legacyAuctionCancellation] }),
]);
expect(
world.peekDirtyState().messages.map((message) => ({
text: message.text,
action: message.option?.action,
args: message.option?.args,
recipient: message.dest.generalId,
}))
).toEqual([
{
text: '이벤트 게임으로 이민족[어려움]을 소환',
action: 'raiseInvader',
args: [-2, -1.2, 15_000, -1],
recipient: 1,
},
{
text: '이벤트 게임으로 이민족[보통]을 소환',
action: 'raiseInvader',
args: [-2, -1.2, -1, -0.5],
recipient: 1,
},
{
text: '이벤트 게임으로 이민족[쉬움]을 소환',
action: 'raiseInvader',
args: [-1, -1, -0.8, 0],
recipient: 1,
},
]);
const bid = vi.fn();
const commands = createTurnDaemonCommandHandler({