Merge origin/main into frontend parity worktree

This commit is contained in:
2026-07-25 11:16:03 +00:00
34 changed files with 2358 additions and 244 deletions
+6 -6
View File
@@ -8,19 +8,19 @@ export const composeCalendarHandlers = (
return undefined;
}
return {
beforeMonthChanged: (context) => {
beforeMonthChanged: async (context) => {
for (const handler of resolved) {
handler.beforeMonthChanged?.(context);
await handler.beforeMonthChanged?.(context);
}
},
onMonthChanged: (context) => {
onMonthChanged: async (context) => {
for (const handler of resolved) {
handler.onMonthChanged?.(context);
await handler.onMonthChanged?.(context);
}
},
onYearChanged: (context) => {
onYearChanged: async (context) => {
for (const handler of resolved) {
handler.onYearChanged?.(context);
await handler.onYearChanged?.(context);
}
},
};
@@ -36,6 +36,17 @@ const zAuctionFinalize = z.object({
auctionId: zFiniteNumber,
});
const zAuctionOpen = z.object({
type: z.literal('auctionOpen'),
generalId: zFiniteNumber,
auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']),
amount: zFiniteNumber,
closeTurnCnt: zFiniteNumber.optional(),
startBidAmount: zFiniteNumber.optional(),
finishBidAmount: zFiniteNumber.optional(),
itemKey: z.string().optional(),
});
const zAuctionBid = z.object({
type: z.literal('auctionBid'),
auctionId: zFiniteNumber,
@@ -266,6 +277,14 @@ const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope
return { ...command, requestId: envelope.requestId };
};
const normalizeAuctionOpen: CommandNormalizer<'auctionOpen'> = (envelope) => {
const command = parseWith(zAuctionOpen, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeAuctionBid: CommandNormalizer<'auctionBid'> = (envelope) => {
const command = parseWith(zAuctionBid, envelope.command);
if (!command) {
@@ -488,6 +507,7 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
const normalizers: CommandNormalizerMap = {
auctionFinalize: normalizeAuctionFinalize,
auctionOpen: normalizeAuctionOpen,
auctionBid: normalizeAuctionBid,
troopCreate: normalizeTroopCreate,
troopJoin: normalizeTroopJoin,
+37
View File
@@ -344,6 +344,7 @@ export const createDatabaseTurnHooks = async (
createdDiplomacy,
deletedEvents,
lifecycleEvents,
pendingNeutralAuctions,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -354,6 +355,28 @@ export const createDatabaseTurnHooks = async (
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey;
await prisma.$executeRaw`
SELECT pg_advisory_xact_lock(
hashtext(${'neutral-auction-registration'}),
${state.id}
)
`;
const persistedRows = await prisma.$queryRaw<Array<{ meta: unknown }>>`
SELECT meta
FROM world_state
WHERE id = ${state.id}
FOR UPDATE
`;
const persistedMeta = asRecord(persistedRows[0]?.meta);
if (persistedMeta.neutralAuctionRegistrationKey === latestRegistrationKey) {
neutralAuctionsToCreate = [];
}
}
await prisma.worldState.update({
where: { id: state.id },
data: worldStateUpdate,
@@ -436,6 +459,20 @@ export const createDatabaseTurnHooks = async (
);
}
if (neutralAuctionsToCreate.length > 0) {
await prisma.auction.createMany({
data: neutralAuctionsToCreate.map((auction) => ({
type: auction.type,
targetCode: auction.targetCode,
hostGeneralId: auction.hostGeneralId,
hostName: auction.hostName,
detail: asJson(auction.detail),
status: 'OPEN',
closeAt: auction.closeAt,
})),
});
}
const createdIds = new Set(createdGenerals.map((general) => general.id));
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
@@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
partial = true;
break;
}
this.world.advanceMonth(nextTickTime);
await this.world.advanceMonth(nextTickTime);
processedTurns += 1;
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
}
+32 -8
View File
@@ -2,7 +2,14 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop,
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import type {
PendingNeutralAuction,
TurnDiplomacy,
TurnEvent,
TurnGeneral,
TurnWorldSnapshot,
TurnWorldState,
} from './types.js';
import {
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
buildDefaultDiplomacy,
@@ -73,9 +80,9 @@ export interface TurnCalendarContext {
export interface TurnCalendarHandler {
// 레거시 PRE_MONTH는 날짜 변경 전, MONTH는 날짜 변경 후에 실행된다.
beforeMonthChanged?(context: TurnCalendarContext): void;
onMonthChanged?(context: TurnCalendarContext): void;
onYearChanged?(context: TurnCalendarContext): void;
beforeMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
}
export interface InMemoryTurnWorldOptions {
@@ -102,6 +109,7 @@ export interface TurnWorldChanges {
createdDiplomacy: TurnDiplomacy[];
deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
@@ -270,6 +278,7 @@ export class InMemoryTurnWorld {
private readonly logs: LogEntryDraft[] = [];
private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -331,6 +340,14 @@ export class InMemoryTurnWorld {
this.logs.push(entry);
}
queueNeutralAuction(auction: PendingNeutralAuction): void {
this.pendingNeutralAuctions.push({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
});
}
getScenarioConfig(): ScenarioConfig {
return this.scenarioConfig;
}
@@ -744,7 +761,7 @@ export class InMemoryTurnWorld {
return nextTurnAt;
}
advanceMonth(turnTime: Date): void {
async advanceMonth(turnTime: Date): Promise<void> {
const previousYear = this.state.currentYear;
const previousMonth = this.state.currentMonth;
let nextYear = previousYear;
@@ -761,7 +778,7 @@ export class InMemoryTurnWorld {
currentMonth: nextMonth,
turnTime,
};
this.calendarHandler?.beforeMonthChanged?.(context);
await this.calendarHandler?.beforeMonthChanged?.(context);
const meta = {
...this.state.meta,
@@ -776,9 +793,9 @@ export class InMemoryTurnWorld {
};
this.advanceDiplomacyMonth();
this.calendarHandler?.onMonthChanged?.(context);
await this.calendarHandler?.onMonthChanged?.(context);
if (nextYear !== previousYear) {
this.calendarHandler?.onYearChanged?.(context);
await this.calendarHandler?.onYearChanged?.(context);
}
}
@@ -818,6 +835,11 @@ export class InMemoryTurnWorld {
const logs = this.logs.slice();
const messages = this.messages.slice();
const lifecycleEvents = this.lifecycleEvents.slice();
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
}));
return {
generals,
@@ -837,6 +859,7 @@ export class InMemoryTurnWorld {
createdDiplomacy,
deletedEvents,
lifecycleEvents,
pendingNeutralAuctions,
};
}
@@ -862,6 +885,7 @@ export class InMemoryTurnWorld {
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.length);
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
}
consumeDirtyState(): TurnWorldChanges {
+10
View File
@@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js';
import { createUnificationHandler } from './unificationHandler.js';
import { createAuctionFinalizer } from '../auction/finalizer.js';
import { createAuctionBidder } from '../auction/bidder.js';
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
@@ -198,6 +199,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
getWorld: () => worldRef,
map: snapshot.map ?? null,
});
const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
databaseUrl: options.databaseUrl,
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
getRedisClient: () => redisConnector?.client,
@@ -215,6 +223,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
nationTurnMonthlyHandler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
frontStateHandler,
neutralAuctionRegistrar.handler,
tournamentAutoStartHandler,
yearbookHandler.handler
);
@@ -394,6 +403,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
const baseClose = close;
close = async () => {
await baseClose();
await neutralAuctionRegistrar.close();
if (unification) {
await unification.close();
}
+10
View File
@@ -49,6 +49,16 @@ export interface TurnEvent {
meta: Record<string, unknown>;
}
export interface PendingNeutralAuction {
registrationKey: string;
type: 'BUY_RICE' | 'SELL_RICE';
targetCode: string;
hostGeneralId: 0;
hostName: '상인';
detail: Record<string, unknown>;
closeAt: Date;
}
export interface TurnWorldSnapshot extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
@@ -31,6 +31,7 @@ import {
} from '@sammo-ts/logic/items/index.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
import { openAuction } from '../auction/opener.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -830,6 +831,13 @@ async function handleAuctionFinalize(
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
}
async function handleAuctionOpen(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionOpen' }>
): Promise<TurnDaemonCommandResult> {
return openAuction(command, ctx.world, ctx.commandDb);
}
async function handleAuctionBid(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
@@ -1340,6 +1348,8 @@ export const createTurnDaemonCommandHandler = (options: {
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
auctionFinalize: (command) =>
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
auctionOpen: (command) =>
handleAuctionOpen(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionOpen' }>),
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),