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 { buildPrestartDeleteAfter } from './prestartDeletion.js'; import type { TurnGeneral } from './types.js'; type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; 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; ownerIconRevision?: string; ownerCanUsePicture?: boolean; ownerLegacyPenalty?: Record; 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, ''); export const resolveLegacyPenalty = ( rawPenalty: Record | undefined, profileId: string, acceptedAt: Date ): Record => { if (!rawPenalty) { return {}; } const merged = { ...asRecord(rawPenalty.any), ...asRecord(rawPenalty[profileId]), }; const acceptedAtSeconds = acceptedAt.getTime() / 1000; const result: Record = {}; 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 => { 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 => { 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 => { const rows = await db.$queryRaw>( 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 => { 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 => { 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 => { 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 => { 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( `${options.cityName}에서 ${options.name}${josaRa}는 기재가 천하에 이름을 알립니다.` ); logger.pushGlobalActionLog( `${options.specialWarName} 특기를 가진 천재의 등장으로 온 천하가 떠들썩합니다.` ); logger.pushGlobalHistoryLog(`【천재】${options.cityName}에 천재가 등장했습니다.`); } else { logger.pushGlobalActionLog( `${options.cityName}에서 ${options.name}${josaRa}는 호걸이 천하에 이름을 알립니다.` ); } logger.pushGeneralHistoryLog(`${options.name}, ${options.cityName}에서 큰 뜻을 품다.`); logger.pushGeneralActionLog( [ '삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^', '처음 하시는 경우에는 도움말을 참고하시고,', '문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~', '부디 즐거운 삼모전 되시길 바랍니다 ^^', `통솔 ${options.bonus[0]} 무력 ${options.bonus[1]} 지력 ${options.bonus[2]} 의 보너스를 받으셨습니다.`, `연령은 ${options.age}세로 시작합니다.`, ], LogFormat.PLAIN ); if (options.genius) { logger.pushGeneralActionLog( `축하합니다! 천재로 태어나 처음부터 ${options.specialWarName} 특기를 가지게 됩니다!`, LogFormat.PLAIN ); logger.pushGeneralHistoryLog(`${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 accountIconUpdatedAt = input.ownerIconRevision && picture === input.ownerPicture && imageServer === Math.max(0, Math.floor(input.ownerImageServer ?? 0)) ? input.ownerIconRevision : undefined; const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config); 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, prestart_delete_after: prestartDeleteAfter.toISOString(), ...(accountIconUpdatedAt ? { accountIconUpdatedAt } : {}), }, }; 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 }; };