feat: port scenario 903 select-pool flow

This commit is contained in:
2026-07-30 23:27:59 +00:00
parent 9bd057456b
commit 115218ded8
80 changed files with 6859 additions and 48 deletions
+2
View File
@@ -12,6 +12,7 @@ export * from './lifecycle/turnDaemonLifecycle.js';
export * from './lifecycle/getNextTickTime.js';
export * from './scenario/scenarioLoader.js';
export * from './scenario/scenarioComposition.js';
export * from './scenario/generalPoolLoader.js';
export * from './scenario/databaseUrl.js';
export * from './scenario/mapLoader.js';
export * from './scenario/scenarioSeeder.js';
@@ -22,6 +23,7 @@ export * from './turn/engineStateManager.js';
export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
export * from './turn/selectPoolService.js';
export * from './turn/turnDaemon.js';
export * from './turn/cli.js';
@@ -17,6 +17,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
private readonly localQueue: TurnDaemonCommand[] = [];
private readonly workerId = randomUUID();
private readonly leaseDurationMs = 60_000;
private readonly maxAttempts = 3;
constructor(private readonly db: GamePrismaClient) {}
@@ -62,6 +63,48 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
await this.complete(requestId, result);
}
async publishCommandError(requestId: string, error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : 'Unknown command error.';
await this.db.$transaction(async (transaction) => {
const event = await transaction.inputEvent.findUnique({
where: { requestId },
select: {
status: true,
target: true,
lockedBy: true,
attempts: true,
},
});
if (
!event ||
event.target !== 'ENGINE' ||
event.status !== 'PROCESSING' ||
event.lockedBy !== this.workerId
) {
return;
}
const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
attempts: event.attempts,
},
data: {
status: terminal ? 'FAILED' : 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
completedAt: terminal ? new Date() : null,
result: GamePrisma.DbNull,
error: message,
},
});
});
}
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => {
@@ -132,8 +175,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async complete(requestId: string, result: unknown): Promise<void> {
await this.db.inputEvent.update({
where: { requestId },
const completed = await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
},
data: {
status: 'SUCCEEDED',
result: asJson(result),
@@ -143,6 +191,24 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
leaseUntil: null,
},
});
if (completed.count > 0) {
return;
}
// Database hooks commit mutation results atomically with game state and
// may already have set SUCCEEDED. Only the worker that still owns the
// lease may clear that committed row's claim metadata.
await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'SUCCEEDED',
lockedBy: this.workerId,
},
data: {
lockedBy: null,
leaseUntil: null,
},
});
}
private async recoverExpiredLeases(): Promise<void> {
@@ -310,6 +310,17 @@ export class TurnDaemonLifecycle {
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.';
if (command.requestId && this.commandResponder?.publishCommandError) {
try {
await this.commandResponder.publishCommandError(command.requestId, error);
} catch (reportError) {
const reportMessage =
reportError instanceof Error
? reportError.message
: 'Unknown command failure reporting error.';
this.status.lastError = `${this.status.lastError} (failure report: ${reportMessage})`;
}
}
await this.hooks?.onRunError?.(error);
return;
}
+1
View File
@@ -33,6 +33,7 @@ export interface TurnDaemonCommandExecutionContext {
export interface TurnDaemonCommandResponder {
publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void>;
publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void>;
publishCommandError?(requestId: string, error: unknown): Promise<void>;
}
export type { Clock } from '@sammo-ts/common';
@@ -0,0 +1,93 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { isRecord } from '@sammo-ts/common';
import { isEventDomesticTraitKey } from '@sammo-ts/logic';
import { resolveWorkspaceRoot } from '../paths.js';
const DEFAULT_GENERAL_POOL_ROOT = path.resolve(resolveWorkspaceRoot(), 'resources', 'general-pool');
const SUPPORTED_POOL = 'SPoolUnderU30';
const EXPECTED_COLUMNS = [
'generalName',
'leadership',
'strength',
'intel',
'specialDomestic',
'dex',
'imgsvr',
'picture',
] as const;
export interface GeneralPoolSeedEntry {
uniqueName: string;
info: Record<string, unknown>;
}
export interface GeneralPoolLoaderOptions {
generalPoolRoot?: string;
}
const readPoolResource = async (filePath: string): Promise<unknown> =>
JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
const normalizePoolRow = (row: unknown, index: number): GeneralPoolSeedEntry => {
if (!Array.isArray(row) || row.length !== EXPECTED_COLUMNS.length) {
throw new Error(`General pool row ${index} does not match the expected ${EXPECTED_COLUMNS.length} columns.`);
}
const info = Object.fromEntries(EXPECTED_COLUMNS.map((column, columnIndex) => [column, row[columnIndex]]));
const uniqueName = info.generalName;
if (typeof uniqueName !== 'string' || uniqueName.length === 0) {
throw new Error(`General pool row ${index} has no generalName.`);
}
if (uniqueName.length > 20) {
throw new Error(`General pool row ${index} has a generalName longer than the select_pool key.`);
}
if (
!Number.isInteger(info.leadership) ||
!Number.isInteger(info.strength) ||
!Number.isInteger(info.intel) ||
typeof info.specialDomestic !== 'string' ||
!isEventDomesticTraitKey(info.specialDomestic) ||
!Array.isArray(info.dex) ||
info.dex.length !== 5 ||
info.dex.some((value) => typeof value !== 'number' || !Number.isInteger(value) || value < 0) ||
info.dex.reduce((sum, value) => sum + Number(value), 0) <= 0 ||
(info.imgsvr !== 0 && info.imgsvr !== 1) ||
typeof info.picture !== 'string'
) {
throw new Error(`General pool row ${index} contains invalid candidate data.`);
}
return {
uniqueName,
info: {
...info,
uniqueName,
},
};
};
export const loadGeneralPoolEntries = async (
poolName: string,
options?: GeneralPoolLoaderOptions
): Promise<GeneralPoolSeedEntry[]> => {
if (poolName !== SUPPORTED_POOL) {
throw new Error(`Unsupported general pool: ${poolName}.`);
}
const root = path.resolve(options?.generalPoolRoot ?? DEFAULT_GENERAL_POOL_ROOT);
const raw = await readPoolResource(path.resolve(root, `${poolName}.json`));
if (!isRecord(raw) || !Array.isArray(raw.columns) || !Array.isArray(raw.data)) {
throw new Error(`General pool ${poolName} is not a valid resource.`);
}
if (
raw.columns.length !== EXPECTED_COLUMNS.length ||
raw.columns.some((column, index) => column !== EXPECTED_COLUMNS[index])
) {
throw new Error(`General pool ${poolName} has an unexpected column contract.`);
}
const entries = raw.data.map(normalizePoolRow);
if (new Set(entries.map((entry) => entry.uniqueName)).size !== entries.length) {
throw new Error(`General pool ${poolName} contains duplicate unique names.`);
}
return entries;
};
+33 -6
View File
@@ -1,3 +1,5 @@
import { randomBytes } from 'node:crypto';
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import {
@@ -14,6 +16,8 @@ import type { ScenarioLoaderOptions } from './scenarioLoader.js';
import { loadScenarioDefinitionById } from './scenarioLoader.js';
import type { UnitSetLoaderOptions } from './unitSetLoader.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
import type { GeneralPoolLoaderOptions } from './generalPoolLoader.js';
import { loadGeneralPoolEntries } from './generalPoolLoader.js';
import { applyInitialChangeCityEvents } from '../turn/monthlyChangeCityAction.js';
const DEFAULT_TICK_SECONDS = 120 * 60;
@@ -51,6 +55,7 @@ export interface ScenarioSeedOptions {
scenarioOptions?: ScenarioLoaderOptions;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean;
now?: Date;
tickSeconds?: number;
@@ -209,6 +214,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const scenarioDefinition = includeExtendedGeneral ? scenario : { ...scenario, generalsEx: [] };
const map = await loadMapDefinitionByName(scenario.config.environment.mapName, options.mapOptions);
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions);
const targetGeneralPool =
typeof scenario.config.map.targetGeneralPool === 'string' ? scenario.config.map.targetGeneralPool : null;
const generalPoolEntries = targetGeneralPool
? await loadGeneralPoolEntries(targetGeneralPool, options.generalPoolOptions)
: [];
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
@@ -271,10 +281,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
worldMeta.serverId = install.serverId.trim();
}
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV];
if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) {
worldMeta.hiddenSeed = integrationSeed.trim();
}
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim();
worldMeta.hiddenSeed =
integrationSeed && integrationSeed.length > 0
? integrationSeed
: randomBytes(16).toString('hex');
if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -286,6 +297,8 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
options: install.autorunUser.options,
};
}
const archivedWorldMeta = { ...worldMeta };
delete archivedWorldMeta.hiddenSeed;
await connector.connect();
try {
@@ -297,6 +310,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
if (eventTableReady) {
await prisma.event.deleteMany();
}
await prisma.selectPoolEntry.deleteMany();
await prisma.generalTurn.deleteMany();
await prisma.generalTurnRevision.deleteMany();
await prisma.rankData.deleteMany();
await prisma.generalAccessLog.deleteMany();
await prisma.diplomacy.deleteMany();
await prisma.general.deleteMany();
await prisma.troop.deleteMany();
@@ -316,6 +334,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
},
});
if (generalPoolEntries.length > 0) {
await prisma.selectPoolEntry.createMany({
data: generalPoolEntries.map((entry) => ({
uniqueName: entry.uniqueName,
info: asJson(entry.info),
})),
});
}
if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) {
await prisma.gameHistory.upsert({
where: { serverId: worldMeta.serverId },
@@ -332,7 +359,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
scenarioName: String(seed.scenarioMeta?.title ?? ''),
env: asJson({
config: scenarioConfig,
meta: worldMeta,
meta: archivedWorldMeta,
}),
},
update: {
@@ -347,7 +374,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
scenarioName: String(seed.scenarioMeta?.title ?? ''),
env: asJson({
config: scenarioConfig,
meta: worldMeta,
meta: archivedWorldMeta,
}),
},
});
@@ -243,6 +243,28 @@ const zPatchGeneral = z.object({
}),
});
const zSelectPoolCreate = z
.object({
type: z.literal('selectPoolCreate'),
requestId: z.string().optional(),
userId: z.string().min(1),
ownerDisplayName: z.string().min(1),
uniqueName: z.string().min(1).max(20),
personality: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
})
.strict();
const zSelectPoolReselect = z
.object({
type: z.literal('selectPoolReselect'),
requestId: z.string().optional(),
userId: z.string().min(1),
ownerDisplayName: z.string().min(1),
uniqueName: z.string().min(1).max(20),
})
.strict();
const zGetStatus = z.object({
type: z.literal('getStatus'),
requestId: z.string().optional(),
@@ -484,6 +506,22 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => {
const command = parseWith(zSelectPoolCreate, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeSelectPoolReselect: CommandNormalizer<'selectPoolReselect'> = (envelope) => {
const command = parseWith(zSelectPoolReselect, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeGetStatus: CommandNormalizer<'getStatus'> = (envelope) => {
const command = parseWith(zGetStatus, envelope.command);
if (!command) {
@@ -547,6 +585,8 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral,
selectPoolCreate: normalizeSelectPoolCreate,
selectPoolReselect: normalizeSelectPoolReselect,
getStatus: normalizeGetStatus,
run: normalizeRun,
pause: normalizePause,
+15
View File
@@ -351,6 +351,8 @@ const buildGeneralUpdate = (
bornYear: general.bornYear,
deadYear: general.deadYear,
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
startAge: general.startAge ?? general.age,
npcState: general.npcState,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
@@ -369,6 +371,7 @@ const buildGeneralCreate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): TurnEngineGeneralCreateManyInput => ({
id: general.id,
userId: general.userId ?? null,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
@@ -392,6 +395,8 @@ const buildGeneralCreate = (
bornYear: general.bornYear,
deadYear: general.deadYear,
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
startAge: general.startAge ?? general.age,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
bookCode: toCode(general.role.items.book),
@@ -799,6 +804,14 @@ export const createDatabaseTurnHooks = async (
}
if (deletedGenerals.length > 0) {
await prisma.selectPoolEntry.updateMany({
where: { generalId: { in: deletedGenerals } },
data: {
generalId: null,
ownerUserId: null,
reservedUntil: null,
},
});
if (prisma.generalTurnRevision) {
await prisma.generalTurnRevision.deleteMany({
where: { generalId: { in: deletedGenerals } },
@@ -969,6 +982,8 @@ export const createDatabaseTurnHooks = async (
result: asJson(commandCompletion.result),
completedAt: new Date(),
error: null,
lockedBy: null,
leaseUntil: null,
},
});
}
+3 -4
View File
@@ -881,10 +881,9 @@ export class InMemoryTurnWorld {
getNextGeneralId(): number {
const meta = this.state.meta as Record<string, unknown>;
let lastId = (meta.lastGeneralId as number | undefined) ?? 0;
if (lastId === 0) {
const currentIds = Array.from(this.generals.keys());
lastId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
}
const currentIds = Array.from(this.generals.keys());
const currentMaxId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
lastId = Math.max(lastId, currentMaxId);
const nextId = lastId + 1;
this.state = {
@@ -0,0 +1,889 @@
import { z } from 'zod';
import {
asNumber,
asRecord,
JosaUtil,
LiteHashDRBG,
RandUtil,
} from '@sammo-ts/common';
import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra';
import {
EventDomesticTraitLoader,
isEventDomesticTraitKey,
isPersonalityTraitKey,
PERSONALITY_TRAIT_KEYS,
simpleSerialize,
} from '@sammo-ts/logic';
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
export type SelectPoolErrorCode =
| 'BAD_REQUEST'
| 'PRECONDITION_FAILED'
| 'CONFLICT'
| 'INTERNAL_SERVER_ERROR';
export class SelectPoolError extends Error {
constructor(
readonly code: SelectPoolErrorCode,
message: string
) {
super(message);
this.name = 'SelectPoolError';
}
}
const SUPPORTED_POOL = 'SPoolUnderU30';
const RESERVATION_COUNT = 14;
const RESERVATION_TURN_MULTIPLIER = 2;
const RESELECTION_TURN_MULTIPLIER = 12;
const DEFAULT_MAX_GENERAL = 500;
const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식';
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
const zCandidateInfo = z.object({
uniqueName: z.string().min(1),
generalName: z.string().min(1),
leadership: z.number().int(),
strength: z.number().int(),
intel: z.number().int(),
specialDomestic: z.string().min(1),
specialWar: z.string().min(1).optional(),
ego: z.string().min(1).optional(),
experience: z.number().int().optional(),
dedication: z.number().int().optional(),
dex: z.tuple([z.number(), z.number(), z.number(), z.number(), z.number()]),
imgsvr: z.union([z.literal(0), z.literal(1)]),
picture: z.string(),
});
export type SelectPoolCandidateInfo = z.infer<typeof zCandidateInfo>;
interface SelectPoolRow {
id: number;
uniqueName: string;
ownerUserId: string | null;
generalId: number | null;
reservedUntil: Date | null;
info: unknown;
}
export interface SelectPoolCandidateDto {
uniqueName: string;
generalName: string;
leadership: number;
strength: number;
intel: number;
specialDomestic: string;
specialDomesticName: string;
specialDomesticInfo: string;
specialWar: string | null;
ego: string | null;
dex: [number, number, number, number, number];
imageServer: 0 | 1;
picture: string;
}
export interface SelectPoolReservationDto {
poolName: typeof SUPPORTED_POOL;
hasGeneral: boolean;
validUntil: string;
candidates: SelectPoolCandidateDto[];
}
const fail = (
code: SelectPoolErrorCode,
message: string
): never => {
throw new SelectPoolError(code, message);
};
const resolvePoolName = (worldState: WorldStateRow): string | null => {
const config = asRecord(worldState.config);
const map = asRecord(config.map);
return typeof map.targetGeneralPool === 'string' ? map.targetGeneralPool : null;
};
const resolvePoolAllowOptions = (worldState: WorldStateRow): string[] => {
const map = asRecord(asRecord(worldState.config).map);
return Array.isArray(map.generalPoolAllowOption)
? map.generalPoolAllowOption.filter((value): value is string => typeof value === 'string')
: [];
};
const resolveTurnTermMinutes = (worldState: WorldStateRow): number => {
const config = asRecord(worldState.config);
const configured = asNumber(config.turnTermMinutes, Math.round(worldState.tickSeconds / 60));
return Math.max(1, Math.abs(Math.trunc(configured)));
};
export const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
const config = asRecord(worldState.config);
return asNumber(config.npcMode, 0) === 2 && resolvePoolName(worldState) === SUPPORTED_POOL;
};
export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => {
const config = asRecord(worldState.config);
const configConst = asRecord(config.const);
return Math.max(
0,
Math.floor(
asNumber(
config.maxGeneral ??
configConst.defaultMaxGeneral ??
configConst.maxGeneral,
DEFAULT_MAX_GENERAL
)
)
);
};
const requirePoolWorld = (worldState: WorldStateRow): void => {
if (!isSelectionPoolWorld(worldState)) {
fail('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
}
};
const parseCandidate = (row: Pick<SelectPoolRow, 'uniqueName' | 'info'>): SelectPoolCandidateInfo => {
const info = zCandidateInfo.safeParse(row.info);
if (!info.success || !info.data) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
`장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}`
);
}
const candidate = info.data;
if (candidate.uniqueName !== row.uniqueName) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
`장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}`
);
}
return candidate;
};
const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
candidate.dex.reduce((sum, value) => sum + value, 0);
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
const toCandidateDto = async (
candidate: SelectPoolCandidateInfo
): Promise<SelectPoolCandidateDto> => {
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
: null;
return {
uniqueName: candidate.uniqueName,
generalName: candidate.generalName,
leadership: candidate.leadership,
strength: candidate.strength,
intel: candidate.intel,
specialDomestic: candidate.specialDomestic,
specialDomesticName:
trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
specialDomesticInfo: trait?.info ?? '',
specialWar: candidate.specialWar ?? null,
ego: candidate.ego ?? null,
dex: candidate.dex,
imageServer: candidate.imgsvr,
picture: candidate.picture,
};
};
const toReservationDto = (
rows: Array<Pick<SelectPoolRow, 'id' | 'uniqueName' | 'reservedUntil' | 'info'>>,
hasGeneral: boolean
): Promise<SelectPoolReservationDto> => {
const validUntil = rows[0]?.reservedUntil;
if (!validUntil) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
'장수 선택 후보의 유효기간이 없습니다.'
);
}
const expiresAt = validUntil;
const sorted = rows
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
.sort(
(left, right) =>
candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id
);
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
poolName: SUPPORTED_POOL,
hasGeneral,
validUntil: expiresAt.toISOString(),
candidates,
}));
};
const formatLegacySeedTime = (value: Date): string => {
const pad = (part: number): string => String(part).padStart(2, '0');
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
export const buildSelectPoolSeed = (
hiddenSeed: string | number,
ownerIdentity: string | number,
now: Date
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
weighted: [T, number][];
rng: RandUtil;
count: number;
claim(candidate: T): Promise<boolean>;
onDraw?(candidate: T): void;
maxAttempts?: number;
}): Promise<T[]> => {
const claimed: T[] = [];
const claimedIds = new Set<number>();
const maxAttempts = options.maxAttempts ?? Math.max(options.weighted.length * 8, 1000);
let attempts = 0;
while (claimed.length < options.count && attempts < maxAttempts) {
attempts += 1;
const candidate = options.rng.choiceUsingWeightPair(options.weighted);
options.onDraw?.(candidate);
if (claimedIds.has(candidate.id) || !(await options.claim(candidate))) {
continue;
}
claimedIds.add(candidate.id);
claimed.push(candidate);
}
return claimed;
};
const readNextChangeAt = (generalMeta: unknown): Date | null => {
const meta = asRecord(generalMeta);
const raw = meta.next_change ?? meta.nextChangeAt;
if (typeof raw !== 'string') {
return null;
}
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta);
const value = meta.hiddenSeed ?? meta.seed;
return typeof value === 'string' || typeof value === 'number'
? value
: fail('INTERNAL_SERVER_ERROR', '장수 선택 비밀 seed가 설정되지 않았습니다.');
};
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`select_pool:${userId}`}, 903))`
);
};
const requireSelectionToken = async (
db: DatabaseClient,
userId: string,
uniqueName: string,
now: Date
): Promise<SelectPoolRow> => {
const token = await db.selectPoolEntry.findFirst({
where: {
ownerUserId: userId,
uniqueName,
reservedUntil: { gte: now },
generalId: null,
},
});
if (!token) {
fail('PRECONDITION_FAILED', '유효한 장수 목록이 없습니다.');
}
return token as SelectPoolRow;
};
export const reserveSelectionPool = async (options: {
db: DatabaseClient;
worldState: WorldStateRow;
userId: string;
now?: Date;
seedOwnerIdentity?: string | number;
}): Promise<SelectPoolReservationDto> => {
const { db, worldState, userId } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
await lockSelectionUser(db, userId);
const general = await db.general.findFirst({
where: { userId },
select: { id: true, meta: true },
});
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const existing = await db.selectPoolEntry.findMany({
where: {
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
orderBy: { id: 'asc' },
});
if (existing.length > 0) {
return toReservationDto(existing as SelectPoolRow[], Boolean(general));
}
await db.selectPoolEntry.updateMany({
where: {
reservedUntil: { lt: now },
generalId: null,
},
data: {
ownerUserId: null,
reservedUntil: null,
},
});
const available = (await db.selectPoolEntry.findMany({
where: {
ownerUserId: null,
reservedUntil: null,
generalId: null,
},
orderBy: { id: 'asc' },
})) as SelectPoolRow[];
if (available.length < RESERVATION_COUNT) {
fail('PRECONDITION_FAILED', 'pool 부족');
}
const rng = new RandUtil(
new LiteHashDRBG(
buildSelectPoolSeed(
getWorldHiddenSeed(worldState),
options.seedOwnerIdentity ?? userId,
now
)
)
);
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
const reservedUntil = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESERVATION_TURN_MULTIPLIER * 60_000
);
const selected = await claimWeightedSelectionCandidates({
weighted,
rng,
count: RESERVATION_COUNT,
claim: async (candidate) => {
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: candidate.id,
ownerUserId: null,
reservedUntil: null,
generalId: null,
},
data: {
ownerUserId: userId,
reservedUntil,
},
});
return claimed.count > 0;
},
});
const reserved = selected.map((candidate) => ({
...candidate,
ownerUserId: userId,
reservedUntil,
}));
if (reserved.length !== RESERVATION_COUNT) {
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
}
return toReservationDto(reserved, Boolean(general));
};
const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> => {
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`);
};
const assertGeneralIdSnapshotMatches = async (
db: DatabaseClient,
world: InMemoryTurnWorld
): Promise<void> => {
const persistedIds = (
await db.general.findMany({
select: { id: true },
orderBy: { id: 'asc' },
})
).map(({ id }) => id);
const runtimeIds = world
.listGenerals()
.map(({ id }) => id)
.sort((left, right) => left - right);
if (
persistedIds.length !== runtimeIds.length ||
persistedIds.some((id, index) => id !== runtimeIds[index])
) {
throw new Error(
'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'
);
}
};
const clearUnusedReservations = async (db: DatabaseClient, userId: string, now: Date): Promise<void> => {
await db.selectPoolEntry.updateMany({
where: {
generalId: null,
OR: [{ ownerUserId: userId }, { reservedUntil: { lt: now } }],
},
data: {
ownerUserId: null,
reservedUntil: null,
},
});
};
const resolveSpecialityAges = (worldState: WorldStateRow, age: number): { domestic: number; war: number } => {
const configConst = asRecord(asRecord(worldState.config).const);
const retirementYear = asNumber(configConst.retirementYear, 80);
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear);
const relativeYear = Math.max(worldState.currentYear - startYear, 0);
const build = (divisor: number): number =>
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
return { domestic: build(12), war: build(6) };
};
const resolveRandomPersonality = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string
): string =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(
getWorldHiddenSeed(worldState),
'selectPickedGeneralPersonality',
ownerIdentity,
uniqueName
)
)
).choice([...PERSONALITY_TRAIT_KEYS]);
const resolveSelectedPersonality = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string,
requested: string
): string => {
if (!resolvePoolAllowOptions(worldState).includes('ego')) {
return 'None';
}
if (requested === 'Random') {
return resolveRandomPersonality(worldState, ownerIdentity, uniqueName);
}
if (!isPersonalityTraitKey(requested)) {
fail('BAD_REQUEST', '올바르지 않은 성격입니다.');
}
return requested;
};
const resolvePoolRng = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string
): RandUtil =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(
getWorldHiddenSeed(worldState),
'selectPickedGeneral',
ownerIdentity,
uniqueName
)
)
);
const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => {
const raw = asRecord(worldState.meta).turntime;
if (typeof raw === 'string') {
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return now;
};
const buildInitialTurnTime = (rng: RandUtil, worldState: WorldStateRow, now: Date): Date => {
const termSeconds = resolveTurnTermMinutes(worldState) * 60;
const seconds = rng.nextRangeInt(0, termSeconds - 1);
const microseconds = rng.nextRangeInt(0, 999_999);
return new Date(resolveTurnTimeBase(worldState, now).getTime() + seconds * 1000 + microseconds / 1000);
};
const appendSelectionLogs = async (options: {
db: DatabaseClient;
worldState: WorldStateRow;
generalId: number;
ownerUserId: string;
generalText: string;
globalText: string;
}): Promise<void> => {
const common = {
year: options.worldState.currentYear,
month: options.worldState.currentMonth,
nationId: null,
userId: null,
meta: { ownerUserId: options.ownerUserId },
};
await options.db.logEntry.createMany({
data: [
{
...common,
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: options.generalId,
text: options.generalText,
},
{
...common,
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
generalId: null,
text: options.globalText,
},
],
});
};
export const createGeneralFromSelectionPool = async (options: {
db: DatabaseClient;
world: InMemoryTurnWorld;
worldState: WorldStateRow;
userId: string;
ownerDisplayName: string;
uniqueName: string;
personality: string;
now?: Date;
seedOwnerIdentity?: string | number;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await assertGeneralIdSnapshotMatches(db, world);
if (
world.listGenerals().some((general) => general.userId === userId) ||
(await db.general.findFirst({ where: { userId }, select: { id: true } }))
) {
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
}
const token = await requireSelectionToken(db, userId, uniqueName, now);
const info = parseCandidate(token);
const config = asRecord(worldState.config);
const configConst = asRecord(config.const);
const maxGeneral = resolveSelectionMaxGeneral(worldState);
const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } });
if (activeCount >= maxGeneral) {
fail('PRECONDITION_FAILED', '더 이상 등록 할 수 없습니다.');
}
const seedOwnerIdentity = options.seedOwnerIdentity ?? userId;
const rng = resolvePoolRng(worldState, seedOwnerIdentity, uniqueName);
const affinity = rng.nextRangeInt(1, 150);
const cities = await db.city.findMany({ select: { id: true, name: true }, orderBy: { id: 'asc' } });
if (cities.length === 0) {
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
}
const city = rng.choice(cities);
const turnTime = buildInitialTurnTime(rng, worldState, now);
const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const showImgLevel = asNumber(config.showImgLevel, 0);
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const personality = resolveSelectedPersonality(
worldState,
seedOwnerIdentity,
uniqueName,
options.personality
);
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
const generalId = world.getNextGeneralId();
const general: TurnGeneral = {
id: generalId,
userId,
name: info.generalName,
nationId: 0,
cityId: city.id,
troopId: 0,
npcState: 0,
affinity,
bornYear: worldState.currentYear - age,
deadYear: worldState.currentYear + 60,
picture,
imageServer: info.imgsvr,
stats: {
leadership: info.leadership,
strength: info.strength,
intelligence: info.intel,
},
experience: info.experience ?? age * 100,
dedication: info.dedication ?? age * 100,
officerLevel: 0,
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: DEFAULT_CREW_TYPE_ID,
train: 0,
atmos: 0,
turnTime,
age,
startAge: age,
role: {
personality,
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? defaultSpecialWar,
items: {
horse: null,
weapon: null,
book: null,
item: null,
},
},
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: {},
refreshScoreTotal: 0,
meta: {
createdBy: 'select_pool',
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
killturn: 5,
specage: specialityAges.domestic,
specage2: specialityAges.war,
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
npc_org: 0,
},
};
if (!world.addGeneral(general)) {
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
}
await db.generalTurn.createMany({
data: Array.from({ length: MAX_GENERAL_TURNS }, (_, turnIdx) => ({
generalId,
turnIdx,
actionCode: DEFAULT_TURN_ACTION,
arg: {},
})),
});
await db.generalTurnRevision.create({
data: {
generalId,
revision: 0,
},
});
const occupied = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
data: {
generalId,
ownerUserId: null,
reservedUntil: null,
},
});
if (occupied.count === 0) {
throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.');
}
await db.generalAccessLog.upsert({
where: { generalId },
update: { userId, lastRefresh: now },
create: { generalId, userId, lastRefresh: now },
});
await clearUnusedReservations(db, userId, now);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
await appendSelectionLogs({
db,
worldState,
generalId,
ownerUserId: userId,
generalText: `<Y>${info.generalName}</>, <G>${city.name}</>에서 등장`,
globalText: `<G><b>${city.name}</b></>에서 <Y>${ownerDisplayName}</>${ownerJosaYi} <Y>${info.generalName}</>${generalJosaRo} 등장합니다.`,
});
return { ok: true, generalId };
};
export const reselectGeneralFromSelectionPool = async (options: {
db: DatabaseClient;
world: InMemoryTurnWorld;
worldState: WorldStateRow;
userId: string;
ownerDisplayName: string;
uniqueName: string;
now?: Date;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
const persistedGeneral = await db.general.findFirst({ where: { userId } });
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
if (!persistedGeneral || !general) {
throw new SelectPoolError(
'PRECONDITION_FAILED',
'장수가 생성하지 않았습니다. 이미 사망하지 않았는지 확인해보세요.'
);
}
if (persistedGeneral.id !== general.id) {
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
}
const nextChangeAt = readNextChangeAt(general.meta);
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const token = await requireSelectionToken(db, userId, uniqueName, now);
const info = parseCandidate(token);
const provisionalGeneralId = -general.id;
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
data: {
generalId: provisionalGeneralId,
ownerUserId: null,
reservedUntil: null,
},
});
if (claimed.count === 0) {
throw new Error('장수 재선택 중 선택 후보 점유에 실패했습니다.');
}
await db.selectPoolEntry.updateMany({
where: { generalId: general.id },
data: { generalId: null, ownerUserId: null, reservedUntil: null },
});
const finalized = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
generalId: provisionalGeneralId,
},
data: {
generalId: general.id,
},
});
if (finalized.count === 0) {
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
}
const currentMeta = asRecord(general.meta);
const cooldown = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const updatedMeta = {
...currentMeta,
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
next_change: cooldown.toISOString(),
nextChangeAt: cooldown.toISOString(),
};
const updated = world.updateGeneral(general.id, {
name: info.generalName,
stats: {
leadership: info.leadership,
strength: info.strength,
intelligence: info.intel,
},
role: {
...general.role,
personality: info.ego ?? general.role.personality,
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? general.role.specialWar,
},
picture: info.picture,
imageServer: info.imgsvr,
meta: updatedMeta as unknown as TurnGeneral['meta'],
});
if (!updated) {
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
}
await clearUnusedReservations(db, userId, now);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
await appendSelectionLogs({
db,
worldState,
generalId: general.id,
ownerUserId: userId,
generalText: `장수를 <Y>${general.name}</>에서 <Y>${info.generalName}</>${generalJosaRo} 변경`,
globalText: `<Y>${ownerDisplayName}</>${ownerJosaYi} 장수를 <Y>${general.name}</>에서 <Y>${info.generalName}</>${generalJosaRo} 변경합니다.`,
});
return { ok: true, generalId: general.id };
};
export const getSelectionPoolStatus = async (
db: DatabaseClient,
worldState: WorldStateRow,
userId: string
): Promise<{
enabled: boolean;
poolName: string | null;
allowOptions: string[];
hasGeneral: boolean;
nextChangeAt: string | null;
}> => {
const poolName = resolvePoolName(worldState);
const enabled = isSelectionPoolWorld(worldState);
const general = await db.general.findFirst({ where: { userId }, select: { meta: true } });
return {
enabled,
poolName,
allowOptions: resolvePoolAllowOptions(worldState),
hasGeneral: Boolean(general),
nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null,
};
};
+1
View File
@@ -27,6 +27,7 @@ export interface TurnGeneral extends General {
deadYear?: number;
affinity?: number | null;
picture?: string | null;
imageServer?: number;
turnTime: Date;
recentWarTime?: Date | null;
lastTurn?: GeneralLastTurn;
+118 -1
View File
@@ -4,7 +4,7 @@ import type {
TurnDaemonCommandExecutionContext,
TurnDaemonCommandResult,
} from '../lifecycle/types.js';
import { GamePrisma } from '@sammo-ts/infra';
import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
LogCategory,
@@ -40,6 +40,11 @@ import {
IMMEDIATE_TROOP_JOIN_MOVE_HANDLER,
LEGACY_TROOP_JOIN_EVENT,
} from './scenarioStaticEvents.js';
import {
createGeneralFromSelectionPool,
reselectGeneralFromSelectionPool,
SelectPoolError,
} from './selectPoolService.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -117,6 +122,108 @@ interface CommandHandlerContext {
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
if (!ctx.commandDb) {
throw new Error('ENGINE mutation transaction is required for selection-pool commands.');
}
return ctx.commandDb as unknown as DatabaseClient;
};
const resolveCommandAcceptedAt = async (
db: DatabaseClient,
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' | 'selectPoolReselect' }>
): Promise<Date> => {
if (!command.requestId) {
throw new Error(`${command.type} requestId is required.`);
}
const event = await db.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { createdAt: true },
});
if (!event) {
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
}
return event.createdAt;
};
async function handleSelectPoolCreate(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const worldState = await db.worldState.findUnique({
where: { id: ctx.world.getState().id },
});
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
try {
return {
type: 'selectPoolCreate',
...(await createGeneralFromSelectionPool({
db,
world: ctx.world,
worldState,
userId: command.userId,
ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName,
personality: command.personality,
seedOwnerIdentity: command.seedOwnerIdentity,
now: acceptedAt,
})),
};
} catch (error) {
if (error instanceof SelectPoolError) {
return {
type: 'selectPoolCreate',
ok: false,
code: error.code,
reason: error.message,
};
}
throw error;
}
}
async function handleSelectPoolReselect(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const worldState = await db.worldState.findUnique({
where: { id: ctx.world.getState().id },
});
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
try {
return {
type: 'selectPoolReselect',
...(await reselectGeneralFromSelectionPool({
db,
world: ctx.world,
worldState,
userId: command.userId,
ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName,
now: acceptedAt,
})),
};
} catch (error) {
if (error instanceof SelectPoolError) {
return {
type: 'selectPoolReselect',
ok: false,
code: error.code,
reason: error.message,
};
}
throw error;
}
}
interface AuctionFinalizer {
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
}
@@ -1845,6 +1952,16 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
selectPoolCreate: (command) =>
handleSelectPoolCreate(
ctx,
command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
),
selectPoolReselect: (command) =>
handleSelectPoolReselect(
ctx,
command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
),
shiftSchedule: (command) =>
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
};
+1
View File
@@ -237,6 +237,7 @@ const mapGeneralRow = (
deadYear: row.deadYear,
affinity: row.affinity,
picture: row.picture,
imageServer: row.imageServer,
triggerState: {
flags: {},
counters: {},