feat: add legacy-compatible neutral auctions

This commit is contained in:
2026-07-25 11:07:37 +00:00
parent 93ae4df519
commit 06c7197a1a
15 changed files with 999 additions and 31 deletions
@@ -0,0 +1,163 @@
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, GamePrisma, type RedisConnector } from '@sammo-ts/infra';
import { buildNeutralResourceAuctionPlan } from '@sammo-ts/logic';
import type { TurnCalendarHandler } from '../turn/inMemoryWorld.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
interface NeutralAuctionCountRow {
type: 'BUY_RICE' | 'SELL_RICE';
count: bigint | number;
}
interface TournamentState {
stage?: unknown;
}
const readFiniteNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const average = (values: number[]): number => {
if (values.length === 0) {
return 0;
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
};
const parseTournamentState = (raw: string | null): TournamentState | null => {
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return asRecord(parsed);
} catch {
return null;
}
};
const isTournamentActive = async (
profileName: string,
redis: RedisConnector['client'] | null | undefined
): Promise<boolean> => {
if (!redis) {
return false;
}
const state = parseTournamentState(await redis.get(`sammo:${profileName}:tournament:state`));
return readFiniteNumber(state?.stage, 0) > 0;
};
export interface NeutralAuctionRegistrar {
handler: TurnCalendarHandler;
close(): Promise<void>;
}
export const createNeutralAuctionRegistrar = async (options: {
databaseUrl: string;
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
getRedisClient: () => RedisConnector['client'] | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
now?: () => Date;
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
loadTournamentActive?: () => Promise<boolean>;
}): Promise<NeutralAuctionRegistrar> => {
const connector = options.loadNeutralAuctionCounts
? null
: createGamePostgresConnector({ url: options.databaseUrl });
await connector?.connect();
const loadNeutralAuctionCounts =
options.loadNeutralAuctionCounts ??
(() =>
connector!.prisma.$queryRaw<NeutralAuctionCountRow[]>(
GamePrisma.sql`
SELECT type, count(*) AS count
FROM auction
WHERE host_general_id = 0
AND type IN ('BUY_RICE'::"AuctionType", 'SELL_RICE'::"AuctionType")
GROUP BY type
`
));
const handler: TurnCalendarHandler = {
onMonthChanged: async (context) => {
const world = options.getWorld();
if (!world) {
return;
}
const state = world.getState();
const hiddenSeed =
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
? state.meta.hiddenSeed
: state.id;
const eligibleGenerals = world.listGenerals().filter((general) => general.npcState < 2);
const counts = await loadNeutralAuctionCounts();
const countByType = new Map(counts.map((row) => [row.type, Number(row.count)]));
for (const pending of world.peekDirtyState().pendingNeutralAuctions) {
countByType.set(pending.type, (countByType.get(pending.type) ?? 0) + 1);
}
const worldConfig = asRecord(options.getWorldConfig() ?? {});
const consumeTournamentRoll =
worldConfig.tournamentTrig === true &&
!(await (options.loadTournamentActive
? options.loadTournamentActive()
: isTournamentActive(options.profileName, options.getRedisClient())));
const plans = buildNeutralResourceAuctionPlan({
hiddenSeed,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
nationCount: world.listNations().length,
consumeTournamentRoll,
averageGold: average(eligibleGenerals.map((general) => general.gold)),
averageRice: average(eligibleGenerals.map((general) => general.rice)),
buyRiceAuctionCount: countByType.get('BUY_RICE') ?? 0,
sellRiceAuctionCount: countByType.get('SELL_RICE') ?? 0,
});
const registrationKey = `${context.currentYear}-${String(context.currentMonth).padStart(2, '0')}`;
world.updateWorldMeta({ neutralAuctionRegistrationKey: registrationKey });
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
for (const plan of plans) {
const openedAt = options.now?.() ?? new Date();
const hostResourceName = plan.auctionType === 'BUY_RICE' ? '쌀' : '금';
world.queueNeutralAuction({
registrationKey,
type: plan.auctionType,
targetCode: String(plan.amount),
hostGeneralId: 0,
hostName: '상인',
detail: {
title: `${hostResourceName} ${plan.amount} 경매`,
hostName: '상인',
amount: plan.amount,
isReverse: false,
startBidAmount: plan.startBidAmount,
finishBidAmount: plan.finishBidAmount,
neutralRegistrationKey: registrationKey,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
closeTurnCnt: plan.closeTurnCnt,
},
closeAt: new Date(openedAt.getTime() + plan.closeTurnCnt * turnMinutes * 60_000),
});
}
},
};
return {
handler,
close: async () => {
await connector?.disconnect();
},
};
};
+4 -4
View File
@@ -8,14 +8,14 @@ export const composeCalendarHandlers = (
return undefined;
}
return {
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);
}
},
};
+37
View File
@@ -339,6 +339,7 @@ export const createDatabaseTurnHooks = async (
createdNations,
createdTroops,
createdDiplomacy,
pendingNeutralAuctions,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -349,6 +350,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,
@@ -425,6 +448,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);
}
+23 -6
View File
@@ -2,7 +2,7 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop,
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import type { PendingNeutralAuction, TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import {
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
buildDefaultDiplomacy,
@@ -59,8 +59,8 @@ export interface TurnCalendarContext {
export interface TurnCalendarHandler {
// 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트.
onMonthChanged?(context: TurnCalendarContext): void;
onYearChanged?(context: TurnCalendarContext): void;
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
}
export interface InMemoryTurnWorldOptions {
@@ -85,6 +85,7 @@ export interface TurnWorldChanges {
createdNations: Nation[];
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
pendingNeutralAuctions: PendingNeutralAuction[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
@@ -250,6 +251,7 @@ export class InMemoryTurnWorld {
}> = [];
private readonly logs: LogEntryDraft[] = [];
private readonly messages: MessageDraft[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -308,6 +310,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;
}
@@ -681,7 +691,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;
@@ -711,9 +721,9 @@ export class InMemoryTurnWorld {
turnTime,
};
this.advanceDiplomacyMonth();
this.calendarHandler?.onMonthChanged?.(context);
await this.calendarHandler?.onMonthChanged?.(context);
if (nextYear !== previousYear) {
this.calendarHandler?.onYearChanged?.(context);
await this.calendarHandler?.onYearChanged?.(context);
}
}
@@ -751,6 +761,11 @@ export class InMemoryTurnWorld {
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.slice();
const messages = this.messages.slice();
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
}));
return {
generals,
@@ -768,6 +783,7 @@ export class InMemoryTurnWorld {
createdNations,
createdTroops,
createdDiplomacy,
pendingNeutralAuctions,
};
}
@@ -791,6 +807,7 @@ export class InMemoryTurnWorld {
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.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';
@@ -132,6 +133,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,
@@ -148,6 +156,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
nationTurnMonthlyHandler,
incomeHandler,
frontStateHandler,
neutralAuctionRegistrar.handler,
tournamentAutoStartHandler,
yearbookHandler.handler
);
@@ -327,6 +336,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
@@ -35,6 +35,16 @@ export interface TurnDiplomacy {
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'