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 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);
} }
}, },
}; };
+37
View File
@@ -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);
} }
+23 -6
View File
@@ -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 {
+10
View File
@@ -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();
} }
+10
View File
@@ -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();
});
});
+22 -13
View File
@@ -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.
+105
View File
@@ -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;
};
+1
View File
@@ -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);
}); });
+86
View File
@@ -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;