feat: make general creation daemon-atomic
This commit is contained in:
@@ -23,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/joinCreateGeneralService.js';
|
||||
export * from './turn/selectPoolService.js';
|
||||
export * from './turn/turnDaemon.js';
|
||||
export * from './turn/cli.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
buildScenarioBootstrap,
|
||||
resolveScenarioGeneralDeathMonth,
|
||||
@@ -233,7 +233,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
const now = options.now ?? new Date();
|
||||
const tickSeconds =
|
||||
install?.turnTermMinutes !== undefined ? install.turnTermMinutes * 60 : options.tickSeconds ?? DEFAULT_TICK_SECONDS;
|
||||
install?.turnTermMinutes !== undefined
|
||||
? install.turnTermMinutes * 60
|
||||
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||
const sync = install?.sync ?? false;
|
||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||
@@ -241,10 +243,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||
|
||||
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
||||
if (
|
||||
typeof scenarioConst.openingPartYear !== 'number' ||
|
||||
Number.isNaN(scenarioConst.openingPartYear)
|
||||
) {
|
||||
if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) {
|
||||
scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR;
|
||||
}
|
||||
const scenarioConfig = {
|
||||
@@ -268,6 +267,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const worldMeta: Record<string, unknown> = {
|
||||
scenarioId: options.scenarioId,
|
||||
scenarioMeta: seed.scenarioMeta,
|
||||
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
|
||||
starttime: formatDateTime(startState.startTime),
|
||||
turntime: formatDateTime(now),
|
||||
opentime: formatDateTime(now),
|
||||
@@ -283,9 +283,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
|
||||
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim();
|
||||
worldMeta.hiddenSeed =
|
||||
integrationSeed && integrationSeed.length > 0
|
||||
? integrationSeed
|
||||
: randomBytes(16).toString('hex');
|
||||
integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex');
|
||||
|
||||
if (install?.preopenAt) {
|
||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||
@@ -484,8 +482,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
: resolveScenarioGeneralDeathMonth({
|
||||
scenarioTitle: String(seed.scenarioMeta?.title ?? ''),
|
||||
startYear: seed.scenarioMeta?.startYear ?? null,
|
||||
contextLabel:
|
||||
typeof meta.source === 'string' ? meta.source : 'general',
|
||||
contextLabel: typeof meta.source === 'string' ? meta.source : 'general',
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
deathYear: general.deathYear,
|
||||
|
||||
@@ -243,6 +243,31 @@ const zPatchGeneral = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const zJoinCreateGeneral = z
|
||||
.object({
|
||||
type: z.literal('joinCreateGeneral'),
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
ownerDisplayName: z.string(),
|
||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||
name: z.string().min(1).max(18),
|
||||
leadership: zFiniteNumber,
|
||||
strength: zFiniteNumber,
|
||||
intel: zFiniteNumber,
|
||||
pic: z.boolean(),
|
||||
character: z.string().min(1),
|
||||
profileId: z.string().min(1),
|
||||
ownerPicture: z.string().optional(),
|
||||
ownerImageServer: zFiniteNumber.optional(),
|
||||
ownerCanUsePicture: z.boolean().optional(),
|
||||
ownerLegacyPenalty: zRecord.optional(),
|
||||
inheritSpecial: z.string().optional(),
|
||||
inheritTurntimeZone: zFiniteNumber.optional(),
|
||||
inheritCity: zFiniteNumber.optional(),
|
||||
inheritBonusStat: z.tuple([zFiniteNumber, zFiniteNumber, zFiniteNumber]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zSelectPoolCreate = z
|
||||
.object({
|
||||
type: z.literal('selectPoolCreate'),
|
||||
@@ -295,7 +320,12 @@ const zShutdown = z.object({
|
||||
const zShiftSchedule = z.object({
|
||||
type: z.literal('shiftSchedule'),
|
||||
actionId: z.string().uuid(),
|
||||
deltaMinutes: z.number().int().min(-1440).max(1440).refine((value) => value !== 0),
|
||||
deltaMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(-1440)
|
||||
.max(1440)
|
||||
.refine((value) => value !== 0),
|
||||
});
|
||||
|
||||
const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope) => {
|
||||
@@ -506,6 +536,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => {
|
||||
const command = parseWith(zJoinCreateGeneral, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => {
|
||||
const command = parseWith(zSelectPoolCreate, envelope.command);
|
||||
if (!command) {
|
||||
@@ -585,6 +623,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
patchGeneral: normalizePatchGeneral,
|
||||
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||
selectPoolCreate: normalizeSelectPoolCreate,
|
||||
selectPoolReselect: normalizeSelectPoolReselect,
|
||||
getStatus: normalizeGetStatus,
|
||||
|
||||
@@ -302,7 +302,13 @@ const buildPersistedGeneralMeta = (
|
||||
const buildInitialRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
||||
buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
|
||||
buildPersistedRankRows(general).map((row) => ({
|
||||
...row,
|
||||
nationId: 0,
|
||||
// Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한
|
||||
// 유산 포인트만 inherit_spent_dyn에 반영한다.
|
||||
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
|
||||
}));
|
||||
|
||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||
|
||||
@@ -362,6 +368,7 @@ const buildGeneralUpdate = (
|
||||
specialCode: toCode(general.role.specialDomestic),
|
||||
special2Code: toCode(general.role.specialWar),
|
||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||
penalty: asJson(general.penalty ?? {}),
|
||||
meta: buildPersistedGeneralMeta(general),
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
@@ -405,6 +412,7 @@ const buildGeneralCreate = (
|
||||
specialCode: toCode(general.role.specialDomestic),
|
||||
special2Code: toCode(general.role.specialWar),
|
||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||
penalty: asJson(general.penalty ?? {}),
|
||||
meta: buildPersistedGeneralMeta(general),
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
||||
import type {
|
||||
City,
|
||||
LogEntryDraft,
|
||||
MessageDraft,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
Troop,
|
||||
TurnSchedule,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
@@ -338,12 +347,14 @@ export class InMemoryTurnWorld {
|
||||
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
|
||||
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private readonly unitSet?: UnitSetDefinition;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
private state: TurnWorldState;
|
||||
|
||||
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
|
||||
this.state = { ...state };
|
||||
this.scenarioConfig = snapshot.scenarioConfig;
|
||||
this.unitSet = snapshot.unitSet;
|
||||
this.schedule = options.schedule;
|
||||
this.generalTurnHandler =
|
||||
options.generalTurnHandler ??
|
||||
@@ -547,6 +558,10 @@ export class InMemoryTurnWorld {
|
||||
return this.scenarioConfig;
|
||||
}
|
||||
|
||||
getUnitSet(): UnitSetDefinition | undefined {
|
||||
return this.unitSet;
|
||||
}
|
||||
|
||||
getGeneralById(id: number): TurnGeneral | null {
|
||||
return this.generals.get(id) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ActionLogger,
|
||||
buildAuctionAlias,
|
||||
buildAuctionAliasPool,
|
||||
getLegacyStringWidth,
|
||||
isPersonalityTraitKey,
|
||||
isWarTraitKey,
|
||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||
loadWarTraitModules,
|
||||
LogFormat,
|
||||
normalizeTroopName,
|
||||
simpleSerialize,
|
||||
TraitSelector,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { WarTraitKey } 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 JoinCreateGeneralErrorCode =
|
||||
'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
|
||||
export class JoinCreateGeneralError extends Error {
|
||||
constructor(
|
||||
readonly code: JoinCreateGeneralErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'JoinCreateGeneralError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface JoinCreateGeneralInput {
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
seedOwnerIdentity: string | number;
|
||||
name: string;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
pic: boolean;
|
||||
character: string;
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
inheritTurntimeZone?: number;
|
||||
inheritCity?: number;
|
||||
inheritBonusStat?: [number, number, number];
|
||||
}
|
||||
|
||||
interface InheritConstants {
|
||||
inheritBornSpecialPoint: number;
|
||||
inheritBornTurntimePoint: number;
|
||||
inheritBornCityPoint: number;
|
||||
inheritBornStatPoint: number;
|
||||
}
|
||||
|
||||
const DEFAULT_JOIN_STAT = {
|
||||
total: 165,
|
||||
min: 15,
|
||||
max: 80,
|
||||
};
|
||||
const DEFAULT_MAX_GENERAL = 500;
|
||||
const DEFAULT_MAX_GENIUS = 5;
|
||||
const DEFAULT_GENERAL_GOLD = 1000;
|
||||
const DEFAULT_GENERAL_RICE = 1000;
|
||||
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 LEGACY_JOIN_REMOVED_CHARACTERS = /[ⓝⓜⓖⓞⓧ㉥\\/`#|-]/gu;
|
||||
|
||||
const fail = (code: JoinCreateGeneralErrorCode, message: string): never => {
|
||||
throw new JoinCreateGeneralError(code, message);
|
||||
};
|
||||
|
||||
const normalizeJoinName = (value: string): string =>
|
||||
normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, '');
|
||||
|
||||
const resolveLegacyPenalty = (
|
||||
rawPenalty: Record<string, unknown> | undefined,
|
||||
profileId: string,
|
||||
acceptedAt: Date
|
||||
): Record<string, unknown> => {
|
||||
if (!rawPenalty) {
|
||||
return {};
|
||||
}
|
||||
const merged = {
|
||||
...asRecord(rawPenalty.any),
|
||||
...asRecord(rawPenalty[profileId]),
|
||||
};
|
||||
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, rawEntry] of Object.entries(merged)) {
|
||||
const entry = asRecord(rawEntry);
|
||||
if (asNumber(entry.expire, 0) > acceptedAtSeconds && Object.hasOwn(entry, 'value')) {
|
||||
result[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveStatRule = (worldState: WorldStateRow) => {
|
||||
const stat = asRecord(asRecord(worldState.config).stat);
|
||||
return {
|
||||
total: asNumber(stat.total, DEFAULT_JOIN_STAT.total),
|
||||
min: asNumber(stat.min, DEFAULT_JOIN_STAT.min),
|
||||
max: asNumber(stat.max, DEFAULT_JOIN_STAT.max),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants => {
|
||||
const configConst = asRecord(asRecord(worldState.config).const);
|
||||
return {
|
||||
inheritBornSpecialPoint: asNumber(configConst.inheritBornSpecialPoint, 6000),
|
||||
inheritBornTurntimePoint: asNumber(configConst.inheritBornTurntimePoint, 2500),
|
||||
inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, 1000),
|
||||
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, 1000),
|
||||
};
|
||||
};
|
||||
|
||||
const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
|
||||
const config = asRecord(worldState.config);
|
||||
const map = asRecord(config.map);
|
||||
return asNumber(config.npcMode, 0) === 2 && map.targetGeneralPool === 'SPoolUnderU30';
|
||||
};
|
||||
|
||||
const resolveMaxGeneral = (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 readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const value = meta.hiddenSeed ?? meta.seed;
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return fail('INTERNAL_SERVER_ERROR', '장수 생성 비밀 seed가 설정되지 않았습니다.');
|
||||
};
|
||||
|
||||
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 buildJoinCreateGeneralSeed = (
|
||||
hiddenSeed: string | number,
|
||||
ownerIdentity: string | number,
|
||||
acceptedAt: Date
|
||||
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, formatLegacySeedTime(acceptedAt));
|
||||
|
||||
const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`);
|
||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" 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 applyInheritanceUser = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<number> => {
|
||||
const rows = await db.$queryRaw<Array<{ key: string; value: number }>>(
|
||||
GamePrisma.sql`
|
||||
SELECT key, value
|
||||
FROM inheritance_point
|
||||
WHERE user_id = ${userId}
|
||||
ORDER BY id ASC
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (rows.length === 1 && rows[0]?.key === 'previous') {
|
||||
return rows[0].value;
|
||||
}
|
||||
|
||||
const previousPoint = rows.find((row) => row.key === 'previous')?.value ?? 0;
|
||||
const totalPoint = Math.trunc(rows.reduce((sum, row) => sum + row.value, 0));
|
||||
for (const row of rows) {
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
year,
|
||||
month,
|
||||
text: `${row.key} 포인트 ${row.value} 증가`,
|
||||
},
|
||||
});
|
||||
}
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
year,
|
||||
month,
|
||||
text: `포인트 ${previousPoint} => ${totalPoint}`,
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
key: { not: 'previous' },
|
||||
},
|
||||
});
|
||||
await setInheritancePoint(db, userId, totalPoint);
|
||||
return totalPoint;
|
||||
};
|
||||
|
||||
const setInheritancePoint = async (db: DatabaseClient, userId: string, value: number): Promise<void> => {
|
||||
await db.inheritancePoint.upsert({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
update: { value },
|
||||
create: { userId, key: 'previous', value },
|
||||
});
|
||||
};
|
||||
|
||||
const appendInheritanceLog = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
year: number,
|
||||
month: number,
|
||||
text: string
|
||||
): Promise<void> => {
|
||||
await db.inheritanceLog.create({
|
||||
data: { userId, year, month, text },
|
||||
});
|
||||
};
|
||||
|
||||
const calculateInheritanceCost = (
|
||||
input: JoinCreateGeneralInput,
|
||||
constants: InheritConstants,
|
||||
inheritBonus: [number, number, number] | null
|
||||
): number => {
|
||||
let required = 0;
|
||||
if (input.inheritCity !== undefined) {
|
||||
required += constants.inheritBornCityPoint;
|
||||
}
|
||||
if (inheritBonus) {
|
||||
required += constants.inheritBornStatPoint;
|
||||
}
|
||||
if (input.inheritSpecial !== undefined) {
|
||||
required += constants.inheritBornSpecialPoint;
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
required += constants.inheritBornTurntimePoint;
|
||||
}
|
||||
return required;
|
||||
};
|
||||
|
||||
const validateAndNormalizeBonus = (bonus: [number, number, number] | undefined): [number, number, number] | null => {
|
||||
if (!bonus) {
|
||||
return null;
|
||||
}
|
||||
if (bonus.some((value) => !Number.isInteger(value) || value < 0)) {
|
||||
fail('BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 가입해주세요!');
|
||||
}
|
||||
const sum = bonus.reduce((acc, value) => acc + value, 0);
|
||||
if (sum === 0) {
|
||||
return null;
|
||||
}
|
||||
if (sum < 3 || sum > 5) {
|
||||
fail('BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!');
|
||||
}
|
||||
return bonus;
|
||||
};
|
||||
|
||||
const buildRandomBonus = (rng: RandUtil, baseStats: [number, number, number]): [number, number, number] => {
|
||||
const result: [number, number, number] = [0, 0, 0];
|
||||
const count = rng.nextRangeInt(3, 5);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const selected = Number(
|
||||
rng.choiceUsingWeight({
|
||||
0: baseStats[0],
|
||||
1: baseStats[1],
|
||||
2: baseStats[2],
|
||||
})
|
||||
);
|
||||
result[selected as 0 | 1 | 2] += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveRelativeYear = (worldState: WorldStateRow): number => {
|
||||
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
|
||||
const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear);
|
||||
return Math.max(worldState.currentYear - startYear, 0);
|
||||
};
|
||||
|
||||
const resolveSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||
|
||||
export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||
const baseTime =
|
||||
Date.UTC(koreaTime.getUTCFullYear(), koreaTime.getUTCMonth(), koreaTime.getUTCDate() - 1, 1) -
|
||||
LEGACY_TIMEZONE_OFFSET_MS;
|
||||
const elapsedSeconds = Math.floor((value.getTime() - baseTime) / 1000);
|
||||
const alignedSeconds = elapsedSeconds - (elapsedSeconds % tickSeconds);
|
||||
return new Date(baseTime + alignedSeconds * 1000);
|
||||
};
|
||||
|
||||
const resolveTurnTime = (
|
||||
rng: RandUtil,
|
||||
worldState: WorldStateRow,
|
||||
acceptedAt: Date,
|
||||
runtimeTurnTime: Date,
|
||||
inheritTurntimeZone: number | undefined
|
||||
): Date => {
|
||||
const tickSeconds = Math.max(1, Math.floor(worldState.tickSeconds));
|
||||
const base = runtimeTurnTime;
|
||||
let offsetSeconds: number;
|
||||
let offsetMicros: number;
|
||||
let turnTimeBase: Date;
|
||||
if (inheritTurntimeZone !== undefined) {
|
||||
const legacyTurnTermMinutes = Math.max(1, Math.floor(tickSeconds / 60));
|
||||
turnTimeBase = cutJoinTurnTime(base, tickSeconds);
|
||||
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||
} else {
|
||||
turnTimeBase = base;
|
||||
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||
}
|
||||
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
|
||||
if (turnTime.getTime() <= acceptedAt.getTime()) {
|
||||
turnTime = new Date(turnTime.getTime() + tickSeconds * 1000);
|
||||
}
|
||||
return turnTime;
|
||||
};
|
||||
|
||||
const resolveCatchupExperience = async (db: DatabaseClient, relativeYear: number): Promise<number> => {
|
||||
if (relativeYear < 3) {
|
||||
return 0;
|
||||
}
|
||||
const count = await db.general.count({
|
||||
where: { nationId: { not: 0 }, npcState: { lt: 4 } },
|
||||
});
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
}
|
||||
const order = Math.max(1, Math.round(count * 0.2));
|
||||
const row = await db.general.findFirst({
|
||||
where: { nationId: { not: 0 }, npcState: { lt: 4 } },
|
||||
orderBy: [{ experience: 'asc' }, { id: 'asc' }],
|
||||
skip: order - 1,
|
||||
select: { experience: true },
|
||||
});
|
||||
return Math.round((row?.experience ?? 0) * 0.8);
|
||||
};
|
||||
|
||||
const resolveRestInheritanceBonus = async (
|
||||
db: DatabaseClient,
|
||||
worldState: WorldStateRow,
|
||||
userId: string
|
||||
): Promise<number> => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
|
||||
const season = Math.floor(asNumber(meta.season, 0));
|
||||
if (!serverId || season < 1) {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
(await db.inheritanceResult.count({
|
||||
where: { serverId, owner: userId },
|
||||
})) > 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const oldGames = await db.gameHistory.findMany({
|
||||
where: {
|
||||
winnerNation: { not: null },
|
||||
season: { gte: 1 },
|
||||
serverId: { not: serverId },
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
take: 8,
|
||||
select: { serverId: true },
|
||||
});
|
||||
if (oldGames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const oldServerIds = oldGames.map(({ serverId: oldServerId }) => oldServerId);
|
||||
const playedRows = await db.oldGeneral.findMany({
|
||||
where: {
|
||||
owner: userId,
|
||||
serverId: { in: oldServerIds },
|
||||
},
|
||||
distinct: ['serverId'],
|
||||
select: { serverId: true },
|
||||
});
|
||||
const played = new Set(playedRows.map(({ serverId: oldServerId }) => oldServerId));
|
||||
let missedGames = 0;
|
||||
for (const { serverId: oldServerId } of oldGames) {
|
||||
if (played.has(oldServerId)) {
|
||||
break;
|
||||
}
|
||||
missedGames += 1;
|
||||
}
|
||||
return missedGames * 500;
|
||||
};
|
||||
|
||||
const pushCreationLogs = (
|
||||
world: InMemoryTurnWorld,
|
||||
options: {
|
||||
generalId: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
cityName: string;
|
||||
bonus: [number, number, number];
|
||||
age: number;
|
||||
genius: boolean;
|
||||
specialWarName: string;
|
||||
}
|
||||
): void => {
|
||||
const logger = new ActionLogger({ generalId: options.generalId });
|
||||
const josaRa = JosaUtil.pick(options.name, '라');
|
||||
if (options.genius) {
|
||||
logger.pushGlobalActionLog(
|
||||
`<G><b>${options.cityName}</b></>에서 <Y>${options.name}</>${josaRa}는 기재가 천하에 이름을 알립니다.`
|
||||
);
|
||||
logger.pushGlobalActionLog(
|
||||
`<C>${options.specialWarName}</> 특기를 가진 <C>천재</>의 등장으로 온 천하가 떠들썩합니다.`
|
||||
);
|
||||
logger.pushGlobalHistoryLog(`<L><b>【천재】</b></><G><b>${options.cityName}</b></>에 천재가 등장했습니다.`);
|
||||
} else {
|
||||
logger.pushGlobalActionLog(
|
||||
`<G><b>${options.cityName}</b></>에서 <Y>${options.name}</>${josaRa}는 호걸이 천하에 이름을 알립니다.`
|
||||
);
|
||||
}
|
||||
logger.pushGeneralHistoryLog(`<Y>${options.name}</>, <G>${options.cityName}</>에서 큰 뜻을 품다.`);
|
||||
logger.pushGeneralActionLog(
|
||||
[
|
||||
'삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^',
|
||||
'처음 하시는 경우에는 <D>도움말</>을 참고하시고,',
|
||||
'문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~',
|
||||
'부디 즐거운 삼모전 되시길 바랍니다 ^^',
|
||||
`통솔 <C>${options.bonus[0]}</> 무력 <C>${options.bonus[1]}</> 지력 <C>${options.bonus[2]}</> 의 보너스를 받으셨습니다.`,
|
||||
`연령은 <C>${options.age}</>세로 시작합니다.`,
|
||||
],
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
if (options.genius) {
|
||||
logger.pushGeneralActionLog(
|
||||
`축하합니다! 천재로 태어나 처음부터 <C>${options.specialWarName}</> 특기를 가지게 됩니다!`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
logger.pushGeneralHistoryLog(`<C>${options.specialWarName}</> 특기를 가진 천재로 탄생.`);
|
||||
}
|
||||
for (const entry of logger.flush()) {
|
||||
world.pushLog({
|
||||
...entry,
|
||||
meta: {
|
||||
...(entry.meta ?? {}),
|
||||
ownerUserId: options.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createGeneralFromJoin = async (options: {
|
||||
db: DatabaseClient;
|
||||
world: InMemoryTurnWorld;
|
||||
worldState: WorldStateRow;
|
||||
input: JoinCreateGeneralInput;
|
||||
acceptedAt: Date;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, input, acceptedAt } = options;
|
||||
await lockJoinMutation(db, input.userId);
|
||||
await assertGeneralIdSnapshotMatches(db, world);
|
||||
|
||||
if (isSelectionPoolWorld(worldState)) {
|
||||
fail('PRECONDITION_FAILED', '장수 선택 목록에서 장수를 골라 주세요.');
|
||||
}
|
||||
const config = asRecord(worldState.config);
|
||||
const configConst = asRecord(config.const);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
if ((blockGeneralCreate & 1) !== 0) {
|
||||
fail('FORBIDDEN', '장수 직접 생성이 불가능한 모드입니다.');
|
||||
}
|
||||
|
||||
if (
|
||||
world.listGenerals().some((general) => general.userId === input.userId) ||
|
||||
(await db.general.findFirst({
|
||||
where: { userId: input.userId },
|
||||
select: { id: true },
|
||||
}))
|
||||
) {
|
||||
fail('PRECONDITION_FAILED', '이미 등록하셨습니다!');
|
||||
}
|
||||
|
||||
const normalizedName = normalizeJoinName(input.name);
|
||||
if (!normalizedName || getLegacyStringWidth(normalizedName) < 1) {
|
||||
fail('BAD_REQUEST', '이름이 짧습니다. 다시 가입해주세요!');
|
||||
}
|
||||
if (getLegacyStringWidth(normalizedName) > 18) {
|
||||
fail('BAD_REQUEST', '이름이 유효하지 않습니다. 다시 가입해주세요!');
|
||||
}
|
||||
if (
|
||||
world.listGenerals().some((general) => general.name === normalizedName) ||
|
||||
(await db.general.findFirst({
|
||||
where: { name: normalizedName },
|
||||
select: { id: true },
|
||||
}))
|
||||
) {
|
||||
fail('CONFLICT', '이미 있는 장수입니다. 다른 이름으로 등록해 주세요!');
|
||||
}
|
||||
|
||||
const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } });
|
||||
if (activeCount >= resolveMaxGeneral(worldState)) {
|
||||
fail('PRECONDITION_FAILED', '더이상 등록할 수 없습니다!');
|
||||
}
|
||||
|
||||
const statRule = resolveStatRule(worldState);
|
||||
const stats = [input.leadership, input.strength, input.intel] as const;
|
||||
if (stats.some((value) => !Number.isInteger(value) || value < statRule.min || value > statRule.max)) {
|
||||
fail('BAD_REQUEST', '능력치 범위를 벗어났습니다.');
|
||||
}
|
||||
if (stats.reduce((sum, value) => sum + value, 0) > statRule.total) {
|
||||
fail('BAD_REQUEST', `능력치가 ${statRule.total}을 넘어섰습니다. 다시 가입해주세요!`);
|
||||
}
|
||||
|
||||
if (
|
||||
input.character !== 'Random' &&
|
||||
(!isPersonalityTraitKey(input.character) || !JOIN_PERSONALITY_TRAIT_KEYS.some((key) => key === input.character))
|
||||
) {
|
||||
fail('BAD_REQUEST', '성격이 잘못 지정되었습니다.');
|
||||
}
|
||||
const availableSpecialWar = Array.isArray(configConst.availableSpecialWar)
|
||||
? configConst.availableSpecialWar.filter(
|
||||
(value): value is WarTraitKey => typeof value === 'string' && isWarTraitKey(value)
|
||||
)
|
||||
: [...WAR_TRAIT_KEYS];
|
||||
if (
|
||||
input.inheritSpecial !== undefined &&
|
||||
(!isWarTraitKey(input.inheritSpecial) || !availableSpecialWar.includes(input.inheritSpecial))
|
||||
) {
|
||||
fail('BAD_REQUEST', '전투 특기가 잘못 지정되었습니다.');
|
||||
}
|
||||
if (
|
||||
input.inheritTurntimeZone !== undefined &&
|
||||
(!Number.isInteger(input.inheritTurntimeZone) ||
|
||||
input.inheritTurntimeZone < 0 ||
|
||||
input.inheritTurntimeZone > 59)
|
||||
) {
|
||||
fail('BAD_REQUEST', '턴 시간 지정 범위가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
||||
const inheritConstants = resolveInheritConstants(worldState);
|
||||
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
||||
const currentInheritancePoint = await applyInheritanceUser(
|
||||
db,
|
||||
input.userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth
|
||||
);
|
||||
if (currentInheritancePoint < inheritRequiredPoint) {
|
||||
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
||||
}
|
||||
|
||||
const allCities = world.listCities().sort((left, right) => left.id - right.id);
|
||||
const selectedCity =
|
||||
input.inheritCity === undefined ? null : (allCities.find((city) => city.id === input.inheritCity) ?? null);
|
||||
if (input.inheritCity !== undefined && !selectedCity) {
|
||||
fail('BAD_REQUEST', '도시가 잘못 지정되었습니다. 다시 가입해주세요!');
|
||||
}
|
||||
const neutralCities = allCities.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0);
|
||||
const randomCities =
|
||||
neutralCities.length > 0 ? neutralCities : allCities.filter((city) => city.level >= 5 && city.level <= 6);
|
||||
if (!selectedCity && randomCities.length === 0) {
|
||||
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
|
||||
}
|
||||
|
||||
const hiddenSeed = readHiddenSeed(worldState);
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, acceptedAt))
|
||||
);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const currentGenius = Math.max(
|
||||
0,
|
||||
Math.floor(asNumber(worldMeta.genius, asNumber(configConst.defaultMaxGenius, DEFAULT_MAX_GENIUS)))
|
||||
);
|
||||
if (input.inheritSpecial !== undefined && currentGenius === 0) {
|
||||
fail('PRECONDITION_FAILED', '이미 천재가 모두 나타났습니다. 다시 가입해주세요!');
|
||||
}
|
||||
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
||||
const genius = geniusRequested && currentGenius > 0;
|
||||
|
||||
const city = selectedCity ?? rng.choice(randomCities);
|
||||
const bonus = inheritBonus ?? buildRandomBonus(rng, [...stats]);
|
||||
const finalStats = {
|
||||
leadership: input.leadership + bonus[0],
|
||||
strength: input.strength + bonus[1],
|
||||
intelligence: input.intel + bonus[2],
|
||||
};
|
||||
const age = 20 + (bonus[0] + bonus[1] + bonus[2]) * 2 - rng.nextRangeInt(0, 1);
|
||||
const relativeYear = resolveRelativeYear(worldState);
|
||||
const retirementYear = asNumber(configConst.retirementYear, 80);
|
||||
const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode);
|
||||
let specialityDomesticAge = resolveSpecialityAge(retirementYear, age, relativeYear, 12);
|
||||
let specialityWarAge = resolveSpecialityAge(retirementYear, age, relativeYear, 6);
|
||||
let specialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
let specialWarName = specialWar;
|
||||
if (genius) {
|
||||
specialityWarAge = age;
|
||||
if (input.inheritSpecial) {
|
||||
specialWar = input.inheritSpecial;
|
||||
} else {
|
||||
const traits = await loadWarTraitModules(availableSpecialWar, new WarTraitLoader());
|
||||
specialWar =
|
||||
TraitSelector.pickWarTrait(
|
||||
rng,
|
||||
finalStats,
|
||||
[0, 0, 0, 0, 0],
|
||||
traits,
|
||||
[],
|
||||
world.getScenarioConfig().stat
|
||||
) ?? specialWar;
|
||||
}
|
||||
const [trait] = await loadWarTraitModules(
|
||||
[specialWar].filter((key) => isWarTraitKey(key)),
|
||||
new WarTraitLoader()
|
||||
);
|
||||
specialWarName = trait?.name ?? specialWar;
|
||||
}
|
||||
if (Number.isFinite(scenarioId) && scenarioId >= 1000) {
|
||||
specialityDomesticAge = age + 3;
|
||||
specialityWarAge = age + 3;
|
||||
}
|
||||
|
||||
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||
const turnTime = resolveTurnTime(
|
||||
rng,
|
||||
worldState,
|
||||
acceptedAt,
|
||||
world.getState().lastTurnTime,
|
||||
input.inheritTurntimeZone
|
||||
);
|
||||
const personality = input.character === 'Random' ? rng.choice([...JOIN_PERSONALITY_TRAIT_KEYS]) : input.character;
|
||||
const affinity = rng.nextRangeInt(1, 150);
|
||||
const generalId = world.getNextGeneralId();
|
||||
let obfuscatedNamePool = Array.isArray(worldMeta.obfuscatedNamePool)
|
||||
? worldMeta.obfuscatedNamePool.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
if ((blockGeneralCreate & 2) !== 0 && obfuscatedNamePool.length === 0) {
|
||||
obfuscatedNamePool = buildAuctionAliasPool(hiddenSeed, configConst);
|
||||
world.updateWorldMeta({ obfuscatedNamePool });
|
||||
}
|
||||
const finalName =
|
||||
(blockGeneralCreate & 2) !== 0
|
||||
? buildAuctionAlias(generalId, hiddenSeed, {
|
||||
...configConst,
|
||||
obfuscatedNamePool,
|
||||
})
|
||||
: normalizedName;
|
||||
const showImgLevel = Math.floor(asNumber(config.showImgLevel, 0));
|
||||
const canUseOwnerPicture =
|
||||
showImgLevel >= 1 &&
|
||||
input.pic === true &&
|
||||
input.ownerCanUsePicture === true &&
|
||||
typeof input.ownerPicture === 'string' &&
|
||||
input.ownerPicture.length > 0 &&
|
||||
input.ownerPicture !== 'default.jpg';
|
||||
const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg';
|
||||
const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0;
|
||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||
const general: TurnGeneral = {
|
||||
id: generalId,
|
||||
userId: input.userId,
|
||||
name: finalName,
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity,
|
||||
// Ref Join은 두 컬럼을 지정하지 않아 schema 기본값 180/300을 유지한다.
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture,
|
||||
imageServer,
|
||||
stats: finalStats,
|
||||
experience,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
injury: 0,
|
||||
gold: asNumber(configConst.defaultGold, DEFAULT_GENERAL_GOLD),
|
||||
rice: asNumber(configConst.defaultRice, DEFAULT_GENERAL_RICE),
|
||||
crew: 0,
|
||||
crewTypeId: world.getUnitSet()?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
turnTime,
|
||||
age,
|
||||
startAge: age,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic:
|
||||
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None',
|
||||
specialWar,
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
},
|
||||
},
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: {},
|
||||
},
|
||||
lastTurn: { command: DEFAULT_TURN_ACTION },
|
||||
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
|
||||
inheritancePoints: {
|
||||
previous: finalInheritancePoint,
|
||||
},
|
||||
refreshScoreTotal: 0,
|
||||
meta: {
|
||||
createdBy: 'join',
|
||||
ownerName: input.ownerDisplayName,
|
||||
owner_name: input.ownerDisplayName,
|
||||
killturn: 6,
|
||||
npc_org: 0,
|
||||
newmsg: 0,
|
||||
makelimit: 0,
|
||||
block: 0,
|
||||
dedlevel: 0,
|
||||
explevel: 0,
|
||||
officerCity: 0,
|
||||
officer_city: 0,
|
||||
permission: 'normal',
|
||||
belong: 1,
|
||||
betray: relativeYear >= 4 ? 2 : 0,
|
||||
specage: specialityDomesticAge,
|
||||
specage2: specialityWarAge,
|
||||
defence_train: 80,
|
||||
tnmt: 1,
|
||||
myset: 6,
|
||||
tournament: 0,
|
||||
newvote: 0,
|
||||
inherit_spent_dyn: inheritRequiredPoint,
|
||||
},
|
||||
};
|
||||
if (!world.addGeneral(general)) {
|
||||
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
|
||||
}
|
||||
if (genius) {
|
||||
world.updateWorldMeta({ genius: currentGenius - 1 });
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId },
|
||||
update: { userId: input.userId, lastRefresh: acceptedAt },
|
||||
create: {
|
||||
generalId,
|
||||
userId: input.userId,
|
||||
lastRefresh: acceptedAt,
|
||||
},
|
||||
});
|
||||
if (inheritRequiredPoint > 0) {
|
||||
await setInheritancePoint(db, input.userId, nextInheritancePoint);
|
||||
const inheritLogs: string[] = [];
|
||||
if (input.inheritSpecial) {
|
||||
inheritLogs.push(`${specialWarName} 전투 특기를 가진 천재 생성`);
|
||||
}
|
||||
if (input.inheritCity !== undefined) {
|
||||
inheritLogs.push(`${city.name}에 장수 생성`);
|
||||
}
|
||||
if (inheritBonus) {
|
||||
inheritLogs.push(`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`);
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
const zoneStart = input.inheritTurntimeZone * Math.max(1, Math.floor(worldState.tickSeconds / 60));
|
||||
inheritLogs.push(
|
||||
`턴 시간 ${String(Math.floor(zoneStart / 60)).padStart(2, '0')}:${String(zoneStart % 60).padStart(
|
||||
2,
|
||||
'0'
|
||||
)} 로 지정`
|
||||
);
|
||||
}
|
||||
inheritLogs.push(`장수 생성으로 포인트 ${inheritRequiredPoint} 소모`);
|
||||
for (const text of inheritLogs) {
|
||||
await appendInheritanceLog(db, input.userId, worldState.currentYear, worldState.currentMonth, text);
|
||||
}
|
||||
}
|
||||
if (restInheritanceBonus > 0) {
|
||||
await setInheritancePoint(db, input.userId, finalInheritancePoint);
|
||||
await appendInheritanceLog(
|
||||
db,
|
||||
input.userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`신규/복귀 생성으로 포인트 ${restInheritanceBonus} 지급`
|
||||
);
|
||||
}
|
||||
pushCreationLogs(world, {
|
||||
generalId,
|
||||
userId: input.userId,
|
||||
name: finalName,
|
||||
cityName: city.name,
|
||||
bonus,
|
||||
age,
|
||||
genius,
|
||||
specialWarName,
|
||||
});
|
||||
return { ok: true, generalId };
|
||||
};
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
reselectGeneralFromSelectionPool,
|
||||
SelectPoolError,
|
||||
} from './selectPoolService.js';
|
||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -131,21 +132,85 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
|
||||
const resolveCommandAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' | '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 },
|
||||
select: { createdAt: true, actorUserId: true },
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
|
||||
}
|
||||
if (event.actorUserId !== command.userId) {
|
||||
throw new Error(`ENGINE input event actor does not match ${command.type} user.`);
|
||||
}
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
async function handleJoinCreateGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const worldState = await db.worldState.findUnique({
|
||||
where: { id: ctx.world.getState().id },
|
||||
});
|
||||
if (!worldState) {
|
||||
throw new Error('Join world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
try {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
...(await createGeneralFromJoin({
|
||||
db,
|
||||
world: ctx.world,
|
||||
worldState,
|
||||
input: {
|
||||
userId: command.userId,
|
||||
ownerDisplayName: command.ownerDisplayName,
|
||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||
name: command.name,
|
||||
leadership: command.leadership,
|
||||
strength: command.strength,
|
||||
intel: command.intel,
|
||||
pic: command.pic,
|
||||
character: command.character,
|
||||
profileId: command.profileId,
|
||||
...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}),
|
||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||
...(command.ownerCanUsePicture !== undefined
|
||||
? { ownerCanUsePicture: command.ownerCanUsePicture }
|
||||
: {}),
|
||||
...(command.ownerLegacyPenalty !== undefined
|
||||
? { ownerLegacyPenalty: command.ownerLegacyPenalty }
|
||||
: {}),
|
||||
...(command.inheritSpecial !== undefined ? { inheritSpecial: command.inheritSpecial } : {}),
|
||||
...(command.inheritTurntimeZone !== undefined
|
||||
? { inheritTurntimeZone: command.inheritTurntimeZone }
|
||||
: {}),
|
||||
...(command.inheritCity !== undefined ? { inheritCity: command.inheritCity } : {}),
|
||||
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
||||
},
|
||||
acceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof JoinCreateGeneralError) {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectPoolCreate(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
||||
@@ -1952,16 +2017,12 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
||||
patchGeneral: (command) =>
|
||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||
joinCreateGeneral: (command) =>
|
||||
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
||||
selectPoolCreate: (command) =>
|
||||
handleSelectPoolCreate(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
||||
),
|
||||
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
|
||||
selectPoolReselect: (command) =>
|
||||
handleSelectPoolReselect(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
|
||||
),
|
||||
handleSelectPoolReselect(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>),
|
||||
shiftSchedule: (command) =>
|
||||
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
|
||||
|
||||
describe('generic join legacy time contracts', () => {
|
||||
it('builds the Ref MakeGeneral seed from the Seoul whole-second timestamp', () => {
|
||||
expect(buildJoinCreateGeneralSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe(
|
||||
'str(4,seed)|str(11,MakeGeneral)|int(42)|str(19,2026-07-31 08:59:58)'
|
||||
);
|
||||
});
|
||||
|
||||
it('aligns a 120-minute turn from the Ref Asia/Seoul previous-day 01:00 anchor', () => {
|
||||
expect(cutJoinTurnTime(new Date('2026-07-30T03:34:56.789Z'), 120 * 60).toISOString()).toBe(
|
||||
'2026-07-30T02:00:00.000Z'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user