feat: add legacy-compatible neutral auctions

This commit is contained in:
2026-07-25 11:07:37 +00:00
parent 93ae4df519
commit 06c7197a1a
15 changed files with 999 additions and 31 deletions
@@ -0,0 +1,163 @@
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, GamePrisma, type RedisConnector } from '@sammo-ts/infra';
import { buildNeutralResourceAuctionPlan } from '@sammo-ts/logic';
import type { TurnCalendarHandler } from '../turn/inMemoryWorld.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
interface NeutralAuctionCountRow {
type: 'BUY_RICE' | 'SELL_RICE';
count: bigint | number;
}
interface TournamentState {
stage?: unknown;
}
const readFiniteNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const average = (values: number[]): number => {
if (values.length === 0) {
return 0;
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
};
const parseTournamentState = (raw: string | null): TournamentState | null => {
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return asRecord(parsed);
} catch {
return null;
}
};
const isTournamentActive = async (
profileName: string,
redis: RedisConnector['client'] | null | undefined
): Promise<boolean> => {
if (!redis) {
return false;
}
const state = parseTournamentState(await redis.get(`sammo:${profileName}:tournament:state`));
return readFiniteNumber(state?.stage, 0) > 0;
};
export interface NeutralAuctionRegistrar {
handler: TurnCalendarHandler;
close(): Promise<void>;
}
export const createNeutralAuctionRegistrar = async (options: {
databaseUrl: string;
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
getRedisClient: () => RedisConnector['client'] | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
now?: () => Date;
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
loadTournamentActive?: () => Promise<boolean>;
}): Promise<NeutralAuctionRegistrar> => {
const connector = options.loadNeutralAuctionCounts
? null
: createGamePostgresConnector({ url: options.databaseUrl });
await connector?.connect();
const loadNeutralAuctionCounts =
options.loadNeutralAuctionCounts ??
(() =>
connector!.prisma.$queryRaw<NeutralAuctionCountRow[]>(
GamePrisma.sql`
SELECT type, count(*) AS count
FROM auction
WHERE host_general_id = 0
AND type IN ('BUY_RICE'::"AuctionType", 'SELL_RICE'::"AuctionType")
GROUP BY type
`
));
const handler: TurnCalendarHandler = {
onMonthChanged: async (context) => {
const world = options.getWorld();
if (!world) {
return;
}
const state = world.getState();
const hiddenSeed =
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
? state.meta.hiddenSeed
: state.id;
const eligibleGenerals = world.listGenerals().filter((general) => general.npcState < 2);
const counts = await loadNeutralAuctionCounts();
const countByType = new Map(counts.map((row) => [row.type, Number(row.count)]));
for (const pending of world.peekDirtyState().pendingNeutralAuctions) {
countByType.set(pending.type, (countByType.get(pending.type) ?? 0) + 1);
}
const worldConfig = asRecord(options.getWorldConfig() ?? {});
const consumeTournamentRoll =
worldConfig.tournamentTrig === true &&
!(await (options.loadTournamentActive
? options.loadTournamentActive()
: isTournamentActive(options.profileName, options.getRedisClient())));
const plans = buildNeutralResourceAuctionPlan({
hiddenSeed,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
nationCount: world.listNations().length,
consumeTournamentRoll,
averageGold: average(eligibleGenerals.map((general) => general.gold)),
averageRice: average(eligibleGenerals.map((general) => general.rice)),
buyRiceAuctionCount: countByType.get('BUY_RICE') ?? 0,
sellRiceAuctionCount: countByType.get('SELL_RICE') ?? 0,
});
const registrationKey = `${context.currentYear}-${String(context.currentMonth).padStart(2, '0')}`;
world.updateWorldMeta({ neutralAuctionRegistrationKey: registrationKey });
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
for (const plan of plans) {
const openedAt = options.now?.() ?? new Date();
const hostResourceName = plan.auctionType === 'BUY_RICE' ? '쌀' : '금';
world.queueNeutralAuction({
registrationKey,
type: plan.auctionType,
targetCode: String(plan.amount),
hostGeneralId: 0,
hostName: '상인',
detail: {
title: `${hostResourceName} ${plan.amount} 경매`,
hostName: '상인',
amount: plan.amount,
isReverse: false,
startBidAmount: plan.startBidAmount,
finishBidAmount: plan.finishBidAmount,
neutralRegistrationKey: registrationKey,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
closeTurnCnt: plan.closeTurnCnt,
},
closeAt: new Date(openedAt.getTime() + plan.closeTurnCnt * turnMinutes * 60_000),
});
}
},
};
return {
handler,
close: async () => {
await connector?.disconnect();
},
};
};
+4 -4
View File
@@ -8,14 +8,14 @@ export const composeCalendarHandlers = (
return undefined;
}
return {
onMonthChanged: (context) => {
onMonthChanged: async (context) => {
for (const handler of resolved) {
handler.onMonthChanged?.(context);
await handler.onMonthChanged?.(context);
}
},
onYearChanged: (context) => {
onYearChanged: async (context) => {
for (const handler of resolved) {
handler.onYearChanged?.(context);
await handler.onYearChanged?.(context);
}
},
};
+37
View File
@@ -339,6 +339,7 @@ export const createDatabaseTurnHooks = async (
createdNations,
createdTroops,
createdDiplomacy,
pendingNeutralAuctions,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -349,6 +350,28 @@ export const createDatabaseTurnHooks = async (
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey;
await prisma.$executeRaw`
SELECT pg_advisory_xact_lock(
hashtext(${'neutral-auction-registration'}),
${state.id}
)
`;
const persistedRows = await prisma.$queryRaw<Array<{ meta: unknown }>>`
SELECT meta
FROM world_state
WHERE id = ${state.id}
FOR UPDATE
`;
const persistedMeta = asRecord(persistedRows[0]?.meta);
if (persistedMeta.neutralAuctionRegistrationKey === latestRegistrationKey) {
neutralAuctionsToCreate = [];
}
}
await prisma.worldState.update({
where: { id: state.id },
data: worldStateUpdate,
@@ -425,6 +448,20 @@ export const createDatabaseTurnHooks = async (
);
}
if (neutralAuctionsToCreate.length > 0) {
await prisma.auction.createMany({
data: neutralAuctionsToCreate.map((auction) => ({
type: auction.type,
targetCode: auction.targetCode,
hostGeneralId: auction.hostGeneralId,
hostName: auction.hostName,
detail: asJson(auction.detail),
status: 'OPEN',
closeAt: auction.closeAt,
})),
});
}
const createdIds = new Set(createdGenerals.map((general) => general.id));
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
@@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
partial = true;
break;
}
this.world.advanceMonth(nextTickTime);
await this.world.advanceMonth(nextTickTime);
processedTurns += 1;
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
}
+23 -6
View File
@@ -2,7 +2,7 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop,
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import type { PendingNeutralAuction, TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import {
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
buildDefaultDiplomacy,
@@ -59,8 +59,8 @@ export interface TurnCalendarContext {
export interface TurnCalendarHandler {
// 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트.
onMonthChanged?(context: TurnCalendarContext): void;
onYearChanged?(context: TurnCalendarContext): void;
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
}
export interface InMemoryTurnWorldOptions {
@@ -85,6 +85,7 @@ export interface TurnWorldChanges {
createdNations: Nation[];
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
pendingNeutralAuctions: PendingNeutralAuction[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
@@ -250,6 +251,7 @@ export class InMemoryTurnWorld {
}> = [];
private readonly logs: LogEntryDraft[] = [];
private readonly messages: MessageDraft[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -308,6 +310,14 @@ export class InMemoryTurnWorld {
this.logs.push(entry);
}
queueNeutralAuction(auction: PendingNeutralAuction): void {
this.pendingNeutralAuctions.push({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
});
}
getScenarioConfig(): ScenarioConfig {
return this.scenarioConfig;
}
@@ -681,7 +691,7 @@ export class InMemoryTurnWorld {
return nextTurnAt;
}
advanceMonth(turnTime: Date): void {
async advanceMonth(turnTime: Date): Promise<void> {
const previousYear = this.state.currentYear;
const previousMonth = this.state.currentMonth;
let nextYear = previousYear;
@@ -711,9 +721,9 @@ export class InMemoryTurnWorld {
turnTime,
};
this.advanceDiplomacyMonth();
this.calendarHandler?.onMonthChanged?.(context);
await this.calendarHandler?.onMonthChanged?.(context);
if (nextYear !== previousYear) {
this.calendarHandler?.onYearChanged?.(context);
await this.calendarHandler?.onYearChanged?.(context);
}
}
@@ -751,6 +761,11 @@ export class InMemoryTurnWorld {
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.slice();
const messages = this.messages.slice();
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
}));
return {
generals,
@@ -768,6 +783,7 @@ export class InMemoryTurnWorld {
createdNations,
createdTroops,
createdDiplomacy,
pendingNeutralAuctions,
};
}
@@ -791,6 +807,7 @@ export class InMemoryTurnWorld {
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
}
consumeDirtyState(): TurnWorldChanges {
+10
View File
@@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js';
import { createUnificationHandler } from './unificationHandler.js';
import { createAuctionFinalizer } from '../auction/finalizer.js';
import { createAuctionBidder } from '../auction/bidder.js';
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
@@ -132,6 +133,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
getWorld: () => worldRef,
map: snapshot.map ?? null,
});
const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
databaseUrl: options.databaseUrl,
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
getRedisClient: () => redisConnector?.client,
@@ -148,6 +156,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
nationTurnMonthlyHandler,
incomeHandler,
frontStateHandler,
neutralAuctionRegistrar.handler,
tournamentAutoStartHandler,
yearbookHandler.handler
);
@@ -327,6 +336,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
const baseClose = close;
close = async () => {
await baseClose();
await neutralAuctionRegistrar.close();
if (unification) {
await unification.close();
}
+10
View File
@@ -35,6 +35,16 @@ export interface TurnDiplomacy {
meta: Record<string, unknown>;
}
export interface PendingNeutralAuction {
registrationKey: string;
type: 'BUY_RICE' | 'SELL_RICE';
targetCode: string;
hostGeneralId: 0;
hostName: '상인';
detail: Record<string, unknown>;
closeAt: Date;
}
export interface TurnWorldSnapshot extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
@@ -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
- `registerAuction()` is called from legacy `func_gamerule.php` during the
monthly game-rule pass. Player-hosted auctions are implemented in core2026;
neutral merchant auction generation remains a separate compatibility item.
- `registerAuction()` is called from legacy `func_gamerule.php` after the
logical month changes. Its RNG is seeded before that change with the previous
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.
Core2026 currently applies the legacy constants against `tickSeconds`.
## core2026 compatibility implementation (2026-07-25)
| 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 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 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 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 |
| Neutral merchant registration | legacy monthly probabilistic `registerAuction()` | Not implemented |
| 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 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 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 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 |
| 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
transaction. Point reads use row locks and deductions use conditional updates,
so simultaneous requests cannot spend the same previous-season inheritance
points twice. A failed open command creates neither an auction nor an orphaned
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 * from './actions/index.js';
export * from './auction/alias.js';
export * from './auction/neutral.js';
export * from './constraints/index.js';
export * from './crewType/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,
GamePrisma,
} 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 workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -453,9 +453,9 @@ describe('auction integration flow', () => {
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: poorBidder.accessToken,
});
await expect(
poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })
).rejects.toThrow('금이 부족합니다.');
await expect(poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })).rejects.toThrow(
'금이 부족합니다.'
);
const updatedAuction = await prisma.auction.findUnique({
where: { id: auction.id },
@@ -576,9 +576,9 @@ describe('auction integration flow', () => {
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: ownerBidder.accessToken,
});
await expect(
ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })
).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.');
await expect(ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
'이미 다른 유니크를 가지고 있습니다.'
);
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: validBidder.accessToken,
@@ -782,4 +782,138 @@ describe('auction integration flow', () => {
expect(winner).not.toBeNull();
expect(Object.values(winner!)).toContain(uniquePair.keyA);
}, 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;