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 undefined;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
onMonthChanged: (context) => {
|
onMonthChanged: async (context) => {
|
||||||
for (const handler of resolved) {
|
for (const handler of resolved) {
|
||||||
handler.onMonthChanged?.(context);
|
await handler.onMonthChanged?.(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onYearChanged: (context) => {
|
onYearChanged: async (context) => {
|
||||||
for (const handler of resolved) {
|
for (const handler of resolved) {
|
||||||
handler.onYearChanged?.(context);
|
await handler.onYearChanged?.(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
createdNations,
|
createdNations,
|
||||||
createdTroops,
|
createdTroops,
|
||||||
createdDiplomacy,
|
createdDiplomacy,
|
||||||
|
pendingNeutralAuctions,
|
||||||
} = changes;
|
} = changes;
|
||||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||||
|
|
||||||
@@ -349,6 +350,28 @@ export const createDatabaseTurnHooks = async (
|
|||||||
meta: asJson(state.meta),
|
meta: asJson(state.meta),
|
||||||
};
|
};
|
||||||
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
|
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({
|
await prisma.worldState.update({
|
||||||
where: { id: state.id },
|
where: { id: state.id },
|
||||||
data: worldStateUpdate,
|
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 createdIds = new Set(createdGenerals.map((general) => general.id));
|
||||||
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
|
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
|
||||||
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
|
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
partial = true;
|
partial = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.world.advanceMonth(nextTickTime);
|
await this.world.advanceMonth(nextTickTime);
|
||||||
processedTurns += 1;
|
processedTurns += 1;
|
||||||
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
|
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 { getNextTurnAt } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
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 {
|
import {
|
||||||
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
|
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
|
||||||
buildDefaultDiplomacy,
|
buildDefaultDiplomacy,
|
||||||
@@ -59,8 +59,8 @@ export interface TurnCalendarContext {
|
|||||||
|
|
||||||
export interface TurnCalendarHandler {
|
export interface TurnCalendarHandler {
|
||||||
// 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트.
|
// 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트.
|
||||||
onMonthChanged?(context: TurnCalendarContext): void;
|
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||||
onYearChanged?(context: TurnCalendarContext): void;
|
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InMemoryTurnWorldOptions {
|
export interface InMemoryTurnWorldOptions {
|
||||||
@@ -85,6 +85,7 @@ export interface TurnWorldChanges {
|
|||||||
createdNations: Nation[];
|
createdNations: Nation[];
|
||||||
createdTroops: Troop[];
|
createdTroops: Troop[];
|
||||||
createdDiplomacy: TurnDiplomacy[];
|
createdDiplomacy: TurnDiplomacy[];
|
||||||
|
pendingNeutralAuctions: PendingNeutralAuction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
||||||
@@ -250,6 +251,7 @@ export class InMemoryTurnWorld {
|
|||||||
}> = [];
|
}> = [];
|
||||||
private readonly logs: LogEntryDraft[] = [];
|
private readonly logs: LogEntryDraft[] = [];
|
||||||
private readonly messages: MessageDraft[] = [];
|
private readonly messages: MessageDraft[] = [];
|
||||||
|
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
|
||||||
private readonly scenarioConfig: ScenarioConfig;
|
private readonly scenarioConfig: ScenarioConfig;
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
private state: TurnWorldState;
|
private state: TurnWorldState;
|
||||||
@@ -308,6 +310,14 @@ export class InMemoryTurnWorld {
|
|||||||
this.logs.push(entry);
|
this.logs.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queueNeutralAuction(auction: PendingNeutralAuction): void {
|
||||||
|
this.pendingNeutralAuctions.push({
|
||||||
|
...auction,
|
||||||
|
detail: { ...auction.detail },
|
||||||
|
closeAt: new Date(auction.closeAt.getTime()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getScenarioConfig(): ScenarioConfig {
|
getScenarioConfig(): ScenarioConfig {
|
||||||
return this.scenarioConfig;
|
return this.scenarioConfig;
|
||||||
}
|
}
|
||||||
@@ -681,7 +691,7 @@ export class InMemoryTurnWorld {
|
|||||||
return nextTurnAt;
|
return nextTurnAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
advanceMonth(turnTime: Date): void {
|
async advanceMonth(turnTime: Date): Promise<void> {
|
||||||
const previousYear = this.state.currentYear;
|
const previousYear = this.state.currentYear;
|
||||||
const previousMonth = this.state.currentMonth;
|
const previousMonth = this.state.currentMonth;
|
||||||
let nextYear = previousYear;
|
let nextYear = previousYear;
|
||||||
@@ -711,9 +721,9 @@ export class InMemoryTurnWorld {
|
|||||||
turnTime,
|
turnTime,
|
||||||
};
|
};
|
||||||
this.advanceDiplomacyMonth();
|
this.advanceDiplomacyMonth();
|
||||||
this.calendarHandler?.onMonthChanged?.(context);
|
await this.calendarHandler?.onMonthChanged?.(context);
|
||||||
if (nextYear !== previousYear) {
|
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 deletedNationSnapshots = this.deletedNationSnapshots.slice();
|
||||||
const logs = this.logs.slice();
|
const logs = this.logs.slice();
|
||||||
const messages = this.messages.slice();
|
const messages = this.messages.slice();
|
||||||
|
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
|
||||||
|
...auction,
|
||||||
|
detail: { ...auction.detail },
|
||||||
|
closeAt: new Date(auction.closeAt.getTime()),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generals,
|
generals,
|
||||||
@@ -768,6 +783,7 @@ export class InMemoryTurnWorld {
|
|||||||
createdNations,
|
createdNations,
|
||||||
createdTroops,
|
createdTroops,
|
||||||
createdDiplomacy,
|
createdDiplomacy,
|
||||||
|
pendingNeutralAuctions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,6 +807,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
|
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
|
||||||
this.logs.splice(0, changes.logs.length);
|
this.logs.splice(0, changes.logs.length);
|
||||||
this.messages.splice(0, changes.messages.length);
|
this.messages.splice(0, changes.messages.length);
|
||||||
|
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
consumeDirtyState(): TurnWorldChanges {
|
consumeDirtyState(): TurnWorldChanges {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js';
|
|||||||
import { createUnificationHandler } from './unificationHandler.js';
|
import { createUnificationHandler } from './unificationHandler.js';
|
||||||
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
||||||
import { createAuctionBidder } from '../auction/bidder.js';
|
import { createAuctionBidder } from '../auction/bidder.js';
|
||||||
|
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
|
||||||
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
||||||
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||||
import { createYearbookHandler } from './yearbookHandler.js';
|
import { createYearbookHandler } from './yearbookHandler.js';
|
||||||
@@ -132,6 +133,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
map: snapshot.map ?? null,
|
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({
|
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
||||||
profileName: options.profileName ?? options.profile,
|
profileName: options.profileName ?? options.profile,
|
||||||
getRedisClient: () => redisConnector?.client,
|
getRedisClient: () => redisConnector?.client,
|
||||||
@@ -148,6 +156,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
nationTurnMonthlyHandler,
|
nationTurnMonthlyHandler,
|
||||||
incomeHandler,
|
incomeHandler,
|
||||||
frontStateHandler,
|
frontStateHandler,
|
||||||
|
neutralAuctionRegistrar.handler,
|
||||||
tournamentAutoStartHandler,
|
tournamentAutoStartHandler,
|
||||||
yearbookHandler.handler
|
yearbookHandler.handler
|
||||||
);
|
);
|
||||||
@@ -327,6 +336,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
const baseClose = close;
|
const baseClose = close;
|
||||||
close = async () => {
|
close = async () => {
|
||||||
await baseClose();
|
await baseClose();
|
||||||
|
await neutralAuctionRegistrar.close();
|
||||||
if (unification) {
|
if (unification) {
|
||||||
await unification.close();
|
await unification.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ export interface TurnDiplomacy {
|
|||||||
meta: Record<string, unknown>;
|
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<
|
export interface TurnWorldSnapshot extends Omit<
|
||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const registrationKey = 'integration-neutral-auction-180-02';
|
||||||
|
|
||||||
|
integration('neutral auction database persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
const deleteFixtureAuctions = async (): Promise<void> => {
|
||||||
|
await db.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
DELETE FROM auction
|
||||||
|
WHERE detail->>'neutralRegistrationKey' = ${registrationKey}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await deleteFixtureAuctions();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteFixtureAuctions();
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the auction with the month state and skips a duplicate registration key', async () => {
|
||||||
|
const row = await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
scenarioCode: 'neutral-auction-integration',
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: row.id,
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-07-25T00:10:00.000Z'),
|
||||||
|
meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey },
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: {
|
||||||
|
total: 300,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
npcTotal: 150,
|
||||||
|
npcMax: 50,
|
||||||
|
npcMin: 10,
|
||||||
|
chiefMin: 70,
|
||||||
|
},
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
});
|
||||||
|
const pending = {
|
||||||
|
registrationKey,
|
||||||
|
type: 'BUY_RICE' as const,
|
||||||
|
targetCode: '1150',
|
||||||
|
hostGeneralId: 0 as const,
|
||||||
|
hostName: '상인' as const,
|
||||||
|
detail: {
|
||||||
|
title: '쌀 1150 경매',
|
||||||
|
hostName: '상인',
|
||||||
|
amount: 1150,
|
||||||
|
isReverse: false,
|
||||||
|
startBidAmount: 920,
|
||||||
|
finishBidAmount: 2300,
|
||||||
|
neutralRegistrationKey: registrationKey,
|
||||||
|
},
|
||||||
|
closeAt: new Date('2026-07-25T00:50:00.000Z'),
|
||||||
|
};
|
||||||
|
const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
try {
|
||||||
|
// DB marker는 아직 없도록 되돌려 첫 flush가 실제 생성을 담당하게 한다.
|
||||||
|
await db.worldState.update({ where: { id: row.id }, data: { meta: { killturn: 24 } } });
|
||||||
|
world.queueNeutralAuction(pending);
|
||||||
|
await dbHooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.auction.count({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
detail: { path: ['neutralRegistrationKey'], equals: registrationKey },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
world.queueNeutralAuction(pending);
|
||||||
|
await dbHooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await db.auction.count({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
detail: { path: ['neutralRegistrationKey'], equals: registrationKey },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await dbHooks.close();
|
||||||
|
await db.worldState.delete({ where: { id: row.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createNeutralAuctionRegistrar } from '../src/auction/neutralRegistrar.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const buildGeneral = (id: number, npcState: number, gold: number, rice: number): TurnGeneral => ({
|
||||||
|
id,
|
||||||
|
name: `General_${id}`,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 0,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||||
|
turnTime: new Date('0180-01-01T00:00:00Z'),
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24 },
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold,
|
||||||
|
rice,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSnapshot = (): TurnWorldSnapshot => ({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(1, 0, 5_432, 7_654),
|
||||||
|
// ref의 WHERE npc < 2와 같이 평균에서 제외되어야 한다.
|
||||||
|
buildGeneral(2, 2, 99_999, 99_999),
|
||||||
|
],
|
||||||
|
cities: [],
|
||||||
|
nations: [1, 2, 3].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: `Nation_${id}`,
|
||||||
|
color: '#000000',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: id === 1 ? 1 : 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
})),
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: {
|
||||||
|
total: 300,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
npcTotal: 150,
|
||||||
|
npcMax: 50,
|
||||||
|
npcMin: 10,
|
||||||
|
chiefMin: 70,
|
||||||
|
},
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('neutral auction monthly registrar', () => {
|
||||||
|
it('uses the previous month seed and queues the legacy amount at the new month boundary', async () => {
|
||||||
|
const worldRef: { current: InMemoryTurnWorld | null } = { current: null };
|
||||||
|
const now = new Date('2026-07-25T12:00:00.000Z');
|
||||||
|
const registrar = await createNeutralAuctionRegistrar({
|
||||||
|
databaseUrl: 'unused://test',
|
||||||
|
profileName: 'test',
|
||||||
|
getWorld: () => worldRef.current,
|
||||||
|
getRedisClient: () => null,
|
||||||
|
getWorldConfig: () => ({ tournamentTrig: false }),
|
||||||
|
now: () => now,
|
||||||
|
loadNeutralAuctionCounts: async () => [],
|
||||||
|
});
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-07-25T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'merchant-11', killturn: 24 },
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, buildSnapshot(), {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: registrar.handler,
|
||||||
|
});
|
||||||
|
worldRef.current = world;
|
||||||
|
|
||||||
|
await world.advanceMonth(new Date('2026-07-25T00:10:00.000Z'));
|
||||||
|
|
||||||
|
expect(world.getState()).toMatchObject({
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
meta: { neutralAuctionRegistrationKey: '180-02' },
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().pendingNeutralAuctions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
registrationKey: '180-02',
|
||||||
|
type: 'BUY_RICE',
|
||||||
|
targetCode: '1150',
|
||||||
|
hostGeneralId: 0,
|
||||||
|
hostName: '상인',
|
||||||
|
closeAt: new Date(now.getTime() + 4 * 10 * 60_000),
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
amount: 1_150,
|
||||||
|
startBidAmount: 920,
|
||||||
|
finishBidAmount: 2_300,
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
closeTurnCnt: 4,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await registrar.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,27 +71,36 @@ Event actions controlling lifecycle:
|
|||||||
|
|
||||||
## Open Questions / Follow-ups
|
## Open Questions / Follow-ups
|
||||||
|
|
||||||
- `registerAuction()` is called from legacy `func_gamerule.php` during the
|
- `registerAuction()` is called from legacy `func_gamerule.php` after the
|
||||||
monthly game-rule pass. Player-hosted auctions are implemented in core2026;
|
logical month changes. Its RNG is seeded before that change with the previous
|
||||||
neutral merchant auction generation remains a separate compatibility item.
|
year/month, then consumes one power roll per nation and the conditional
|
||||||
|
tournament roll before auction generation.
|
||||||
- Scenario-specific overrides of unique auction timing have not been found.
|
- Scenario-specific overrides of unique auction timing have not been found.
|
||||||
Core2026 currently applies the legacy constants against `tickSeconds`.
|
Core2026 currently applies the legacy constants against `tickSeconds`.
|
||||||
|
|
||||||
## core2026 compatibility implementation (2026-07-25)
|
## core2026 compatibility implementation (2026-07-25)
|
||||||
|
|
||||||
| Contract | core2026 path | Status |
|
| Contract | core2026 path | Status |
|
||||||
| --- | --- | --- |
|
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test |
|
| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test |
|
||||||
| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test |
|
| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test |
|
||||||
| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck |
|
| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck |
|
||||||
| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test |
|
| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test |
|
||||||
| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test |
|
| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test |
|
||||||
| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test |
|
| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test |
|
||||||
| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization |
|
| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization |
|
||||||
| Neutral merchant registration | legacy monthly probabilistic `registerAuction()` | Not implemented |
|
| Neutral merchant registration | `neutralRegistrar.ts` runs at the month boundary, reproduces the previous-month seed and preceding RNG consumption, and queues host `0` auctions in the same DB transaction as the month state | Confirmed by PHP differential, engine boundary, PostgreSQL idempotency, and full integration tests |
|
||||||
|
|
||||||
Unique auction mutations deliberately run in the turn-engine database
|
Unique auction mutations deliberately run in the turn-engine database
|
||||||
transaction. Point reads use row locks and deductions use conditional updates,
|
transaction. Point reads use row locks and deductions use conditional updates,
|
||||||
so simultaneous requests cannot spend the same previous-season inheritance
|
so simultaneous requests cannot spend the same previous-season inheritance
|
||||||
points twice. A failed open command creates neither an auction nor an orphaned
|
points twice. A failed open command creates neither an auction nor an orphaned
|
||||||
inheritance request marker.
|
inheritance request marker.
|
||||||
|
|
||||||
|
Neutral registration intentionally counts every historical host-`0` resource
|
||||||
|
auction, including finished rows, because the legacy probability denominator
|
||||||
|
does not filter on `finished`. Average gold/rice includes only generals with
|
||||||
|
`npcState < 2` and is clamped to 1,000..20,000. The month marker and generated
|
||||||
|
auction rows are persisted together under a PostgreSQL advisory transaction
|
||||||
|
lock, preventing the same month from producing duplicate merchant auctions
|
||||||
|
during daemon retry.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import { simpleSerialize } from '../war/utils.js';
|
||||||
|
|
||||||
|
export type NeutralResourceAuctionType = 'BUY_RICE' | 'SELL_RICE';
|
||||||
|
|
||||||
|
export interface NeutralAuctionPlanInput {
|
||||||
|
hiddenSeed: string | number;
|
||||||
|
seedYear: number;
|
||||||
|
seedMonth: number;
|
||||||
|
nationCount: number;
|
||||||
|
consumeTournamentRoll: boolean;
|
||||||
|
averageGold: number;
|
||||||
|
averageRice: number;
|
||||||
|
buyRiceAuctionCount: number;
|
||||||
|
sellRiceAuctionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeutralResourceAuctionPlan {
|
||||||
|
auctionType: NeutralResourceAuctionType;
|
||||||
|
amount: number;
|
||||||
|
startBidAmount: number;
|
||||||
|
finishBidAmount: number;
|
||||||
|
closeTurnCnt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
if (max < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundToTens = (value: number): number => Math.round(value / 10) * 10;
|
||||||
|
|
||||||
|
const normalizeCount = (value: number): number => (Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0);
|
||||||
|
|
||||||
|
const normalizeAverage = (value: number): number => clamp(Number.isFinite(value) ? value : 0, 1_000, 20_000);
|
||||||
|
|
||||||
|
const canOpenResourceAuction = (plan: NeutralResourceAuctionPlan): boolean =>
|
||||||
|
plan.closeTurnCnt >= 1 &&
|
||||||
|
plan.closeTurnCnt <= 24 &&
|
||||||
|
plan.amount >= 100 &&
|
||||||
|
plan.amount <= 10_000 &&
|
||||||
|
plan.startBidAmount >= plan.amount * 0.5 &&
|
||||||
|
plan.startBidAmount <= plan.amount * 2 &&
|
||||||
|
plan.finishBidAmount >= plan.amount * 1.1 &&
|
||||||
|
plan.finishBidAmount <= plan.amount * 2 &&
|
||||||
|
plan.finishBidAmount >= plan.startBidAmount * 1.1;
|
||||||
|
|
||||||
|
export const buildNeutralResourceAuctionPlan = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||||
|
// ref TurnExecutionHelper는 날짜를 넘기기 전에 이전 연월로 monthly RNG를 만든다.
|
||||||
|
const rng = new RandUtil(
|
||||||
|
new LiteHashDRBG(simpleSerialize(input.hiddenSeed, 'monthly', input.seedYear, input.seedMonth))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ref postUpdateMonthly()의 국가 국력 보정이 registerAuction()보다 먼저 RNG를 소비한다.
|
||||||
|
for (let nationIdx = 0; nationIdx < normalizeCount(input.nationCount); nationIdx += 1) {
|
||||||
|
rng.nextRange(0.95, 1.05);
|
||||||
|
}
|
||||||
|
// 토너먼트가 없고 자동 개시가 켜진 경우 성공 여부와 무관하게 한 번 소비한다.
|
||||||
|
if (input.consumeTournamentRoll) {
|
||||||
|
rng.nextBool(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageGold = normalizeAverage(input.averageGold);
|
||||||
|
const averageRice = normalizeAverage(input.averageRice);
|
||||||
|
const result: NeutralResourceAuctionPlan[] = [];
|
||||||
|
|
||||||
|
const buyRiceAuctionCount = normalizeCount(input.buyRiceAuctionCount);
|
||||||
|
if (rng.nextBool(1 / (buyRiceAuctionCount + 5))) {
|
||||||
|
const multiplier = rng.nextRangeInt(1, 5);
|
||||||
|
const rawAmount = (averageRice / 20) * multiplier;
|
||||||
|
const rawStartBid = clamp((averageGold / 20) * 0.9 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||||
|
const plan: NeutralResourceAuctionPlan = {
|
||||||
|
auctionType: 'BUY_RICE',
|
||||||
|
amount: roundToTens(rawAmount),
|
||||||
|
startBidAmount: roundToTens(rawStartBid),
|
||||||
|
finishBidAmount: roundToTens(rawAmount * 2),
|
||||||
|
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||||
|
};
|
||||||
|
if (canOpenResourceAuction(plan)) {
|
||||||
|
result.push(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellRiceAuctionCount = normalizeCount(input.sellRiceAuctionCount);
|
||||||
|
if (rng.nextBool(1 / (sellRiceAuctionCount + 5))) {
|
||||||
|
const multiplier = rng.nextRangeInt(1, 5);
|
||||||
|
const rawAmount = (averageGold / 20) * multiplier;
|
||||||
|
const rawStartBid = clamp((averageRice / 20) * 1.1 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||||
|
const plan: NeutralResourceAuctionPlan = {
|
||||||
|
auctionType: 'SELL_RICE',
|
||||||
|
amount: roundToTens(rawAmount),
|
||||||
|
startBidAmount: roundToTens(rawStartBid),
|
||||||
|
finishBidAmount: roundToTens(rawAmount * 2),
|
||||||
|
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||||
|
};
|
||||||
|
if (canOpenResourceAuction(plan)) {
|
||||||
|
result.push(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ export * from './domain/entities.js';
|
|||||||
export type { RandomGenerator } from '@sammo-ts/common';
|
export type { RandomGenerator } from '@sammo-ts/common';
|
||||||
export * from './actions/index.js';
|
export * from './actions/index.js';
|
||||||
export * from './auction/alias.js';
|
export * from './auction/alias.js';
|
||||||
|
export * from './auction/neutral.js';
|
||||||
export * from './constraints/index.js';
|
export * from './constraints/index.js';
|
||||||
export * from './crewType/index.js';
|
export * from './crewType/index.js';
|
||||||
export * from './diplomacy/index.js';
|
export * from './diplomacy/index.js';
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildNeutralResourceAuctionPlan,
|
||||||
|
type NeutralAuctionPlanInput,
|
||||||
|
type NeutralResourceAuctionPlan,
|
||||||
|
} from '../src/auction/neutral.js';
|
||||||
|
|
||||||
|
const refRoot = process.env.SAMMO_REF_ROOT;
|
||||||
|
const oraclePath = fileURLToPath(new URL('../../../tools/legacy-oracles/neutral-auction.php', import.meta.url));
|
||||||
|
|
||||||
|
const runLegacyOracle = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||||
|
if (!refRoot) {
|
||||||
|
throw new Error('SAMMO_REF_ROOT is required');
|
||||||
|
}
|
||||||
|
const result = spawnSync('php', [oraclePath, refRoot, JSON.stringify(input)], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(result.stderr || `legacy oracle exited with ${result.status}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(result.stdout) as NeutralResourceAuctionPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixtures: Array<{ input: NeutralAuctionPlanInput; expectedTypes: string[] }> = [
|
||||||
|
{
|
||||||
|
expectedTypes: ['BUY_RICE'],
|
||||||
|
input: {
|
||||||
|
hiddenSeed: 'merchant-11',
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
nationCount: 3,
|
||||||
|
consumeTournamentRoll: false,
|
||||||
|
averageGold: 5_432,
|
||||||
|
averageRice: 7_654,
|
||||||
|
buyRiceAuctionCount: 0,
|
||||||
|
sellRiceAuctionCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedTypes: ['BUY_RICE', 'SELL_RICE'],
|
||||||
|
input: {
|
||||||
|
hiddenSeed: 'tournament-35',
|
||||||
|
seedYear: 191,
|
||||||
|
seedMonth: 12,
|
||||||
|
nationCount: 8,
|
||||||
|
consumeTournamentRoll: true,
|
||||||
|
averageGold: 25_000,
|
||||||
|
averageRice: 500,
|
||||||
|
buyRiceAuctionCount: 2,
|
||||||
|
sellRiceAuctionCount: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedTypes: [],
|
||||||
|
input: {
|
||||||
|
hiddenSeed: 'merchant-32',
|
||||||
|
seedYear: 203,
|
||||||
|
seedMonth: 7,
|
||||||
|
nationCount: 3,
|
||||||
|
consumeTournamentRoll: false,
|
||||||
|
averageGold: 5_432,
|
||||||
|
averageRice: 7_654,
|
||||||
|
buyRiceAuctionCount: 0,
|
||||||
|
sellRiceAuctionCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe.skipIf(!refRoot)('neutral auction legacy PHP differential', () => {
|
||||||
|
for (const [index, fixture] of fixtures.entries()) {
|
||||||
|
it(`matches legacy RNG timing and amounts for fixture ${index + 1}`, () => {
|
||||||
|
const actual = buildNeutralResourceAuctionPlan(fixture.input);
|
||||||
|
expect(actual).toEqual(runLegacyOracle(fixture.input));
|
||||||
|
expect(actual.map((plan) => plan.auctionType)).toEqual(fixture.expectedTypes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('matches a seed, month, count, tournament, and average-resource matrix', () => {
|
||||||
|
let generatedAuctions = 0;
|
||||||
|
const generatedTypes = new Set<string>();
|
||||||
|
for (let index = 0; index < 96; index += 1) {
|
||||||
|
const input: NeutralAuctionPlanInput = {
|
||||||
|
hiddenSeed: `neutral-matrix-${index}`,
|
||||||
|
seedYear: 180 + (index % 17),
|
||||||
|
seedMonth: (index % 12) + 1,
|
||||||
|
nationCount: index % 11,
|
||||||
|
consumeTournamentRoll: index % 2 === 0,
|
||||||
|
averageGold: 500 + ((index * 1_337) % 25_000),
|
||||||
|
averageRice: 500 + ((index * 2_111) % 25_000),
|
||||||
|
buyRiceAuctionCount: index % 9,
|
||||||
|
sellRiceAuctionCount: index % 13,
|
||||||
|
};
|
||||||
|
const actual = buildNeutralResourceAuctionPlan(input);
|
||||||
|
expect(actual, `matrix fixture ${index}`).toEqual(runLegacyOracle(input));
|
||||||
|
generatedAuctions += actual.length;
|
||||||
|
for (const plan of actual) {
|
||||||
|
generatedTypes.add(plan.auctionType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(generatedAuctions).toBeGreaterThan(0);
|
||||||
|
expect(generatedTypes).toEqual(new Set(['BUY_RICE', 'SELL_RICE']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import { ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
@@ -453,9 +453,9 @@ describe('auction integration flow', () => {
|
|||||||
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: poorBidder.accessToken,
|
value: poorBidder.accessToken,
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })).rejects.toThrow(
|
||||||
poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })
|
'금이 부족합니다.'
|
||||||
).rejects.toThrow('금이 부족합니다.');
|
);
|
||||||
|
|
||||||
const updatedAuction = await prisma.auction.findUnique({
|
const updatedAuction = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
@@ -576,9 +576,9 @@ describe('auction integration flow', () => {
|
|||||||
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: ownerBidder.accessToken,
|
value: ownerBidder.accessToken,
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||||
ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })
|
'이미 다른 유니크를 가지고 있습니다.'
|
||||||
).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.');
|
);
|
||||||
|
|
||||||
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: validBidder.accessToken,
|
value: validBidder.accessToken,
|
||||||
@@ -782,4 +782,138 @@ describe('auction integration flow', () => {
|
|||||||
expect(winner).not.toBeNull();
|
expect(winner).not.toBeNull();
|
||||||
expect(Object.values(winner!)).toContain(uniquePair.keyA);
|
expect(Object.values(winner!)).toContain(uniquePair.keyA);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it('opens the same neutral merchant auctions on the legacy monthly boundary', async () => {
|
||||||
|
if (!gameConnector) {
|
||||||
|
throw new Error('runtime not ready');
|
||||||
|
}
|
||||||
|
const prisma = gameConnector.prisma;
|
||||||
|
if (turnDaemon) {
|
||||||
|
await turnDaemon.lifecycle.stop('integration-test');
|
||||||
|
await turnDaemon.close();
|
||||||
|
await turnDaemonLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auction.deleteMany({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const futureTurn = new Date(Date.now() + 60 * 60_000);
|
||||||
|
await prisma.general.updateMany({
|
||||||
|
where: { npcState: { lt: 2 } },
|
||||||
|
data: {
|
||||||
|
gold: 5_432,
|
||||||
|
rice: 7_654,
|
||||||
|
turnTime: futureTurn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nationCount = await prisma.nation.count();
|
||||||
|
let hiddenSeed = '';
|
||||||
|
let expected = [] as ReturnType<typeof buildNeutralResourceAuctionPlan>;
|
||||||
|
for (let index = 0; index < 1_000; index += 1) {
|
||||||
|
hiddenSeed = `integration-neutral-${index}`;
|
||||||
|
expected = buildNeutralResourceAuctionPlan({
|
||||||
|
hiddenSeed,
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
nationCount,
|
||||||
|
consumeTournamentRoll: false,
|
||||||
|
averageGold: 5_432,
|
||||||
|
averageRice: 7_654,
|
||||||
|
buyRiceAuctionCount: 0,
|
||||||
|
sellRiceAuctionCount: 0,
|
||||||
|
});
|
||||||
|
if (expected.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(expected.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const worldState = await prisma.worldState.findFirstOrThrow();
|
||||||
|
const worldMeta =
|
||||||
|
worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta)
|
||||||
|
? worldState.meta
|
||||||
|
: {};
|
||||||
|
await prisma.worldState.update({
|
||||||
|
where: { id: worldState.id },
|
||||||
|
data: {
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 60,
|
||||||
|
meta: {
|
||||||
|
...worldMeta,
|
||||||
|
hiddenSeed,
|
||||||
|
lastTurnTime: new Date(Date.now() - 61_000).toISOString(),
|
||||||
|
neutralAuctionRegistrationKey: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:908',
|
||||||
|
databaseUrl: gameDatabaseUrl,
|
||||||
|
gatewayDatabaseUrl,
|
||||||
|
});
|
||||||
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
|
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
let rows = await prisma.auction.findMany({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
while (rows.length < expected.length && Date.now() < deadline) {
|
||||||
|
await sleep(100);
|
||||||
|
rows = await prisma.auction.findMany({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(expected.length);
|
||||||
|
for (const [index, plan] of expected.entries()) {
|
||||||
|
const row = rows[index]!;
|
||||||
|
const detail = row.detail as Record<string, unknown>;
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
type: plan.auctionType,
|
||||||
|
targetCode: String(plan.amount),
|
||||||
|
hostGeneralId: 0,
|
||||||
|
hostName: '상인',
|
||||||
|
status: 'OPEN',
|
||||||
|
});
|
||||||
|
expect(detail).toMatchObject({
|
||||||
|
amount: plan.amount,
|
||||||
|
startBidAmount: plan.startBidAmount,
|
||||||
|
finishBidAmount: plan.finishBidAmount,
|
||||||
|
closeTurnCnt: plan.closeTurnCnt,
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
neutralRegistrationKey: '180-02',
|
||||||
|
});
|
||||||
|
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual(
|
||||||
|
plan.closeTurnCnt * 60_000 - 2_000
|
||||||
|
);
|
||||||
|
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual(
|
||||||
|
plan.closeTurnCnt * 60_000 + 2_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const persistedState = await prisma.worldState.findUniqueOrThrow({
|
||||||
|
where: { id: worldState.id },
|
||||||
|
});
|
||||||
|
expect(persistedState).toMatchObject({
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use sammo\LiteHashDRBG;
|
||||||
|
use sammo\RandUtil;
|
||||||
|
use sammo\Util;
|
||||||
|
|
||||||
|
if ($argc !== 3) {
|
||||||
|
fwrite(STDERR, "usage: php neutral-auction.php <ref-root> <json-input>\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$refRoot = rtrim($argv[1], '/');
|
||||||
|
require $refRoot . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$input = json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
|
||||||
|
$input['hiddenSeed'],
|
||||||
|
'monthly',
|
||||||
|
$input['seedYear'],
|
||||||
|
$input['seedMonth'],
|
||||||
|
)));
|
||||||
|
|
||||||
|
for ($idx = 0; $idx < max(0, (int)$input['nationCount']); $idx++) {
|
||||||
|
$rng->nextRange(0.95, 1.05);
|
||||||
|
}
|
||||||
|
if ($input['consumeTournamentRoll']) {
|
||||||
|
$rng->nextBool(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$avgGold = Util::valueFit($input['averageGold'], 1000, 20000);
|
||||||
|
$avgRice = Util::valueFit($input['averageRice'], 1000, 20000);
|
||||||
|
$result = [];
|
||||||
|
$appendIfOpenable = static function (array $plan) use (&$result): void {
|
||||||
|
if ($plan['closeTurnCnt'] < 1 || $plan['closeTurnCnt'] > 24) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['amount'] < 100 || $plan['amount'] > 10000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['startBidAmount'] < $plan['amount'] * 0.5 || $plan['amount'] * 2 < $plan['startBidAmount']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['finishBidAmount'] < $plan['amount'] * 1.1 || $plan['amount'] * 2 < $plan['finishBidAmount']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['finishBidAmount'] < $plan['startBidAmount'] * 1.1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$result[] = $plan;
|
||||||
|
};
|
||||||
|
|
||||||
|
$buyRiceCount = max(0, (int)$input['buyRiceAuctionCount']);
|
||||||
|
if ($rng->nextBool(1 / ($buyRiceCount + 5))) {
|
||||||
|
$mul = $rng->nextRangeInt(1, 5);
|
||||||
|
$amount = $avgRice / 20 * $mul;
|
||||||
|
$cost = $avgGold / 20 * 0.9 * $mul;
|
||||||
|
$topv = $amount * 2;
|
||||||
|
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
|
||||||
|
$appendIfOpenable([
|
||||||
|
'auctionType' => 'BUY_RICE',
|
||||||
|
'amount' => Util::round($amount, -1),
|
||||||
|
'startBidAmount' => Util::round($cost, -1),
|
||||||
|
'finishBidAmount' => Util::round($topv, -1),
|
||||||
|
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sellRiceCount = max(0, (int)$input['sellRiceAuctionCount']);
|
||||||
|
if ($rng->nextBool(1 / ($sellRiceCount + 5))) {
|
||||||
|
$mul = $rng->nextRangeInt(1, 5);
|
||||||
|
$amount = $avgGold / 20 * $mul;
|
||||||
|
$cost = $avgRice / 20 * 1.1 * $mul;
|
||||||
|
$topv = $amount * 2;
|
||||||
|
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
|
||||||
|
$appendIfOpenable([
|
||||||
|
'auctionType' => 'SELL_RICE',
|
||||||
|
'amount' => Util::round($amount, -1),
|
||||||
|
'startBidAmount' => Util::round($cost, -1),
|
||||||
|
'finishBidAmount' => Util::round($topv, -1),
|
||||||
|
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||||
Reference in New Issue
Block a user