feat: execute MyPage immediate actions in daemon

This commit is contained in:
2026-07-31 01:56:16 +00:00
parent c6a2a0da93
commit d82e493109
13 changed files with 1836 additions and 98 deletions
@@ -93,11 +93,13 @@ const zDieOnPrestart = z.object({
const zBuildNationCandidate = z.object({
type: z.literal('buildNationCandidate'),
userId: z.string().min(1),
generalId: zFiniteNumber,
});
const zInstantRetreat = z.object({
type: z.literal('instantRetreat'),
userId: z.string().min(1),
generalId: zFiniteNumber,
});
@@ -1892,3 +1892,259 @@ export const createReservedTurnHandler = async (options: {
},
};
};
export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환';
export type ImmediateGeneralActionExecutor = {
execute(input: {
actionKey: ImmediateGeneralActionKey;
generalId: number;
rng: RandUtil;
refreshKillturn?: boolean;
}): Promise<{ ok: boolean; reason?: string }>;
};
/**
* Ref의 MyPage 즉시 행동은 예약 턴과 같은 command/action-module stack을
* 실행하지만 장수의 turnTime과 예약 큐는 진행시키지 않는다.
*/
export const createImmediateGeneralActionExecutor = async (options: {
world: InMemoryTurnWorld;
reservedTurns?: InMemoryReservedTurnStore;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
commandProfile?: TurnCommandProfile;
getAdditionalOccupiedUniqueItemKeys?: () =>
Iterable<string | null | undefined> | Promise<Iterable<string | null | undefined>>;
}): Promise<ImmediateGeneralActionExecutor> => {
const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet());
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
const { general: definitions } = await buildReservedTurnDefinitions({
env,
commandProfile,
defaultActionKey: DEFAULT_ACTION,
});
const generalModuleLoader = new GeneralTurnCommandLoader();
const contextBuilders = new Map<string, ActionContextBuilder>();
for (const actionKey of ['che_거병', 'che_접경귀환'] as const) {
if (!definitions.has(actionKey)) {
continue;
}
const module = await generalModuleLoader.load(actionKey);
contextBuilders.set(actionKey, module.actionContextBuilder ?? defaultActionContextBuilder);
}
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
return {
async execute(input) {
const definition = definitions.get(input.actionKey);
if (!definition) {
return {
ok: false,
reason:
input.actionKey === 'che_거병'
? '거병할 수 없는 모드입니다.'
: '접경귀환을 사용할 수 없는 모드입니다.',
};
}
const general = options.world.getGeneralById(input.generalId);
if (!general) {
return { ok: false, reason: '장수 정보를 찾을 수 없습니다.' };
}
const city = options.world.getCityById(general.cityId) ?? undefined;
const nation = general.nationId > 0 ? options.world.getNationById(general.nationId) : null;
const args = definition.parseArgs({});
if (args === null) {
return { ok: false, reason: '인자가 올바르지 않습니다.' };
}
const state = options.world.getState();
const constraintEnv = {
...resolveConstraintEnv(state, options.scenarioMeta, env),
...(options.map ? { map: options.map } : {}),
...(options.world.getUnitSet() ? { unitSet: options.world.getUnitSet() } : {}),
cities: options.world.listCities(),
nations: options.world.listNations(),
};
const constraintArgs = withCanonicalArgumentAliases(extractArgsRecord(args));
const constraintCtx = buildConstraintContext(general, city, nation, constraintArgs, constraintEnv);
const view = new WorldStateView(options.world, constraintEnv, constraintArgs, {
general,
city,
nation,
});
const constraintResult = evaluateConstraints(
definition.buildConstraints(constraintCtx, args),
constraintCtx,
view
);
if (constraintResult.kind !== 'allow') {
const reason =
constraintResult.kind === 'deny' ? constraintResult.reason : '조건을 확인할 수 없습니다.';
const failureText =
definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ??
`${reason} ${definition.name} 실패.`;
if (input.actionKey === 'che_접경귀환') {
options.world.pushLog({
...createActionLog(failureText),
generalId: general.id,
});
}
return { ok: false, reason: failureText };
}
if (input.actionKey === 'che_거병' && !options.reservedTurns) {
throw new Error('Immediate uprising requires the reserved-turn store.');
}
const additionalOccupiedUniqueItemKeys = (await options.getAdditionalOccupiedUniqueItemKeys?.()) ?? [];
const seedBase = buildSeedBase(state);
const uniqueLottery = buildUniqueLotteryRunner({
world: state,
worldView: options.world,
scenarioMeta: options.scenarioMeta,
seedBase,
itemRegistry,
uniqueConfig,
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
});
const startYear = resolveStartYear(state, options.scenarioMeta);
const baseContext: ActionContextBase = {
general,
city,
nation,
worldView: {
listGenerals: () => options.world.listGenerals(),
listGeneralsByCity: (cityId) =>
options.world.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => options.world.listNations(),
},
rng: input.rng,
time: {
year: state.currentYear,
month: state.currentMonth,
startYear,
},
uniqueLottery,
};
const actionContext =
buildActionContext(
input.actionKey,
baseContext,
{
world: state,
scenarioConfig: options.world.getScenarioConfig(),
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.world.getUnitSet(),
worldRef: options.world,
actionArgs: constraintArgs,
createGeneralId: () => options.world.getNextGeneralId(),
createNationId: () => options.world.getNextNationId(),
seedBase,
},
contextBuilders
) ?? baseContext;
const resolution = resolveGeneralAction(
definition,
actionContext,
{
now: general.turnTime,
schedule: {
entries: [
{
startMinute: 0,
tickMinutes: Math.max(1, Math.floor(state.tickSeconds / 60)),
},
],
},
},
args
);
if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) {
for (const log of resolution.logs) {
options.world.pushLog(log);
}
return { ok: false, reason: '가까운 아국 도시가 없습니다.' };
}
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) } : {}),
};
nextGeneral = applyLegacyGeneralProgression(
{
...nextGeneral,
meta: nextMeta,
lastTurn: {
command: definition.name,
arg: extractArgsRecord(args),
},
},
general,
input.actionKey,
env,
progressionLogs
);
}
for (const createdNation of resolution.created?.nations ?? []) {
if (!options.world.addNation(createdNation)) {
throw new Error(`Immediate action could not create nation ${createdNation.id}.`);
}
options.reservedTurns?.ensureNationTurns(createdNation.id, 12);
options.reservedTurns?.ensureNationTurns(createdNation.id, 11);
}
if (resolution.dirty?.city && resolution.city) {
options.world.updateCity(resolution.city.id, resolution.city);
}
if (resolution.dirty?.nation && resolution.nation) {
options.world.updateNation(resolution.nation.id, resolution.nation);
}
for (const patch of resolution.patches?.generals ?? []) {
options.world.updateGeneral(patch.id, patch.patch as Partial<TurnGeneral>);
}
for (const patch of resolution.patches?.cities ?? []) {
options.world.updateCity(patch.id, patch.patch);
}
for (const patch of resolution.patches?.nations ?? []) {
options.world.updateNation(patch.id, patch.patch);
}
for (const effect of resolution.effects) {
if (effect.type === 'diplomacy:patch') {
options.world.applyDiplomacyPatch({
srcNationId: effect.srcNationId,
destNationId: effect.destNationId,
patch: effect.patch,
});
} else if (effect.type === 'message:add') {
options.world.queueMessage(effect.draft);
}
}
for (const log of [...resolution.logs, ...progressionLogs]) {
options.world.pushLog(log);
}
options.world.updateGeneral(input.generalId, nextGeneral);
return { ok: true };
},
};
};
+5
View File
@@ -724,6 +724,11 @@ const createTurnDaemonRuntimeWithLease = async (
const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
const commandHandler = createTurnDaemonCommandHandler({
world,
reservedTurns: reservedTurnStoreHandle?.store,
scenarioMeta: snapshot.scenarioMeta,
map: snapshot.map,
commandProfile,
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
auctionFinalizer: auctionFinalizer ?? undefined,
auctionBidder: auctionBidder ?? undefined,
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
+135 -23
View File
@@ -23,7 +23,10 @@ import {
resolveUniqueConfig,
rollUniqueLottery,
type ItemModule,
type MapDefinition,
type ScenarioMeta,
type TriggerValue,
type TurnCommandProfile,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
@@ -33,7 +36,9 @@ import {
removeEquippedItem,
} from '@sammo-ts/logic/items/index.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import type { TurnGeneral } from './types.js';
import { createImmediateGeneralActionExecutor, type ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
import { openAuction } from '../auction/opener.js';
import {
hasScenarioStaticEventHandler,
@@ -121,6 +126,7 @@ interface CommandHandlerContext {
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
getImmediateGeneralActionExecutor?: () => Promise<ImmediateGeneralActionExecutor>;
}
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
@@ -150,6 +156,30 @@ const resolveCommandAcceptedAt = async (
return event.createdAt;
};
const assertImmediateGeneralActionActor = async (
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'buildNationCandidate' | 'instantRetreat' }>,
general: TurnGeneral
): Promise<void> => {
if (general.userId !== command.userId) {
throw new Error(`${command.type} general owner does not match command user.`);
}
if (!command.requestId) {
return;
}
const db = requireCommandDatabase(ctx);
const event = await db.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { actorUserId: true },
});
if (!event) {
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
}
if (event.actorUserId !== command.userId) {
throw new Error(`ENGINE input event actor does not match ${command.type} user.`);
}
};
async function handleJoinCreateGeneral(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>
@@ -990,17 +1020,10 @@ async function handleBuildNationCandidate(
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (general.nationId !== 0) {
return {
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '이미 국가에 소속되어 있습니다.',
reason: '장수 없습니다',
};
}
await assertImmediateGeneralActionActor(ctx, command, general);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
@@ -1009,11 +1032,43 @@ async function handleBuildNationCandidate(
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '가오픈 기간이 아닙니다.',
reason: '게임이 시작되었습니다.',
};
}
if (general.nationId !== 0) {
return {
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '이미 국가에 소속되어있습니다.',
};
}
return { type: 'buildNationCandidate', ok: true, generalId: command.generalId };
if (!ctx.getImmediateGeneralActionExecutor) {
throw new Error('Immediate general action runtime is not configured.');
}
const hiddenSeed = asRecord(worldState.meta).hiddenSeed ?? asRecord(worldState.meta).seed ?? worldState.id;
const executor = await ctx.getImmediateGeneralActionExecutor();
const execution = await executor.execute({
actionKey: 'che_거병',
generalId: command.generalId,
rng: new RandUtil(
new LiteHashDRBG(
simpleSerialize(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
'BuildNationCandidate',
command.generalId
)
)
),
refreshKillturn: true,
});
return {
type: 'buildNationCandidate',
ok: execution.ok,
generalId: command.generalId,
...(execution.reason ? { reason: execution.reason } : {}),
};
}
async function handleInstantRetreat(
@@ -1021,16 +1076,6 @@ async function handleInstantRetreat(
command: Extract<TurnDaemonCommand, { type: 'instantRetreat' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
type: 'instantRetreat',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
const config = world.getScenarioConfig();
const availableInstantAction = config.const.availableInstantAction as Record<string, boolean> | undefined;
if (!availableInstantAction?.instantRetreat) {
@@ -1038,11 +1083,48 @@ async function handleInstantRetreat(
type: 'instantRetreat',
ok: false,
generalId: command.generalId,
reason: '즉시 귀환이 허용되지 않는 서버입니다.',
reason: '접경귀환을 사용할 수 없는 시나리오입니다.',
};
}
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
type: 'instantRetreat',
ok: false,
generalId: command.generalId,
reason: '장수가 없습니다',
};
}
await assertImmediateGeneralActionActor(ctx, command, general);
return { type: 'instantRetreat', ok: true, generalId: command.generalId };
if (!ctx.getImmediateGeneralActionExecutor) {
throw new Error('Immediate general action runtime is not configured.');
}
const state = world.getState();
const hiddenSeed = asRecord(state.meta).hiddenSeed ?? asRecord(state.meta).seed ?? state.id;
const executor = await ctx.getImmediateGeneralActionExecutor();
const execution = await executor.execute({
actionKey: 'che_접경귀환',
generalId: command.generalId,
rng: new RandUtil(
new LiteHashDRBG(
simpleSerialize(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
'InstantRetreat',
command.generalId,
state.currentYear,
state.currentMonth,
general.cityId
)
)
),
});
return {
type: 'instantRetreat',
ok: execution.ok,
generalId: command.generalId,
...(execution.reason ? { reason: execution.reason } : {}),
};
}
async function handleVacation(
@@ -1952,15 +2034,45 @@ async function handleVoteReward(
export const createTurnDaemonCommandHandler = (options: {
world: InMemoryTurnWorld;
reservedTurns?: InMemoryReservedTurnStore;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
commandProfile?: TurnCommandProfile;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}): TurnDaemonCommandHandler => {
let immediateGeneralActionExecutor: Promise<ImmediateGeneralActionExecutor> | null = null;
const ctx: CommandHandlerContext = {
world: options.world,
auctionFinalizer: options.auctionFinalizer,
auctionBidder: options.auctionBidder,
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
getImmediateGeneralActionExecutor: () => {
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
world: options.world,
reservedTurns: options.reservedTurns,
scenarioMeta: options.scenarioMeta,
map: options.map,
commandProfile: options.commandProfile,
getAdditionalOccupiedUniqueItemKeys: async () => {
if (ctx.commandDb) {
const rows = await ctx.commandDb.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
return rows.map((row) => row.targetCode);
}
return options.getAdditionalOccupiedUniqueItemKeys?.() ?? [];
},
});
return immediateGeneralActionExecutor;
},
};
type HandlerMap = Partial<