feat: port scenario 903 select-pool flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }>),
|
||||
};
|
||||
|
||||
@@ -237,6 +237,7 @@ const mapGeneralRow = (
|
||||
deadYear: row.deadYear,
|
||||
affinity: row.affinity,
|
||||
picture: row.picture,
|
||||
imageServer: row.imageServer,
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
|
||||
@@ -93,4 +93,48 @@ integration('database command queue', () => {
|
||||
lockedBy: 'active-worker',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries an owned command twice and then records a terminal failure', async () => {
|
||||
const requestId = 'integration:engine:bounded-failure';
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'vacation',
|
||||
payload: {
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
generalId: 10,
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
const stale = new DatabaseTurnDaemonCommandQueue(db);
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
await expect(owner.drain()).resolves.toEqual([
|
||||
{ type: 'vacation', requestId, generalId: 10 },
|
||||
]);
|
||||
await stale.publishCommandError(requestId, new Error('stale worker failure'));
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
|
||||
).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
attempts: attempt,
|
||||
});
|
||||
|
||||
await owner.publishCommandError(requestId, new Error('injected command failure'));
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
|
||||
).resolves.toMatchObject({
|
||||
status: attempt < 3 ? 'PENDING' : 'FAILED',
|
||||
attempts: attempt,
|
||||
error: 'injected command failure',
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(owner.drain()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
|
||||
|
||||
describe('SPoolUnderU30 resource', () => {
|
||||
it('preserves the Ref UnderS30 row contract and ordering', async () => {
|
||||
const entries = await loadGeneralPoolEntries('SPoolUnderU30');
|
||||
const weights = entries.map((entry) => {
|
||||
const dex = entry.info.dex as number[];
|
||||
return dex.reduce((sum, value) => sum + value, 0);
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(1844);
|
||||
expect(new Set(entries.map((entry) => entry.uniqueName)).size).toBe(1844);
|
||||
expect(Math.min(...weights)).toBe(100122);
|
||||
expect(Math.max(...weights)).toBe(2582699);
|
||||
const traitFrequencies = entries.reduce<Record<string, number>>((result, entry) => {
|
||||
const key = String(entry.info.specialDomestic);
|
||||
result[key] = (result[key] ?? 0) + 1;
|
||||
return result;
|
||||
}, {});
|
||||
expect(traitFrequencies).toEqual({
|
||||
che_event_격노: 152,
|
||||
che_event_견고: 91,
|
||||
che_event_공성: 8,
|
||||
che_event_궁병: 12,
|
||||
che_event_귀병: 38,
|
||||
che_event_기병: 12,
|
||||
che_event_돌격: 98,
|
||||
che_event_무쌍: 100,
|
||||
che_event_반계: 81,
|
||||
che_event_보병: 10,
|
||||
che_event_신산: 99,
|
||||
che_event_신중: 106,
|
||||
che_event_위압: 85,
|
||||
che_event_의술: 37,
|
||||
che_event_저격: 251,
|
||||
che_event_집중: 125,
|
||||
che_event_징병: 169,
|
||||
che_event_척사: 166,
|
||||
che_event_필살: 156,
|
||||
che_event_환술: 48,
|
||||
});
|
||||
expect(entries[0]).toEqual({
|
||||
uniqueName: '⑨탈곡기',
|
||||
info: {
|
||||
generalName: '⑨탈곡기',
|
||||
leadership: 69,
|
||||
strength: 12,
|
||||
intel: 80,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [12066, 27302, 29463, 307356, 16448],
|
||||
imgsvr: 1,
|
||||
picture: '9ed8be6.gif?=20190417',
|
||||
uniqueName: '⑨탈곡기',
|
||||
},
|
||||
});
|
||||
expect(entries.at(-1)?.uniqueName).toBe('④야부키 나코');
|
||||
});
|
||||
|
||||
it('rejects an unsupported pool instead of silently substituting data', async () => {
|
||||
await expect(loadGeneralPoolEntries('SPoolUnknown')).rejects.toThrow('Unsupported general pool');
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,7 @@ describe('input event atomicity', () => {
|
||||
resolveError = resolve;
|
||||
});
|
||||
const publishCommandResult = vi.fn(async () => {});
|
||||
const publishCommandError = vi.fn(async () => {});
|
||||
let engineState = { value: 'before' };
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('test', {
|
||||
@@ -287,6 +288,7 @@ describe('input event atomicity', () => {
|
||||
commandResponder: {
|
||||
publishStatus: async () => {},
|
||||
publishCommandResult,
|
||||
publishCommandError,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -305,6 +307,10 @@ describe('input event atomicity', () => {
|
||||
lastError: 'injected commit failure',
|
||||
});
|
||||
expect(publishCommandResult).not.toHaveBeenCalled();
|
||||
expect(publishCommandError).toHaveBeenCalledWith(
|
||||
'event-2',
|
||||
expect.objectContaining({ message: 'injected commit failure' })
|
||||
);
|
||||
expect(engineState).toEqual({ value: 'before' });
|
||||
expect(stateManager.getRevision()).toBe(0);
|
||||
|
||||
@@ -320,6 +326,7 @@ describe('input event atomicity', () => {
|
||||
});
|
||||
const commitCommand = vi.fn(async () => {});
|
||||
const publishCommandResult = vi.fn(async () => {});
|
||||
const publishCommandError = vi.fn(async () => {});
|
||||
let engineState = { value: 'before' };
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('test', {
|
||||
@@ -351,6 +358,7 @@ describe('input event atomicity', () => {
|
||||
commandResponder: {
|
||||
publishStatus: async () => {},
|
||||
publishCommandResult,
|
||||
publishCommandError,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -370,6 +378,10 @@ describe('input event atomicity', () => {
|
||||
});
|
||||
expect(commitCommand).not.toHaveBeenCalled();
|
||||
expect(publishCommandResult).not.toHaveBeenCalled();
|
||||
expect(publishCommandError).toHaveBeenCalledWith(
|
||||
'event-3',
|
||||
expect.objectContaining({ message: 'injected handler failure' })
|
||||
);
|
||||
expect(engineState).toEqual({ value: 'before' });
|
||||
expect(stateManager.getRevision()).toBe(0);
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ type ScenarioSeederPrismaClient = {
|
||||
count(): Promise<number>;
|
||||
findFirst(): Promise<{ age: number; startAge: number; meta: unknown } | null>;
|
||||
};
|
||||
selectPoolEntry: {
|
||||
count(): Promise<number>;
|
||||
findFirst(args: {
|
||||
orderBy: { id: 'asc' | 'desc' };
|
||||
}): Promise<{ uniqueName: string; info: unknown } | null>;
|
||||
};
|
||||
diplomacy: {
|
||||
count(): Promise<number>;
|
||||
findFirst(args: {
|
||||
@@ -48,6 +54,10 @@ type ScenarioSeederPrismaClient = {
|
||||
currentMonth: number;
|
||||
} | null>;
|
||||
};
|
||||
gameHistory: {
|
||||
findUnique(args: { where: { serverId: string } }): Promise<{ env: unknown } | null>;
|
||||
deleteMany(args: { where: { serverId: string } }): Promise<{ count: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event'];
|
||||
@@ -254,6 +264,167 @@ describeDb('scenario database seed', () => {
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('seeds the exact scenario 903 UnderS30 selection pool', async () => {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl,
|
||||
});
|
||||
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
|
||||
expect(await prisma.selectPoolEntry.count()).toBe(1844);
|
||||
expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'asc' } })).toMatchObject({
|
||||
uniqueName: '⑨탈곡기',
|
||||
info: {
|
||||
generalName: '⑨탈곡기',
|
||||
specialDomestic: 'che_event_징병',
|
||||
},
|
||||
});
|
||||
expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'desc' } })).toMatchObject({
|
||||
uniqueName: '④야부키 나코',
|
||||
});
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('clears general lifecycle rows before reusing general ids on reseed', async () => {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl,
|
||||
});
|
||||
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
const prisma = connector.prisma;
|
||||
const generalId = 990_903;
|
||||
const createSelectedGeneral = async (): Promise<void> => {
|
||||
await prisma.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId: 'scenario-reseed-user',
|
||||
name: '재설치선택장수',
|
||||
turnTime: new Date('2026-07-30T12:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
};
|
||||
const createLifecycleRows = async (): Promise<void> => {
|
||||
await prisma.generalTurn.create({
|
||||
data: {
|
||||
generalId,
|
||||
turnIdx: 0,
|
||||
actionCode: '휴식',
|
||||
},
|
||||
});
|
||||
await prisma.generalTurnRevision.create({
|
||||
data: {
|
||||
generalId,
|
||||
revision: 7,
|
||||
},
|
||||
});
|
||||
await prisma.rankData.create({
|
||||
data: {
|
||||
nationId: 0,
|
||||
generalId,
|
||||
type: 'experience',
|
||||
value: 123,
|
||||
},
|
||||
});
|
||||
await prisma.generalAccessLog.create({
|
||||
data: {
|
||||
generalId,
|
||||
userId: 'scenario-reseed-user',
|
||||
},
|
||||
});
|
||||
};
|
||||
const expectLifecycleRows = async (count: number): Promise<void> => {
|
||||
await expect(
|
||||
Promise.all([
|
||||
prisma.generalTurn.count({ where: { generalId } }),
|
||||
prisma.generalTurnRevision.count({ where: { generalId } }),
|
||||
prisma.rankData.count({ where: { generalId } }),
|
||||
prisma.generalAccessLog.count({ where: { generalId } }),
|
||||
])
|
||||
).resolves.toEqual([count, count, count, count]);
|
||||
};
|
||||
|
||||
await createSelectedGeneral();
|
||||
await createLifecycleRows();
|
||||
await expectLifecycleRows(1);
|
||||
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl,
|
||||
});
|
||||
await expectLifecycleRows(0);
|
||||
await expect(prisma.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
|
||||
|
||||
await createSelectedGeneral();
|
||||
await createLifecycleRows();
|
||||
await expectLifecycleRows(1);
|
||||
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl,
|
||||
});
|
||||
await expectLifecycleRows(0);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('persists a private hidden seed without copying it into game history', async () => {
|
||||
const envName = 'INTEGRATION_WORLD_SEED';
|
||||
const originalSeed = process.env[envName];
|
||||
const serverId = 'scenario-seeder-hidden-seed-test';
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
try {
|
||||
process.env[envName] = 'scenario-seeder-explicit-hidden-seed';
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl,
|
||||
installOptions: { serverId },
|
||||
});
|
||||
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
|
||||
const explicitWorld = await prisma.worldState.findFirst();
|
||||
expect(explicitWorld?.meta).toMatchObject({
|
||||
hiddenSeed: 'scenario-seeder-explicit-hidden-seed',
|
||||
});
|
||||
const explicitHistory = await prisma.gameHistory.findUnique({ where: { serverId } });
|
||||
expect((explicitHistory?.env as { meta?: Record<string, unknown> })?.meta).not.toHaveProperty(
|
||||
'hiddenSeed'
|
||||
);
|
||||
|
||||
delete process.env[envName];
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl,
|
||||
installOptions: { serverId },
|
||||
});
|
||||
const randomWorld = await prisma.worldState.findFirst();
|
||||
const randomSeed = (randomWorld?.meta as Record<string, unknown>)?.hiddenSeed;
|
||||
expect(randomSeed).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(randomSeed).not.toBe('scenario-seeder-explicit-hidden-seed');
|
||||
const randomHistory = await prisma.gameHistory.findUnique({ where: { serverId } });
|
||||
expect((randomHistory?.env as { meta?: Record<string, unknown> })?.meta).not.toHaveProperty(
|
||||
'hiddenSeed'
|
||||
);
|
||||
await prisma.gameHistory.deleteMany({ where: { serverId } });
|
||||
} finally {
|
||||
if (originalSeed === undefined) {
|
||||
delete process.env[envName];
|
||||
} else {
|
||||
process.env[envName] = originalSeed;
|
||||
}
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracked scenario composition', () => {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
|
||||
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 990_904;
|
||||
const cityId = 990_904;
|
||||
const scenarioCode = 'select-pool-release-integration';
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||
if (!schema?.endsWith('select_pool_integration')) {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
};
|
||||
|
||||
integration('select pool release during general deletion', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedDatabase(databaseUrl!);
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
|
||||
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
|
||||
await db.selectPoolEntry.deleteMany({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode,
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 300,
|
||||
config: {
|
||||
npcMode: 2,
|
||||
turnTermMinutes: 5,
|
||||
stat: {
|
||||
total: 165,
|
||||
min: 15,
|
||||
max: 80,
|
||||
npcTotal: 165,
|
||||
npcMax: 80,
|
||||
npcMin: 15,
|
||||
chiefMin: 40,
|
||||
},
|
||||
iconPath: '.',
|
||||
map: {
|
||||
targetGeneralPool: 'SPoolUnderU30',
|
||||
generalPoolAllowOption: ['ego'],
|
||||
},
|
||||
const: {},
|
||||
environment: {
|
||||
mapName: 'che',
|
||||
unitSet: 'che',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
hiddenSeed: 'select-pool-release-seed',
|
||||
killturn: 5,
|
||||
turntime: '2026-07-30T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.city.create({
|
||||
data: {
|
||||
id: cityId,
|
||||
name: '해제성',
|
||||
level: 5,
|
||||
nationId: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
region: 1,
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId: 'select-pool-release-user',
|
||||
name: '해제대상',
|
||||
nationId: 0,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: 1,
|
||||
bornYear: 160,
|
||||
deadYear: 240,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
leadership: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
officerLevel: 0,
|
||||
turnTime: new Date('2026-07-30T12:01:00.000Z'),
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'che_안전',
|
||||
specialCode: 'che_event_신산',
|
||||
special2Code: 'che_무쌍',
|
||||
meta: {
|
||||
killturn: 5,
|
||||
dex1: 100_000,
|
||||
dex2: 100_000,
|
||||
dex3: 100_000,
|
||||
dex4: 100_000,
|
||||
dex5: 100_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.selectPoolEntry.create({
|
||||
data: {
|
||||
uniqueName: 'release-candidate',
|
||||
ownerUserId: null,
|
||||
generalId,
|
||||
reservedUntil: null,
|
||||
info: {
|
||||
uniqueName: 'release-candidate',
|
||||
generalName: '해제대상',
|
||||
leadership: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
specialDomestic: 'che_event_신산',
|
||||
dex: [100_000, 100_000, 100_000, 100_000, 100_000],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!db) {
|
||||
await closeDb?.();
|
||||
return;
|
||||
}
|
||||
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
|
||||
await db.selectPoolEntry.deleteMany({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('round-trips independent event-domestic and war trait slots through a dirty flush', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const before = world.getGeneralById(generalId);
|
||||
expect(before?.role).toMatchObject({
|
||||
specialDomestic: 'che_event_신산',
|
||||
specialWar: 'che_무쌍',
|
||||
});
|
||||
expect(world.updateGeneral(generalId, { gold: (before?.gold ?? 0) + 1 })).not.toBeNull();
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: generalId } })).resolves.toMatchObject({
|
||||
specialCode: 'che_event_신산',
|
||||
special2Code: 'che_무쌍',
|
||||
});
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(
|
||||
reloaded.snapshot.generals.find((general) => general.id === generalId)?.role
|
||||
).toMatchObject({
|
||||
specialDomestic: 'che_event_신산',
|
||||
specialWar: 'che_무쌍',
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back a failed flush, then releases all Ref fields before deleting the general', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
expect(world.removeGeneral(generalId)).toBe(true);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE "select_pool_delete_blocker" (
|
||||
"general_id" INTEGER PRIMARY KEY
|
||||
REFERENCES "general"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await db.$executeRawUnsafe(
|
||||
`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`
|
||||
);
|
||||
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.not.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
generalId,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
});
|
||||
|
||||
await db.$executeRawUnsafe('DROP TABLE "select_pool_delete_blocker"');
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
generalId: null,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
});
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
lastTurnTime: baseTime,
|
||||
meta: {},
|
||||
meta: { lastGeneralId: 1 },
|
||||
};
|
||||
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
@@ -157,5 +157,8 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
});
|
||||
|
||||
expect(executed).toEqual([2, 3, 1]);
|
||||
expect(world.getNextGeneralId()).toBe(4);
|
||||
expect(world.getNextGeneralId()).toBe(5);
|
||||
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user