feat: add legacy-compatible neutral auctions
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user