Files
core2026/app/game-engine/src/turn/joinCreateGeneralService.ts
T

869 lines
32 KiB
TypeScript

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<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;
ownerIconRevision?: string;
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, '');
export 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 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 };
};