feat: make general creation daemon-atomic

This commit is contained in:
2026-07-31 00:33:03 +00:00
parent 115218ded8
commit c6a2a0da93
34 changed files with 2227 additions and 655 deletions
+3 -41
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { parseGameSessionTokenPayload, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { isValid, parseISO } from 'date-fns';
interface RedisClientLike {
@@ -10,49 +10,11 @@ interface RedisClientLike {
const ACCESS_TOKEN_PREFIX = 'ga_';
const buildAccessKey = (profileName: string, token: string): string =>
`sammo:game:access:${profileName}:${token}`;
const buildAccessKey = (profileName: string, token: string): string => `sammo:game:access:${profileName}:${token}`;
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
`sammo:game:gateway-used:${profileName}:${sessionId}`;
const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
if (!value || typeof value !== 'object') {
return null;
}
const payload = value as Partial<GameSessionTokenPayload>;
if (payload.version !== 1) {
return null;
}
if (typeof payload.profile !== 'string') {
return null;
}
if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') {
return null;
}
if (typeof payload.sessionId !== 'string') {
return null;
}
if (!payload.user || typeof payload.user !== 'object') {
return null;
}
const user = payload.user as Partial<GameSessionTokenPayload['user']>;
if (
typeof user.id !== 'string' ||
typeof user.username !== 'string' ||
typeof user.displayName !== 'string' ||
!Array.isArray(user.roles) ||
(user.legacyMemberNo !== undefined &&
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
) {
return null;
}
if (!payload.sanctions || typeof payload.sanctions !== 'object') {
return null;
}
return payload as GameSessionTokenPayload;
};
const resolveTtlSeconds = (expiresAt: string): number => {
const parsed = parseISO(expiresAt);
if (!isValid(parsed)) {
@@ -96,7 +58,7 @@ export class RedisAccessTokenStore {
return null;
}
try {
const payload = parsePayload(JSON.parse(raw));
const payload = parseGameSessionTokenPayload(JSON.parse(raw));
if (!payload) {
return null;
}
+125 -457
View File
@@ -1,36 +1,24 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import type { DatabaseClient, GameApiContext, WorldStateRow } from '../../context.js';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import {
isPersonalityTraitKey,
isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS,
loadPersonalityTraitModules,
loadWarTraitModules,
PersonalityTraitLoader,
PERSONALITY_TRAIT_KEYS,
WarTraitLoader,
WAR_TRAIT_KEYS,
} from '@sammo-ts/logic';
import {
appendInheritanceLog,
readInheritancePoint,
resolveInheritConstants,
setInheritancePoint,
} from '../../services/inheritance.js';
import {
getSelectionPoolStatus,
reserveSelectionPool,
resolveSelectionMaxGeneral,
} from '../../services/selectPool.js';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
const resolveSelectionCommandResult = (
result:
| Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>>
| null,
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null,
expectedType: 'selectPoolCreate' | 'selectPoolReselect'
): { ok: true; generalId: number } => {
if (!result) {
@@ -67,13 +55,66 @@ const resolveSelectionRequestId = (
if (!contextRequestId) {
return undefined;
}
const path =
operation === 'create'
? 'join.selectPoolGeneral'
: 'join.reselectPoolGeneral';
const path = operation === 'create' ? 'join.selectPoolGeneral' : 'join.reselectPoolGeneral';
return `${contextRequestId}:${path}`;
};
const resolveJoinCreateCommandResult = (
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null
): { ok: true; generalId: number } => {
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message:
'장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== 'joinCreateGeneral') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 장수 생성 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({
code: result.code,
message: result.reason,
});
}
return { ok: true, generalId: result.generalId };
};
const resolveJoinCreateRequestId = (
contextRequestId: string | undefined,
userId: string,
clientRequestId: string | undefined
): string | undefined => {
if (clientRequestId) {
return `join-create:${userId}:${clientRequestId}`;
}
return contextRequestId ? `${contextRequestId}:join.createGeneral` : undefined;
};
const requestJoinCreateCommand = async (
ctx: GameApiContext,
command: Parameters<GameApiContext['turnDaemon']['requestCommand']>[0]
) => {
try {
return await ctx.turnDaemon.requestCommand(command);
} catch (error) {
if (
error instanceof ConflictingTurnDaemonCommandError ||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 접수된 장수 생성 요청과 입력이 다릅니다. 새 요청 번호로 다시 시도해 주세요.',
});
}
throw error;
}
};
const DEFAULT_JOIN_STAT = {
total: 165,
min: 15,
@@ -82,12 +123,8 @@ const DEFAULT_JOIN_STAT = {
bonusMax: 5,
};
const buildSpecialityAge = (
retirementYear: number,
age: number,
relativeYear: number,
divisor: number
): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
export const resolveJoinSpecialityAges = (options: {
retirementYear: number;
@@ -116,90 +153,30 @@ const resolveJoinStat = (worldState: WorldStateRow) => {
};
};
const resolveJoinPolicy = (worldState: WorldStateRow) => {
const config = asRecord(worldState.config);
const joinMode = typeof config.joinMode === 'string' ? config.joinMode : 'full';
const blockGeneralCreate =
typeof config.blockGeneralCreate === 'number' && Number.isFinite(config.blockGeneralCreate)
? Math.floor(config.blockGeneralCreate)
: 0;
return { joinMode, blockGeneralCreate };
};
const hashString = (value: string): number => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
}
return hash;
};
const pickFromList = (values: string[], seed: string): string | null => {
if (!values.length) {
return null;
}
const index = hashString(seed) % values.length;
return values[index] ?? null;
};
const buildTurnTimeZones = (tickMinutes: number): string[] => {
const buildTurnTimeZones = (tickSeconds: number): string[] => {
const zones: string[] = [];
const legacyZoneSeconds = Math.max(1, Math.floor(tickSeconds / 60));
for (let i = 0; i < 60; i += 1) {
const totalMinutes = i * tickMinutes;
const hour = Math.floor(totalMinutes / 60) % 24;
const minute = totalMinutes % 60;
zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
const startSeconds = i * legacyZoneSeconds;
const endSeconds = startSeconds + legacyZoneSeconds - 1;
const format = (totalSeconds: number, fraction: string): string =>
`${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(
2,
'0'
)}.${fraction}`;
zones.push(`${format(startSeconds, '000')} ~ ${format(endSeconds, '999')}`);
}
return zones;
};
const alignToTurnBase = (time: Date, tickMinutes: number): Date => {
const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0);
const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000);
const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes);
return new Date(base.getTime() + alignedMinutes * 60000);
};
const nextRangeInt = (rng: LiteHashDRBG, minInclusive: number, maxInclusive: number): number => {
if (maxInclusive <= minInclusive) {
return minInclusive;
}
return minInclusive + rng.nextInt(maxInclusive - minInclusive);
};
const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => {
const total = weights.reduce((acc, value) => acc + value, 0);
if (total <= 0) {
return 0;
}
let cursor = rng.nextFloat1() * total;
for (let i = 0; i < weights.length; i += 1) {
cursor -= weights[i] ?? 0;
if (cursor <= 0) {
return i;
}
}
return weights.length - 1;
};
const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => {
const count = rng.nextInt(2) + 3;
const bonus: [number, number, number] = [0, 0, 0];
for (let i = 0; i < count; i += 1) {
const index = pickWeightedIndex(rng, baseStats);
bonus[index] += 1;
}
return bonus;
};
let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null;
const zJoinPersonality = z.enum(['Random', ...JOIN_PERSONALITY_TRAIT_KEYS]);
const loadPersonalityOptions = async () => {
if (cachedPersonalityOptions) {
return cachedPersonalityOptions;
}
const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
const modules = await loadPersonalityTraitModules([...JOIN_PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
cachedPersonalityOptions = modules.map((trait) => ({
key: trait.key,
name: trait.name,
@@ -233,8 +210,7 @@ export const joinRouter = router({
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] =
await Promise.all([
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] = await Promise.all([
loadPersonalityOptions(),
loadWarOptions(warKeys),
ctx.db.nation.findMany({
@@ -265,40 +241,24 @@ export const joinRouter = router({
const inheritTotalPoint = ctx.auth?.user.id
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
: 0;
const selectionPool = await getSelectionPoolStatus(
ctx.db,
worldState,
ctx.auth?.user.id ?? ''
);
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, ctx.auth?.user.id ?? '');
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
const maxGeneral = resolveSelectionMaxGeneral(worldState);
const inheritCitiesRaw = await ctx.db.city.findMany({
where: { level: { in: [5, 6] }, nationId: 0 },
const inheritCities = await ctx.db.city.findMany({
select: { id: true, name: true, level: true, region: true },
orderBy: { id: 'asc' },
});
const inheritCities =
inheritCitiesRaw.length > 0
? inheritCitiesRaw
: await ctx.db.city.findMany({
where: { level: { in: [5, 6] } },
select: { id: true, name: true, level: true, region: true },
orderBy: { id: 'asc' },
});
return {
rules: {
stat: resolveJoinStat(worldState),
allowCustomName: true,
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
},
user: {
id: ctx.auth?.user.id ?? '',
displayName: ctx.auth?.user.displayName ?? '',
},
personalities: [
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
...personalities,
],
personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities],
warSpecials,
nations,
serverInfo: {
@@ -318,7 +278,7 @@ export const joinRouter = router({
inheritBornStatPoint: inheritConst.inheritBornStatPoint,
},
availableCities: inheritCities,
turnTimeZones: buildTurnTimeZones(tickMinutes),
turnTimeZones: buildTurnTimeZones(worldState.tickSeconds),
availableSpecialWar: warSpecials,
},
selectionPool,
@@ -363,12 +323,7 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const commandRequestId = resolveSelectionRequestId(
ctx.requestId,
userId,
input.clientRequestId,
'create'
);
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -408,15 +363,16 @@ export const joinRouter = router({
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
createGeneral: authedProcedure
createGeneral: engineAuthedProcedure
.input(
z.object({
name: z.string().min(1).max(18),
leadership: z.number().int(),
strength: z.number().int(),
intel: z.number().int(),
pic: z.boolean().optional(),
character: z.string(),
pic: z.boolean(),
character: zJoinPersonality,
clientRequestId: z.string().uuid().optional(),
inheritSpecial: z.string().optional(),
inheritTurntimeZone: z.number().int().optional(),
inheritCity: z.number().int().optional(),
@@ -424,337 +380,49 @@ export const joinRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
const auth = ctx.auth;
if (!auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
if (ctx.auth?.identity?.canCreateGeneral === false) {
if (auth.identity?.canCreateGeneral === false) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, userId);
if (selectionPool.enabled) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '장수 선택 목록에서 장수를 골라 주세요.',
});
}
const joinPolicy = resolveJoinPolicy(worldState);
if (joinPolicy.blockGeneralCreate === 1) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '장수 생성이 제한된 서버입니다.',
});
}
const inheritConst = resolveInheritConstants(worldState);
const configConst = asRecord(asRecord(worldState.config).const);
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const inheritBonus = input.inheritBonusStat ?? null;
if (inheritBonus) {
const bonusSum = inheritBonus.reduce((acc, value) => acc + value, 0);
if (inheritBonus.some((value) => value < 0)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '보너스 능력치가 음수입니다. 다시 가입해주세요!',
});
}
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!',
});
}
}
if (input.inheritSpecial !== undefined) {
if (!isWarTraitKey(input.inheritSpecial)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '전투 특기가 잘못 지정되었습니다.',
});
}
if (availableSpecialWar.length > 0 && !availableSpecialWar.includes(input.inheritSpecial)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '허용되지 않은 전투 특기입니다.',
});
}
}
if (input.inheritTurntimeZone !== undefined) {
if (input.inheritTurntimeZone < 0 || input.inheritTurntimeZone > 59) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '턴 시간 지정 범위가 올바르지 않습니다.',
});
}
}
const statRule = resolveJoinStat(worldState);
const statTotal = input.leadership + input.strength + input.intel;
if (
input.leadership < statRule.min ||
input.strength < statRule.min ||
input.intel < statRule.min ||
input.leadership > statRule.max ||
input.strength > statRule.max ||
input.intel > statRule.max
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '능력치 범위를 벗어났습니다.',
});
}
if (statTotal > statRule.total) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `능력치 합이 ${statRule.total}을 초과했습니다.`,
});
}
const personalityOptions = await loadPersonalityOptions();
const personalityKeys = personalityOptions.map((trait) => trait.key);
const resolveGeneralName = async (): Promise<string> => {
if (joinPolicy.blockGeneralCreate !== 2) {
return input.name;
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const candidate = randomBytes(5).toString('hex');
const exists = await ctx.db.general.findFirst({ where: { name: candidate } });
if (!exists) {
return candidate;
}
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '랜덤 장수명 생성에 실패했습니다.',
});
};
const generalName = await resolveGeneralName();
const chosenPersonality =
input.character === 'Random'
? pickFromList(personalityKeys, `${userId}:${generalName}`) ?? 'None'
: isPersonalityTraitKey(input.character)
? input.character
: 'None';
const createGeneral = async (db: DatabaseClient) => {
const existing = await db.general.findFirst({ where: { userId } });
if (existing) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '이미 장수가 생성되어 있습니다.',
});
}
const nameExists = await db.general.findFirst({ where: { name: generalName } });
if (nameExists) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 존재하는 장수명입니다.',
});
}
const maxId = await db.general.aggregate({ _max: { id: true } });
const nextId = (maxId._max.id ?? 0) + 1;
const cityList = await db.city.findMany({
select: { id: true, level: true, nationId: true, name: true },
orderBy: { id: 'asc' },
});
if (!cityList.length) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '도시 정보를 찾을 수 없습니다.',
});
}
const neutralCities = cityList.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0);
const candidateCities =
neutralCities.length > 0 ? neutralCities : cityList.filter((city) => city.level >= 5 && city.level <= 6);
if (!candidateCities.length) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '생성 가능한 도시가 없습니다.',
});
}
let inheritRequiredPoint = 0;
if (input.inheritCity !== undefined) {
inheritRequiredPoint += inheritConst.inheritBornCityPoint;
}
if (input.inheritSpecial !== undefined) {
inheritRequiredPoint += inheritConst.inheritBornSpecialPoint;
}
if (input.inheritTurntimeZone !== undefined) {
inheritRequiredPoint += inheritConst.inheritBornTurntimePoint;
}
if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) {
inheritRequiredPoint += inheritConst.inheritBornStatPoint;
}
const currentPoint = await readInheritancePoint(db, userId, 'previous');
if (currentPoint < inheritRequiredPoint) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '유산 포인트가 부족합니다.',
});
}
const hiddenSeed = String(asRecord(worldState.meta).hiddenSeed ?? 'inherit');
const rng = new LiteHashDRBG(`${hiddenSeed}:MakeGeneral:${userId}:${generalName}`);
const bonusStatSum = inheritBonus ? inheritBonus.reduce((acc, value) => acc + value, 0) : 0;
const randomBonus =
!inheritBonus || bonusStatSum === 0
? buildRandomBonus(
rng,
[input.leadership, input.strength, input.intel]
)
: (inheritBonus as [number, number, number]);
const finalLeadership = input.leadership + randomBonus[0];
const finalStrength = input.strength + randomBonus[1];
const finalIntel = input.intel + randomBonus[2];
const age = 20 + (randomBonus[0] + randomBonus[1] + randomBonus[2]) * 2 - nextRangeInt(rng, 0, 1);
const selectedCity =
typeof input.inheritCity === 'number'
? candidateCities.find((city) => city.id === input.inheritCity)
: null;
if (input.inheritCity !== undefined && !selectedCity) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '지정한 도시를 찾을 수 없습니다.',
});
}
const cityIndex = nextRangeInt(rng, 0, candidateCities.length - 1);
const cityId = (selectedCity ?? candidateCities[cityIndex] ?? candidateCities[0]).id;
const defaultSpecialDomestic =
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None';
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const retirementYear =
typeof configConst.retirementYear === 'number' && Number.isFinite(configConst.retirementYear)
? configConst.retirementYear
: 80;
const worldMeta = asRecord(worldState.meta);
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
const startYear =
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
? scenarioMeta.startYear
: worldState.currentYear;
const relativeYear = Math.max(
worldState.currentYear - startYear,
0
);
const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode);
const specialityAges = resolveJoinSpecialityAges({
retirementYear,
age,
relativeYear,
scenarioId,
});
const specialWar =
input.inheritSpecial && isWarTraitKey(input.inheritSpecial) ? input.inheritSpecial : defaultSpecialWar;
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
const baseTime = alignToTurnBase(new Date(), tickMinutes);
let turnTime = new Date(baseTime.getTime() + rng.nextFloat1() * tickMinutes * 60000);
if (input.inheritTurntimeZone !== undefined) {
const offsetMinutes = input.inheritTurntimeZone * tickMinutes + rng.nextFloat1() * tickMinutes;
turnTime = new Date(baseTime.getTime() + offsetMinutes * 60000);
}
if (turnTime.getTime() <= Date.now()) {
turnTime = new Date(turnTime.getTime() + tickMinutes * 60000);
}
const logEntries: string[] = [];
if (input.inheritSpecial && isWarTraitKey(input.inheritSpecial)) {
const [special] = await loadWarOptions([input.inheritSpecial]);
const specialName = special?.name ?? input.inheritSpecial;
logEntries.push(`${specialName} 전투 특기를 가진 천재 생성`);
}
if (input.inheritCity !== undefined && selectedCity) {
logEntries.push(`${selectedCity.name}에 장수 생성`);
}
if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) {
logEntries.push(
`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`
);
}
if (input.inheritTurntimeZone !== undefined) {
const zones = buildTurnTimeZones(tickMinutes);
const zoneLabel = zones[input.inheritTurntimeZone];
if (zoneLabel) {
logEntries.push(`턴 시간 ${zoneLabel} 로 지정`);
}
}
const general = await db.general.create({
data: {
id: nextId,
userId,
name: generalName,
nationId: 0,
cityId,
troopId: 0,
npcState: 0,
leadership: finalLeadership,
strength: finalStrength,
intel: finalIntel,
personalCode: chosenPersonality ?? 'None',
specialCode: defaultSpecialDomestic,
special2Code: specialWar,
turnTime,
age,
startAge: age,
meta: {
createdBy: 'join',
ownerName: ctx.auth?.user.displayName ?? '',
killturn: 24,
specage: specialityAges.domestic,
specage2: specialityAges.war,
},
},
});
await db.generalAccessLog.upsert({
where: { generalId: general.id },
update: {
userId,
lastRefresh: new Date(),
},
create: {
generalId: general.id,
userId,
lastRefresh: new Date(),
},
});
if (inheritRequiredPoint > 0) {
await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint);
}
for (const entry of logEntries) {
await appendInheritanceLog(db, userId, worldState.currentYear, worldState.currentMonth, entry);
}
return { ok: true, generalId: general.id };
};
return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db);
const userId = auth.user.id;
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestJoinCreateCommand(ctx, {
type: 'joinCreateGeneral',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
name: input.name,
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
pic: input.pic,
character: input.character,
profileId: ctx.profile.id,
...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}),
...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}),
...(auth.user.canUseGeneralPicture !== undefined
? {
ownerCanUsePicture: auth.user.canUseGeneralPicture,
}
: {}),
...(auth.sanctions.legacyPenalty !== undefined
? {
ownerLegacyPenalty: auth.sanctions.legacyPenalty,
}
: {}),
...(input.inheritSpecial !== undefined ? { inheritSpecial: input.inheritSpecial } : {}),
...(input.inheritTurntimeZone !== undefined ? { inheritTurntimeZone: input.inheritTurntimeZone } : {}),
...(input.inheritCity !== undefined ? { inheritCity: input.inheritCity } : {}),
...(input.inheritBonusStat !== undefined ? { inheritBonusStat: input.inheritBonusStat } : {}),
});
return resolveJoinCreateCommandResult(result);
}),
listPossessCandidates: authedProcedure
.input(
@@ -0,0 +1,508 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RANK_DATA_TYPES } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.CREATE_GENERAL_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const profile = 'hwe:2';
const userId = 'create-general-integration-user';
const failureUserId = 'create-general-integration-failure-user';
const rejectedUserId = 'create-general-integration-rejected-user';
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('create_general_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
if (!/^[a-z0-9_]+$/.test(schema)) {
throw new Error(`Refusing unsafe schema name: ${schema}`);
}
};
const buildAuth = (id: string, displayName: string, legacyMemberNo: number): GameSessionTokenPayload => ({
version: 1,
profile,
issuedAt: '2026-07-30T00:00:00.000Z',
expiresAt: '2026-08-30T00:00:00.000Z',
sessionId: `create-general-${id}`,
user: {
id,
username: id,
displayName,
roles: ['user'],
legacyMemberNo,
picture: 'custom-owner.webp',
imageServer: 2,
canUseGeneralPicture: true,
},
sanctions: {
legacyPenalty: {
any: {
ban: { expire: 4_102_444_800, value: 1 },
expired: { expire: 1, value: 9 },
},
hwe: {
ban: { expire: 4_102_444_800, value: 2 },
chat: { expire: 4_102_444_800, value: 3 },
},
},
},
});
integration('generic general creation through the durable turn daemon', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let runtime: TurnDaemonRuntime | undefined;
let daemonLoop: Promise<void> | undefined;
let turnDaemon: TurnDaemonTransport;
const buildContext = (requestId: string, auth: GameSessionTokenPayload): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
};
return {
requestId,
db,
redis: redisClient as unknown as RedisConnector['client'],
turnDaemon,
battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'hwe', scenario: '2', name: profile },
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth,
accessTokenStore: new RedisAccessTokenStore(redisClient, profile),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'create-general-test-secret',
};
};
const stopRuntime = async (reason: string): Promise<void> => {
if (!runtime) {
return;
}
await runtime.lifecycle.stop(reason);
await daemonLoop;
await runtime.close();
runtime = undefined;
daemonLoop = undefined;
};
const startRuntime = async (ownerId: string): Promise<void> => {
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: ownerId,
});
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start();
await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({
state: expect.any(String),
});
};
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
process.env.INTEGRATION_WORLD_SEED = 'create-general-integration-seed';
try {
await seedScenarioToDatabase({
scenarioId: 2,
databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'),
installOptions: {
turnTermMinutes: 5,
npcMode: 0,
showImgLevel: 3,
serverId: profile,
season: 1,
},
});
} finally {
if (previousSeed === undefined) {
delete process.env.INTEGRATION_WORLD_SEED;
} else {
process.env.INTEGRATION_WORLD_SEED = previousSeed;
}
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany();
await db.logEntry.deleteMany();
await db.inheritanceLog.deleteMany({
where: { userId: { in: [userId, failureUserId] } },
});
await db.oldGeneral.deleteMany({
where: { serverId: { startsWith: 'create-general-old-' } },
});
await db.gameHistory.deleteMany({
where: { serverId: { startsWith: 'create-general-old-' } },
});
await db.gameHistory.createMany({
data: [1, 2, 3, 4].map((index) => ({
serverId: `create-general-old-${index}`,
date: new Date(`2026-0${5 - index}-01T00:00:00.000Z`),
winnerNation: 1,
map: 'miniche_b',
season: 1,
scenario: 2,
scenarioName: '통합 과거기',
env: {},
})),
});
await db.oldGeneral.create({
data: {
serverId: 'create-general-old-4',
generalNo: 1,
owner: userId,
name: '과거장수',
lastYearMonth: 18001,
turnTime: new Date('2026-01-01T00:00:00.000Z'),
data: {},
},
});
for (const ownerUserId of [userId, failureUserId]) {
await db.inheritancePoint.upsert({
where: {
userId_key: { userId: ownerUserId, key: 'previous' },
},
update: { value: 10_000 },
create: {
userId: ownerUserId,
key: 'previous',
value: 10_000,
},
});
}
for (const [key, value] of [
['active_action', 250.9],
['combat', 100.8],
] as const) {
await db.inheritancePoint.upsert({
where: { userId_key: { userId, key } },
update: { value },
create: { userId, key, value },
});
}
await startRuntime('create-general-integration-daemon');
}, 60_000);
afterAll(async () => {
await stopRuntime('create-general integration complete');
await closeDb?.();
}, 30_000);
it('commits one complete ref-shaped general and survives a daemon reload', async () => {
const auth = buildAuth(userId, '생성사용자', 4242);
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
const clientRequestId = '11111111-1111-4111-8111-111111111111';
const input = {
name: '일/반-장수#',
leadership: 55,
strength: 55,
intel: 55,
pic: true,
character: 'che_안전' as const,
clientRequestId,
inheritTurntimeZone: 7,
inheritCity: city.id,
inheritBonusStat: [2, 1, 1] as [number, number, number],
};
const first = await appRouter
.createCaller(buildContext('create-general-http-a', auth))
.join.createGeneral(input);
const retried = await appRouter
.createCaller(buildContext('create-general-http-b', auth))
.join.createGeneral(input);
expect(retried).toEqual(first);
const created = await db.general.findUniqueOrThrow({
where: { id: first.generalId },
});
expect(created).toMatchObject({
userId,
name: '일반장수',
nationId: 0,
cityId: city.id,
npcState: 0,
leadership: 57,
strength: 56,
intel: 56,
bornYear: 180,
deadYear: 300,
picture: 'custom-owner.webp',
imageServer: 2,
crewTypeId: 1100,
personalCode: 'che_안전',
penalty: {
ban: 2,
chat: 3,
},
});
expect(created.affinity).toBeGreaterThanOrEqual(1);
expect(created.affinity).toBeLessThanOrEqual(150);
expect(created.meta).toMatchObject({
createdBy: 'join',
ownerName: '생성사용자',
killturn: 6,
inherit_spent_dyn: 4500,
});
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
id: created.id,
userId,
name: created.name,
cityId: city.id,
inheritancePoints: {
previous: 7351,
},
});
expect(await db.general.count({ where: { userId } })).toBe(1);
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1);
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
expect(
await db.rankData.findUniqueOrThrow({
where: {
generalId_type: {
generalId: created.id,
type: 'inherit_spent_dyn',
},
},
})
).toMatchObject({ nationId: 0, value: 4500 });
const access = await db.generalAccessLog.findUniqueOrThrow({
where: { generalId: created.id },
});
expect(access).toMatchObject({ userId });
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId, key: 'previous' } },
})
).toMatchObject({ value: 7351 });
expect(await db.inheritancePoint.count({ where: { userId } })).toBe(1);
expect(await db.inheritanceLog.count({ where: { userId } })).toBe(9);
expect(
await db.inheritanceLog.findFirst({
where: {
userId,
text: '신규/복귀 생성으로 포인트 1500 지급',
},
})
).not.toBeNull();
const event = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `join-create:${userId}:${clientRequestId}` },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'SUCCEEDED',
attempts: 1,
actorUserId: userId,
});
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const turnGridOffsetSeconds =
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
expect(turnGridOffsetSeconds).toBeLessThan(40);
await stopRuntime('verify generic join reload');
await startRuntime('create-general-integration-reloaded-daemon');
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
id: created.id,
userId,
name: created.name,
cityId: city.id,
inheritancePoints: {
previous: 7351,
},
});
await expect(
appRouter.createCaller(buildContext('create-general-conflict', auth)).join.createGeneral({
...input,
name: '다른장수',
})
).rejects.toMatchObject({ code: 'CONFLICT' });
expect(await db.general.count({ where: { userId } })).toBe(1);
}, 45_000);
it('rejects an unaffordable inheritance before consuming allocator state', async () => {
const auth = buildAuth(rejectedUserId, '거절사용자', 4444);
const requestUuid = '33333333-3333-4333-8333-333333333333';
const requestId = `join-create:${rejectedUserId}:${requestUuid}`;
const runtimeMetaBefore = runtime!.world.getState().meta;
const persistedMetaBefore = (await db.worldState.findFirstOrThrow()).meta;
await expect(
appRouter.createCaller(buildContext('create-general-rejected-http', auth)).join.createGeneral({
name: '포인트부족',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'che_안전',
inheritSpecial: 'che_무쌍',
clientRequestId: requestUuid,
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '유산 포인트가 부족합니다. 다시 가입해주세요!',
});
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
expect(runtime!.world.getState().meta).toEqual(runtimeMetaBefore);
expect((await db.worldState.findFirstOrThrow()).meta).toEqual(persistedMetaBefore);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
target: 'ENGINE',
status: 'SUCCEEDED',
attempts: 1,
actorUserId: rejectedUserId,
result: {
type: 'joinCreateGeneral',
ok: false,
code: 'BAD_REQUEST',
},
});
}, 30_000);
it('rolls back a hard failure and retries the ENGINE event exactly once', async () => {
const auth = buildAuth(failureUserId, '실패사용자', 4343);
const requestUuid = '22222222-2222-4222-8222-222222222222';
const requestId = `join-create:${failureUserId}:${requestUuid}`;
const triggerName = 'create_general_fail_first_log';
const functionName = 'create_general_fail_first_log_fn';
await db.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"()
RETURNS trigger AS $$
BEGIN
IF NEW.meta ->> 'ownerUserId' = '${failureUserId}'
AND EXISTS (
SELECT 1
FROM "${schemaName}"."input_event"
WHERE "request_id" = '${requestId}'
AND "status" = 'PROCESSING'
AND "attempts" = 1
)
THEN
RAISE EXCEPTION 'injected first generic join log failure';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await db.$executeRawUnsafe(`
CREATE TRIGGER "${triggerName}"
BEFORE INSERT ON "${schemaName}"."log_entry"
FOR EACH ROW EXECUTE FUNCTION "${schemaName}"."${functionName}"()
`);
try {
await expect(
appRouter.createCaller(buildContext('create-general-failure-http', auth)).join.createGeneral({
name: '재시장수',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'Random',
clientRequestId: requestUuid,
})
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
} finally {
await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`);
await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`);
}
const created = await db.general.findFirstOrThrow({
where: { userId: failureUserId },
});
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
id: created.id,
userId: failureUserId,
name: created.name,
});
expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1);
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 2,
actorUserId: failureUserId,
error: null,
});
}, 45_000);
it('rejects a forged ENGINE event whose actor does not own the command', async () => {
const requestId = 'join-create:forged-owner:forged-request';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'joinCreateGeneral',
actorUserId: 'different-actor',
payload: {
type: 'joinCreateGeneral',
requestId,
userId: 'forged-owner',
ownerDisplayName: '위조사용자',
seedOwnerIdentity: 4545,
name: '위조장수',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'che_안전',
profileId: 'hwe',
} as GamePrisma.InputJsonValue,
},
});
const deadline = Date.now() + 5_000;
let event = await db.inputEvent.findUniqueOrThrow({
where: { requestId },
});
while (event.status !== 'FAILED' && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
event = await db.inputEvent.findUniqueOrThrow({
where: { requestId },
});
}
expect(event).toMatchObject({
status: 'FAILED',
actorUserId: 'different-actor',
attempts: 3,
error: expect.stringContaining('actor does not match'),
});
expect(await db.general.count({ where: { userId: 'forged-owner' } })).toBe(0);
expect(runtime!.world.listGenerals()).not.toEqual(
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
);
}, 10_000);
});
+4 -3
View File
@@ -281,9 +281,9 @@ describe('appRouter', () => {
});
it('rejects unauthenticated or game-blocked auth status checks', async () => {
await expect(
appRouter.createCaller(buildContext({ auth: null })).auth.status()
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(appRouter.createCaller(buildContext({ auth: null })).auth.status()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(
appRouter
.createCaller(
@@ -346,6 +346,7 @@ describe('appRouter', () => {
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'Random',
})
).rejects.toMatchObject({
@@ -2,11 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RANK_DATA_TYPES } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
createTurnDaemonRuntime,
seedScenarioToDatabase,
type TurnDaemonRuntime,
} from '@sammo-ts/game-engine';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import {
createGamePostgresConnector,
type GamePrisma,
@@ -29,7 +25,7 @@ const otherUserId = 'select-pool-integration-other-user';
const foreignUserId = 'select-pool-integration-foreign-user';
const failureUserId = 'select-pool-integration-failure-user';
const profile = 'hwe:903';
const schemaName = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') ?? '' : '';
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
@@ -97,10 +93,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
let turnDaemon: TurnDaemonTransport;
let worldStateId: number;
const buildContext = (
requestId: string,
actorAuth: GameSessionTokenPayload = auth
): GameApiContext => {
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
@@ -257,12 +250,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
select: { nationId: true, type: true, value: true },
});
expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length);
expect(initialRankRows.map(({ type }) => type).sort()).toEqual(
[...RANK_DATA_TYPES].sort()
);
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(
true
);
expect(initialRankRows.map(({ type }) => type).sort()).toEqual([...RANK_DATA_TYPES].sort());
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true);
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
expect(
@@ -293,9 +282,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const reselection = await appRouter
.createCaller(buildContext('select-pool-reserve-reselection'))
.join.getSelectionPool();
const target = reselection.candidates.find(
(candidate) => candidate.generalName !== initial.name
)!;
const target = reselection.candidates.find((candidate) => candidate.generalName !== initial.name)!;
await expect(
appRouter
.createCaller(buildContext('select-pool-reselect'))
@@ -328,9 +315,11 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
},
});
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
expect(
await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })
).toMatchObject({ generalId: initial.id, ownerUserId: null, reservedUntil: null });
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })).toMatchObject({
generalId: initial.id,
ownerUserId: null,
reservedUntil: null,
});
expect(
await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } },
@@ -345,9 +334,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
patch: { meta: { postReselectionFlush: 1 } },
})
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
await expect(
db.general.findUniqueOrThrow({ where: { id: initial.id } })
).resolves.toMatchObject({
await expect(db.general.findUniqueOrThrow({ where: { id: initial.id } })).resolves.toMatchObject({
name: target.generalName,
leadership: target.leadership,
strength: target.strength,
@@ -376,23 +363,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.createCaller(buildContext('select-pool-full-reselection-reserve'))
.join.getSelectionPool();
await expect(
appRouter
.createCaller(buildContext('select-pool-full-reselection'))
.join.reselectPoolGeneral({
uniqueName: fullReselection.candidates[0]!.uniqueName,
})
appRouter.createCaller(buildContext('select-pool-full-reselection')).join.reselectPoolGeneral({
uniqueName: fullReselection.candidates[0]!.uniqueName,
})
).resolves.toEqual({ ok: true, generalId: initial.id });
const otherReservation = await appRouter
.createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth))
.join.getSelectionPool();
await expect(
appRouter
.createCaller(buildContext('select-pool-full-new-user-create', otherAuth))
.join.selectPoolGeneral({
uniqueName: otherReservation.candidates[0]!.uniqueName,
personality: 'che_안전',
})
appRouter.createCaller(buildContext('select-pool-full-new-user-create', otherAuth)).join.selectPoolGeneral({
uniqueName: otherReservation.candidates[0]!.uniqueName,
personality: 'che_안전',
})
).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' });
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
await db.worldState.update({
@@ -408,12 +391,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const candidate = reservation.candidates[0]!;
await expect(
appRouter
.createCaller(buildContext('select-pool-foreign-token', foreignAuth))
.join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
})
appRouter.createCaller(buildContext('select-pool-foreign-token', foreignAuth)).join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
})
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
expect(await db.general.count({ where: { userId: foreignUserId } })).toBe(0);
@@ -422,25 +403,22 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
data: { reservedUntil: new Date(Date.now() - 60_000) },
});
await expect(
appRouter
.createCaller(buildContext('select-pool-expired-token', otherAuth))
.join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
})
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
})
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
await expect(
appRouter
.createCaller(buildContext('select-pool-generic-bypass', otherAuth))
.join.createGeneral({
name: '우회장수',
leadership: 55,
strength: 55,
intel: 55,
character: 'che_안전',
})
appRouter.createCaller(buildContext('select-pool-generic-bypass', otherAuth)).join.createGeneral({
name: '우회장수',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'che_안전',
})
).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' });
const input = {
@@ -453,24 +431,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
});
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
const persistedAllocatorBefore = (
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
.meta as Record<string, unknown>
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record<string, unknown>
).lastGeneralId;
await expect(
appRouter
.createCaller(buildContext('select-pool-invalid-personality', otherAuth))
.join.selectPoolGeneral({
...input,
personality: 'not-a-personality',
clientRequestId: '11111111-1111-4111-8111-111111111111',
})
appRouter.createCaller(buildContext('select-pool-invalid-personality', otherAuth)).join.selectPoolGeneral({
...input,
personality: 'not-a-personality',
clientRequestId: '11111111-1111-4111-8111-111111111111',
})
).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' });
expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore);
expect(
(
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
.meta as Record<string, unknown>
).lastGeneralId
((await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record<string, unknown>)
.lastGeneralId
).toBe(persistedAllocatorBefore);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
@@ -534,21 +507,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
try {
await expect(
appRouter
.createCaller(buildContext('select-pool-failure-http', failureAuth))
.join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
clientRequestId: requestUuid,
})
appRouter.createCaller(buildContext('select-pool-failure-http', failureAuth)).join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
clientRequestId: requestUuid,
})
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
} finally {
await db.$executeRawUnsafe(
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`
);
await db.$executeRawUnsafe(
`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`
);
await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`);
await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`);
}
const created = await db.general.findFirstOrThrow({ where: { userId: failureUserId } });
@@ -560,9 +527,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1);
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1);
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(
RANK_DATA_TYPES.length
);
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1);
expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1);
expect(
@@ -570,9 +535,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
where: { meta: { path: ['ownerUserId'], equals: failureUserId } },
})
).toBe(2);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 2,
actorUserId: failureUserId,
+1
View File
@@ -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';
+8 -11
View File
@@ -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,
+40 -1
View File
@@ -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,
+9 -1
View File
@@ -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,
+16 -1
View File
@@ -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 };
};
+71 -10
View File
@@ -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'
);
});
});
@@ -0,0 +1,64 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15126);
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15127);
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'create_general_integration';
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${profileId}:${scenario}`;
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL ?? '';
const redisUrl = process.env.JOIN_LIVE_REDIS_URL ?? '';
const gameSecret = process.env.JOIN_LIVE_GAME_SECRET ?? '';
export default defineConfig({
testDir: '.',
testMatch: ['joinGeneralLive.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/join-general-live'),
use: {
baseURL,
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: [
{
command: 'node app/game-api/dist/index.js',
cwd: repositoryRoot,
url: `http://127.0.0.1:${apiPort}/healthz`,
reuseExistingServer: false,
timeout: 120_000,
env: {
DATABASE_URL: databaseUrl,
REDIS_URL: redisUrl,
GAME_TOKEN_SECRET: gameSecret,
GAME_API_HOST: '127.0.0.1',
GAME_API_PORT: String(apiPort),
PROFILE: profileId,
SCENARIO: scenario,
GAME_PROFILE_NAME: gameProfile,
},
},
{
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
cwd: repositoryRoot,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
],
});
@@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL;
const gameTokenSecret = process.env.JOIN_LIVE_GAME_SECRET;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'create_general_integration:2';
const userId = 'join-general-live-user';
const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret);
const installSession = async (page: Page): Promise<void> => {
const now = new Date();
const gameToken = encryptGameSessionToken(
{
version: 1,
profile,
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
sessionId: `join-live-${randomUUID()}`,
user: {
id: userId,
username: 'join-live-user',
displayName: '브라우저생성',
roles: ['user'],
legacyMemberNo: 7_700,
canUseGeneralPicture: false,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ token, gameProfile }) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', gameProfile);
},
{ token: gameToken, gameProfile: profile }
);
};
test.describe('generic general creation through live PostgreSQL, Redis, API, and Chromium', () => {
test.skip(!hasLiveFixture, 'live join token and database are required');
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
let closeDb: (() => Promise<void>) | undefined;
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
let daemonLoop: Promise<void> | undefined;
test.beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema');
if (schema !== 'create_general_integration') {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
await seedScenarioToDatabase({
scenarioId: 2,
databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'),
installOptions: {
turnTermMinutes: 5,
npcMode: 0,
showImgLevel: 3,
serverId: profile,
season: 1,
},
});
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany();
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: 'join-general-live-daemon',
});
daemonLoop = runtime.lifecycle.start();
});
test.afterAll(async () => {
if (runtime) {
await runtime.lifecycle.stop('join general live complete');
await daemonLoop;
await runtime.close();
}
await closeDb?.();
});
test('keeps the request id across an accepted timeout and creates exactly once', async ({ page }, testInfo) => {
const requestIds: string[] = [];
let injectTimeout = true;
await installSession(page);
await page.route('**/trpc/join.createGeneral?batch=1', async (route) => {
const findClientRequestId = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined;
if ('clientRequestId' in value && typeof value.clientRequestId === 'string') {
return value.clientRequestId;
}
for (const nested of Object.values(value)) {
const found = findClientRequestId(nested);
if (found) return found;
}
return undefined;
};
const clientRequestId = findClientRequestId(route.request().postDataJSON());
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
requestIds.push(clientRequestId!);
if (!injectTimeout) {
await route.continue();
return;
}
injectTimeout = false;
const accepted = await route.fetch();
expect(accepted.ok()).toBe(true);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message:
'장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.createGeneral',
},
},
},
]),
});
});
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('join');
await expect(page.getByRole('heading', { name: '장수 생성/빙의' })).toBeVisible();
const createButton = page.locator('.form-actions').getByRole('button', {
name: '장수 생성',
exact: true,
});
await expect(createButton).toBeEnabled();
await expect(page.getByText('은둔', { exact: true })).toHaveCount(0);
const geometry = await page.locator('.join-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
minHeight: style.minHeight,
padding: style.padding,
};
});
expect(geometry).toEqual({
width: 1200,
minHeight: '900px',
padding: '24px',
});
await createButton.focus();
await expect(createButton).toBeFocused();
await page.screenshot({
path: testInfo.outputPath('join-general-focused.png'),
fullPage: true,
});
await createButton.click();
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
const storedPending = await page.evaluate(() =>
window.sessionStorage.getItem('sammo-join-create-pending-action')
);
expect(storedPending).toContain(requestIds[0]);
await createButton.click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(requestIds).toHaveLength(2);
expect(requestIds[1]).toBe(requestIds[0]);
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
const created = await db.general.findFirstOrThrow({ where: { userId } });
expect(created).toMatchObject({
name: '브라우저생성',
picture: 'default.jpg',
});
const event = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'joinCreateGeneral' },
});
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-join-create-pending-action'))).toBeNull();
});
});
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"allowJs": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"types": ["node", "@playwright/test"]
},
"include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"]
}
+1
View File
@@ -14,6 +14,7 @@
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
+81 -5
View File
@@ -10,7 +10,14 @@ import { cityLevelMap, regionMap } from '../utils/nationFormat';
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[0];
type JoinForm = Omit<JoinInput, 'inheritBonusStat'> & { inheritBonusStat: [number, number, number] };
type JoinForm = Omit<JoinInput, 'inheritBonusStat' | 'clientRequestId'> & {
inheritBonusStat: [number, number, number];
};
type PendingJoinAction = {
ownerUserId: string;
input: JoinForm;
clientRequestId: string;
};
const router = useRouter();
const session = useSessionStore();
@@ -21,6 +28,7 @@ const submitting = ref(false);
const joinConfig = ref<JoinConfig | null>(null);
const activeTab = ref<'create' | 'possess'>('create');
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
const form = ref<JoinForm>({
name: '',
@@ -32,6 +40,55 @@ const form = ref<JoinForm>({
inheritBonusStat: [0, 0, 0],
});
const readPendingJoin = (): PendingJoinAction | null => {
try {
const raw = window.sessionStorage.getItem(pendingJoinStorageKey);
if (!raw) return null;
const value = JSON.parse(raw) as Partial<PendingJoinAction>;
if (
!value.input ||
typeof value.input !== 'object' ||
typeof value.ownerUserId !== 'string' ||
typeof value.clientRequestId !== 'string'
) {
return null;
}
return value as PendingJoinAction;
} catch {
return null;
}
};
const cloneJoinInput = (): JoinForm => JSON.parse(JSON.stringify(form.value)) as JoinForm;
const getPendingJoin = (): PendingJoinAction => {
const input = cloneJoinInput();
const current = readPendingJoin();
const ownerUserId = joinConfig.value?.user.id ?? '';
if (current && current.ownerUserId === ownerUserId && JSON.stringify(current.input) === JSON.stringify(input)) {
return current;
}
const pending: PendingJoinAction = {
ownerUserId,
input,
clientRequestId: crypto.randomUUID(),
};
window.sessionStorage.setItem(pendingJoinStorageKey, JSON.stringify(pending));
return pending;
};
const clearPendingJoin = (pending: PendingJoinAction): void => {
if (readPendingJoin()?.clientRequestId === pending.clientRequestId) {
window.sessionStorage.removeItem(pendingJoinStorageKey);
}
};
const isIndeterminateTimeout = (value: unknown): boolean => {
if (!value || typeof value !== 'object' || !('data' in value)) return false;
const data = value.data;
return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT');
};
const npcCandidates = ref<PossessCandidate[]>([]);
const npcLoading = ref(false);
const npcError = ref<string | null>(null);
@@ -199,8 +256,13 @@ const loadConfig = async () => {
return;
}
joinConfig.value = config;
form.value.name = config.user.displayName || '';
applyBalancedStats();
const pending = readPendingJoin();
if (pending?.ownerUserId === config.user.id) {
form.value = pending.input;
} else {
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
applyBalancedStats();
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'join_config_failed';
} finally {
@@ -234,13 +296,21 @@ const submitJoin = async () => {
}
submitting.value = true;
error.value = null;
const pending = getPendingJoin();
try {
await trpc.join.createGeneral.mutate(form.value);
await trpc.join.createGeneral.mutate({
...pending.input,
clientRequestId: pending.clientRequestId,
});
clearPendingJoin(pending);
await session.refreshGeneralStatus();
if (session.hasGeneral) {
await router.push({ name: 'home' });
}
} catch (err) {
if (!isIndeterminateTimeout(err)) {
clearPendingJoin(pending);
}
error.value = err instanceof Error ? err.message : 'join_failed';
} finally {
submitting.value = false;
@@ -315,7 +385,13 @@ onMounted(() => {
<div class="form-grid">
<label class="form-field">
<span>장수명</span>
<input v-model="form.name" type="text" class="form-input" />
<input
v-if="joinConfig?.rules.allowCustomName"
v-model="form.name"
type="text"
class="form-input"
/>
<span v-else>무작위</span>
</label>
<label class="form-field">
<span>성격</span>
@@ -20,9 +20,13 @@ const readObject = <T extends object>(value: unknown, fallback: T): T => {
const readLegacyMemberNo = (value: unknown): number | undefined => {
const legacyData = readObject<Record<string, unknown>>(value, {});
const memberNo = legacyData.memberNo;
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0
? memberNo
: undefined;
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0 ? memberNo : undefined;
};
const readLegacyGrade = (value: unknown): number | undefined => {
const legacyData = readObject<Record<string, unknown>>(value, {});
const grade = legacyData.grade;
return typeof grade === 'number' && Number.isSafeInteger(grade) ? grade : undefined;
};
const mapUser = (row: {
@@ -71,6 +75,7 @@ const mapUser = (row: {
passwordSalt: row.passwordSalt,
createdAt: row.createdAt.toISOString(),
legacyMemberNo: readLegacyMemberNo(row.legacyData),
legacyGrade: readLegacyGrade(row.legacyData),
});
export const createPostgresUserRepository = (
@@ -21,6 +21,7 @@ export interface UserRecord {
passwordSalt: string;
createdAt: string;
legacyMemberNo?: number;
legacyGrade?: number;
}
export interface PublicUser {
@@ -43,6 +44,7 @@ export interface UserSanctions {
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<string, UserServerRestriction>;
legacyPenalty?: Record<string, unknown>;
}
export interface UserServerRestriction {
+11 -13
View File
@@ -13,12 +13,7 @@ import type { UserOAuthInfo } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import {
openPassword,
zDisplayName,
zPasswordEnvelope,
zRegistrationUsername,
} from './auth/registrationInput.js';
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
const zUsername = z
.string()
@@ -667,14 +662,17 @@ export const appRouter = router({
expiresAt: addSeconds(now, ctx.gameSessionTtlSeconds).toISOString(),
sessionId: gameSession.gameToken,
user: {
id: gameSession.userId,
username: gameSession.username,
displayName: gameSession.displayName,
roles: gameSession.roles,
createdAt: gameSession.createdAt,
legacyMemberNo: gameSession.legacyMemberNo,
id: user.id,
username: user.username,
displayName: user.displayName,
picture: user.picture,
imageServer: user.imageServer,
canUseGeneralPicture: user.legacyGrade === undefined || user.legacyGrade >= 1,
roles: user.roles,
createdAt: user.createdAt,
legacyMemberNo: user.legacyMemberNo,
},
sanctions: gameSession.sanctions,
sanctions: user.sanctions,
identity: {
kakaoVerified: localAccountPolicy.kakaoVerified,
canCreateGeneral: localAccountPolicy.canCreateGeneral,
+49 -5
View File
@@ -79,7 +79,8 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
];
const profiles = {
listProfiles: async () => profileRows,
getProfile: async (profileName: string) => profileRows.find((profile) => profile.profileName === profileName) ?? null,
getProfile: async (profileName: string) =>
profileRows.find((profile) => profile.profileName === profileName) ?? null,
upsertProfile: async () => {
throw new Error('not used');
},
@@ -544,6 +545,49 @@ describe('gateway auth flow', () => {
expect(validated?.user).not.toHaveProperty('legacyMemberNo');
});
it('issues each game token from the latest user roles, sanctions, and icon', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'fresh-game-identity',
password: 'secretpass',
});
user.legacyGrade = 0;
const session = await sessions.createSession(user);
await users.updateRoles(user.id, ['user', 'latest-role']);
await users.updateSanctions(user.id, {
warningCount: 2,
legacyPenalty: {
any: {
chat: { expire: 4_102_444_800, value: 1 },
},
},
});
await users.updateIcon(user.id, 'latest-owner.webp', 3, new Date('2026-07-30T12:00:00.000Z'));
const issued = await caller.auth.issueGameSession({
sessionToken: session.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.user).toMatchObject({
id: user.id,
roles: ['user', 'latest-role'],
picture: 'latest-owner.webp',
imageServer: 3,
canUseGeneralPicture: false,
});
expect(payload?.sanctions).toMatchObject({
warningCount: 2,
legacyPenalty: {
any: {
chat: { expire: 4_102_444_800, value: 1 },
},
},
});
});
it('revokes the gateway session and every linked game session on logout', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
@@ -573,10 +617,10 @@ describe('account self service', () => {
const session = await sessions.createSession(user);
await expect(
caller.account.changePassword({
sessionToken: session.sessionToken,
currentCredential: sealPassword('wrong-password'),
newCredential: sealPassword('next-password'),
caller.account.changePassword({
sessionToken: session.sessionToken,
currentCredential: sealPassword('wrong-password'),
newCredential: sealPassword('next-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
+10 -4
View File
@@ -9,6 +9,7 @@ export interface UserSanctions {
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<string, UserServerRestriction>;
legacyPenalty?: Record<string, unknown>;
}
export interface UserServerRestriction {
@@ -23,6 +24,9 @@ export interface GatewayUserInfo {
username: string;
displayName: string;
roles: string[];
picture?: string;
imageServer?: number;
canUseGeneralPicture?: boolean;
createdAt?: string;
legacyMemberNo?: number;
}
@@ -58,7 +62,7 @@ export const encryptGameSessionToken = (payload: GameSessionTokenPayload, secret
return `${toBase64Url(iv)}.${toBase64Url(ciphertext)}.${toBase64Url(tag)}`;
};
const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPayload | null => {
if (!value || typeof value !== 'object') {
return null;
}
@@ -84,8 +88,10 @@ const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
typeof user.username !== 'string' ||
typeof user.displayName !== 'string' ||
!Array.isArray(user.roles) ||
(user.legacyMemberNo !== undefined &&
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
(user.picture !== undefined && typeof user.picture !== 'string') ||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
) {
return null;
}
@@ -123,7 +129,7 @@ export const decryptGameSessionToken = (token: string, secret: string): GameSess
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
return parsePayload(JSON.parse(plaintext));
return parseGameSessionTokenPayload(JSON.parse(plaintext));
} catch {
return null;
}
+33
View File
@@ -204,6 +204,28 @@ export type TurnDaemonCommand =
specialWar?: string;
};
}
| {
type: 'joinCreateGeneral';
requestId?: string;
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];
}
| {
type: 'selectPoolCreate';
requestId?: string;
@@ -476,6 +498,17 @@ export type TurnDaemonCommandResult =
generalId: number;
reason: string;
}
| {
type: 'joinCreateGeneral';
ok: true;
generalId: number;
}
| {
type: 'joinCreateGeneral';
ok: false;
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
reason: string;
}
| {
type: 'selectPoolCreate';
ok: true;
+1
View File
@@ -204,6 +204,7 @@ export interface TurnEngineGeneralUpdateInput {
specialCode: string;
special2Code: string;
lastTurn: InputJsonValue;
penalty: InputJsonValue;
meta: InputJsonValue;
turnTime: Date;
recentWarTime: Date | null;
@@ -16,6 +16,11 @@ export const PERSONALITY_TRAIT_KEYS = [
export type PersonalityTraitKey = (typeof PERSONALITY_TRAIT_KEYS)[number];
// Ref GameConst::$availablePersonality에는 은둔이 포함되지 않는다.
export const JOIN_PERSONALITY_TRAIT_KEYS = PERSONALITY_TRAIT_KEYS.filter(
(key): key is Exclude<PersonalityTraitKey, 'che_은둔'> => key !== 'che_은둔'
);
export type PersonalityTraitModule = TraitModule;
export type PersonalityTraitImporter = () => Promise<TraitModuleExport>;
+15 -4
View File
@@ -179,11 +179,14 @@ const readNameParts = (value: unknown, fallback: readonly string[]): string[] =>
return result.length > 0 ? result : [...fallback];
};
export const buildAuctionAlias = (
generalId: number,
export const buildAuctionAliasPool = (
hiddenSeed: string | number,
configConst: Record<string, unknown> = {}
): string => {
): string[] => {
const persistedPool = readNameParts(configConst.obfuscatedNamePool, []);
if (persistedPool.length > 0) {
return persistedPool;
}
const firstNames = readNameParts(configConst.randGenFirstName, LEGACY_RANDOM_GENERAL_FIRST_NAMES);
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
const lastNames = readNameParts(configConst.randGenLastName, LEGACY_RANDOM_GENERAL_LAST_NAMES);
@@ -195,7 +198,15 @@ export const buildAuctionAlias = (
}
}
}
const shuffled = new RandUtil(new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))).shuffle(pool);
return new RandUtil(new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))).shuffle(pool);
};
export const buildAuctionAlias = (
generalId: number,
hiddenSeed: string | number,
configConst: Record<string, unknown> = {}
): string => {
const shuffled = buildAuctionAliasPool(hiddenSeed, configConst);
const normalizedId = Math.max(0, Math.floor(generalId));
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildAuctionAlias } from '../src/auction/alias.js';
import { buildAuctionAlias, buildAuctionAliasPool } from '../src/auction/alias.js';
describe('buildAuctionAlias', () => {
it('returns a stable alias for the same world seed and general id', () => {
@@ -22,4 +22,18 @@ describe('buildAuctionAlias', () => {
expect(buildAuctionAlias(1, 'seed', config)).toMatch(/^청운(객|상)$/);
expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/);
});
it('reuses the world-persisted alias pool after general creation changes RNG inputs', () => {
const config = {
obfuscatedNamePool: ['고정별호A', '고정별호B'],
randGenFirstName: ['변경'],
randGenMiddleName: ['된'],
randGenLastName: ['이름'],
};
expect(buildAuctionAliasPool('new-seed', config)).toEqual(['고정별호A', '고정별호B']);
expect(buildAuctionAlias(0, 'new-seed', config)).toBe('고정별호A');
expect(buildAuctionAlias(1, 'new-seed', config)).toBe('고정별호B');
expect(buildAuctionAlias(2, 'new-seed', config)).toBe('고정별호A1');
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "ludo_rathowm",
"name": "ludo_rathowm",
"defaultCrewTypeId": 1100,
"defaultCrewTypeId": 217100,
"armTypes": {
"1": "보병",
"2": "궁병",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "siegetank",
"name": "siegetank",
"defaultCrewTypeId": 1100,
"defaultCrewTypeId": 1500,
"armTypes": {
"1": "보병",
"2": "궁병",
@@ -329,6 +329,7 @@ describe('auction integration flow', () => {
strength: 55,
intel: 55,
character: 'Random',
pic: true,
});
userSessions.push({
username: user.username,
@@ -322,6 +322,7 @@ describe('integration initialization flow', () => {
strength: 55,
intel: 55,
character: 'Random',
pic: true,
});
const generalInfo = await userGameClient.general.me.query();
if (!generalInfo?.general) {
@@ -639,6 +639,7 @@ describe('pm2 orchestrator e2e', () => {
strength: 55,
intel: 55,
character: 'Random',
pic: true,
});
expect(createdGeneral.generalId).toBeGreaterThan(0);
@@ -230,10 +230,7 @@ describe('actual tournament lifecycle', () => {
for (const [username, displayName] of users) {
const login = await gatewayClient.auth.login.mutate({
username,
credential: sealGatewayPassword(
`${username}-pass`,
await gatewayClient.auth.passwordKey.query()
),
credential: sealGatewayPassword(`${username}-pass`, await gatewayClient.auth.passwordKey.query()),
});
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
@@ -252,6 +249,7 @@ describe('actual tournament lifecycle', () => {
strength: 55,
intel: 55,
character: 'Random',
pic: true,
});
generalIds.set(username, created.generalId);
}