feat: make general creation daemon-atomic
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
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';
|
import { isValid, parseISO } from 'date-fns';
|
||||||
|
|
||||||
interface RedisClientLike {
|
interface RedisClientLike {
|
||||||
@@ -10,49 +10,11 @@ interface RedisClientLike {
|
|||||||
|
|
||||||
const ACCESS_TOKEN_PREFIX = 'ga_';
|
const ACCESS_TOKEN_PREFIX = 'ga_';
|
||||||
|
|
||||||
const buildAccessKey = (profileName: string, token: string): string =>
|
const buildAccessKey = (profileName: string, token: string): string => `sammo:game:access:${profileName}:${token}`;
|
||||||
`sammo:game:access:${profileName}:${token}`;
|
|
||||||
|
|
||||||
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
|
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
|
||||||
`sammo:game:gateway-used:${profileName}:${sessionId}`;
|
`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 resolveTtlSeconds = (expiresAt: string): number => {
|
||||||
const parsed = parseISO(expiresAt);
|
const parsed = parseISO(expiresAt);
|
||||||
if (!isValid(parsed)) {
|
if (!isValid(parsed)) {
|
||||||
@@ -96,7 +58,7 @@ export class RedisAccessTokenStore {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = parsePayload(JSON.parse(raw));
|
const payload = parseGameSessionTokenPayload(JSON.parse(raw));
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,24 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
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 { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
|
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
isPersonalityTraitKey,
|
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
|
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||||
loadPersonalityTraitModules,
|
loadPersonalityTraitModules,
|
||||||
loadWarTraitModules,
|
loadWarTraitModules,
|
||||||
PersonalityTraitLoader,
|
PersonalityTraitLoader,
|
||||||
PERSONALITY_TRAIT_KEYS,
|
|
||||||
WarTraitLoader,
|
WarTraitLoader,
|
||||||
WAR_TRAIT_KEYS,
|
WAR_TRAIT_KEYS,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import {
|
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||||
appendInheritanceLog,
|
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
|
||||||
readInheritancePoint,
|
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
||||||
resolveInheritConstants,
|
|
||||||
setInheritancePoint,
|
|
||||||
} from '../../services/inheritance.js';
|
|
||||||
import {
|
|
||||||
getSelectionPoolStatus,
|
|
||||||
reserveSelectionPool,
|
|
||||||
resolveSelectionMaxGeneral,
|
|
||||||
} from '../../services/selectPool.js';
|
|
||||||
|
|
||||||
const resolveSelectionCommandResult = (
|
const resolveSelectionCommandResult = (
|
||||||
result:
|
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null,
|
||||||
| Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>>
|
|
||||||
| null,
|
|
||||||
expectedType: 'selectPoolCreate' | 'selectPoolReselect'
|
expectedType: 'selectPoolCreate' | 'selectPoolReselect'
|
||||||
): { ok: true; generalId: number } => {
|
): { ok: true; generalId: number } => {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -67,13 +55,66 @@ const resolveSelectionRequestId = (
|
|||||||
if (!contextRequestId) {
|
if (!contextRequestId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const path =
|
const path = operation === 'create' ? 'join.selectPoolGeneral' : 'join.reselectPoolGeneral';
|
||||||
operation === 'create'
|
|
||||||
? 'join.selectPoolGeneral'
|
|
||||||
: 'join.reselectPoolGeneral';
|
|
||||||
return `${contextRequestId}:${path}`;
|
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 = {
|
const DEFAULT_JOIN_STAT = {
|
||||||
total: 165,
|
total: 165,
|
||||||
min: 15,
|
min: 15,
|
||||||
@@ -82,12 +123,8 @@ const DEFAULT_JOIN_STAT = {
|
|||||||
bonusMax: 5,
|
bonusMax: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildSpecialityAge = (
|
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||||
retirementYear: number,
|
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||||
age: number,
|
|
||||||
relativeYear: number,
|
|
||||||
divisor: number
|
|
||||||
): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
|
||||||
|
|
||||||
export const resolveJoinSpecialityAges = (options: {
|
export const resolveJoinSpecialityAges = (options: {
|
||||||
retirementYear: number;
|
retirementYear: number;
|
||||||
@@ -116,90 +153,30 @@ const resolveJoinStat = (worldState: WorldStateRow) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveJoinPolicy = (worldState: WorldStateRow) => {
|
const buildTurnTimeZones = (tickSeconds: number): string[] => {
|
||||||
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 zones: string[] = [];
|
const zones: string[] = [];
|
||||||
|
const legacyZoneSeconds = Math.max(1, Math.floor(tickSeconds / 60));
|
||||||
for (let i = 0; i < 60; i += 1) {
|
for (let i = 0; i < 60; i += 1) {
|
||||||
const totalMinutes = i * tickMinutes;
|
const startSeconds = i * legacyZoneSeconds;
|
||||||
const hour = Math.floor(totalMinutes / 60) % 24;
|
const endSeconds = startSeconds + legacyZoneSeconds - 1;
|
||||||
const minute = totalMinutes % 60;
|
const format = (totalSeconds: number, fraction: string): string =>
|
||||||
zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
`${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;
|
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;
|
let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null;
|
||||||
|
const zJoinPersonality = z.enum(['Random', ...JOIN_PERSONALITY_TRAIT_KEYS]);
|
||||||
|
|
||||||
const loadPersonalityOptions = async () => {
|
const loadPersonalityOptions = async () => {
|
||||||
if (cachedPersonalityOptions) {
|
if (cachedPersonalityOptions) {
|
||||||
return 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) => ({
|
cachedPersonalityOptions = modules.map((trait) => ({
|
||||||
key: trait.key,
|
key: trait.key,
|
||||||
name: trait.name,
|
name: trait.name,
|
||||||
@@ -233,8 +210,7 @@ export const joinRouter = router({
|
|||||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||||
|
|
||||||
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] =
|
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] = await Promise.all([
|
||||||
await Promise.all([
|
|
||||||
loadPersonalityOptions(),
|
loadPersonalityOptions(),
|
||||||
loadWarOptions(warKeys),
|
loadWarOptions(warKeys),
|
||||||
ctx.db.nation.findMany({
|
ctx.db.nation.findMany({
|
||||||
@@ -265,40 +241,24 @@ export const joinRouter = router({
|
|||||||
const inheritTotalPoint = ctx.auth?.user.id
|
const inheritTotalPoint = ctx.auth?.user.id
|
||||||
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
||||||
: 0;
|
: 0;
|
||||||
const selectionPool = await getSelectionPoolStatus(
|
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, ctx.auth?.user.id ?? '');
|
||||||
ctx.db,
|
|
||||||
worldState,
|
|
||||||
ctx.auth?.user.id ?? ''
|
|
||||||
);
|
|
||||||
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
||||||
const maxGeneral = resolveSelectionMaxGeneral(worldState);
|
const maxGeneral = resolveSelectionMaxGeneral(worldState);
|
||||||
const inheritCitiesRaw = await ctx.db.city.findMany({
|
const inheritCities = await ctx.db.city.findMany({
|
||||||
where: { level: { in: [5, 6] }, nationId: 0 },
|
|
||||||
select: { id: true, name: true, level: true, region: true },
|
select: { id: true, name: true, level: true, region: true },
|
||||||
orderBy: { id: 'asc' },
|
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 {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
stat: resolveJoinStat(worldState),
|
stat: resolveJoinStat(worldState),
|
||||||
allowCustomName: true,
|
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: ctx.auth?.user.id ?? '',
|
id: ctx.auth?.user.id ?? '',
|
||||||
displayName: ctx.auth?.user.displayName ?? '',
|
displayName: ctx.auth?.user.displayName ?? '',
|
||||||
},
|
},
|
||||||
personalities: [
|
personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities],
|
||||||
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
|
|
||||||
...personalities,
|
|
||||||
],
|
|
||||||
warSpecials,
|
warSpecials,
|
||||||
nations,
|
nations,
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
@@ -318,7 +278,7 @@ export const joinRouter = router({
|
|||||||
inheritBornStatPoint: inheritConst.inheritBornStatPoint,
|
inheritBornStatPoint: inheritConst.inheritBornStatPoint,
|
||||||
},
|
},
|
||||||
availableCities: inheritCities,
|
availableCities: inheritCities,
|
||||||
turnTimeZones: buildTurnTimeZones(tickMinutes),
|
turnTimeZones: buildTurnTimeZones(worldState.tickSeconds),
|
||||||
availableSpecialWar: warSpecials,
|
availableSpecialWar: warSpecials,
|
||||||
},
|
},
|
||||||
selectionPool,
|
selectionPool,
|
||||||
@@ -363,12 +323,7 @@ export const joinRouter = router({
|
|||||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const commandRequestId = resolveSelectionRequestId(
|
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||||
ctx.requestId,
|
|
||||||
userId,
|
|
||||||
input.clientRequestId,
|
|
||||||
'create'
|
|
||||||
);
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
@@ -408,15 +363,16 @@ export const joinRouter = router({
|
|||||||
});
|
});
|
||||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||||
}),
|
}),
|
||||||
createGeneral: authedProcedure
|
createGeneral: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().min(1).max(18),
|
name: z.string().min(1).max(18),
|
||||||
leadership: z.number().int(),
|
leadership: z.number().int(),
|
||||||
strength: z.number().int(),
|
strength: z.number().int(),
|
||||||
intel: z.number().int(),
|
intel: z.number().int(),
|
||||||
pic: z.boolean().optional(),
|
pic: z.boolean(),
|
||||||
character: z.string(),
|
character: zJoinPersonality,
|
||||||
|
clientRequestId: z.string().uuid().optional(),
|
||||||
inheritSpecial: z.string().optional(),
|
inheritSpecial: z.string().optional(),
|
||||||
inheritTurntimeZone: z.number().int().optional(),
|
inheritTurntimeZone: z.number().int().optional(),
|
||||||
inheritCity: z.number().int().optional(),
|
inheritCity: z.number().int().optional(),
|
||||||
@@ -424,337 +380,49 @@ export const joinRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const auth = ctx.auth;
|
||||||
if (!userId) {
|
if (!auth) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
if (ctx.auth?.identity?.canCreateGeneral === false) {
|
if (auth.identity?.canCreateGeneral === false) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const userId = auth.user.id;
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||||
if (!worldState) {
|
const result = await requestJoinCreateCommand(ctx, {
|
||||||
throw new TRPCError({
|
type: 'joinCreateGeneral',
|
||||||
code: 'PRECONDITION_FAILED',
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
message: 'World state is not initialized.',
|
userId,
|
||||||
});
|
ownerDisplayName: auth.user.displayName,
|
||||||
}
|
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||||
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, userId);
|
name: input.name,
|
||||||
if (selectionPool.enabled) {
|
leadership: input.leadership,
|
||||||
throw new TRPCError({
|
strength: input.strength,
|
||||||
code: 'PRECONDITION_FAILED',
|
intel: input.intel,
|
||||||
message: '장수 선택 목록에서 장수를 골라 주세요.',
|
pic: input.pic,
|
||||||
});
|
character: input.character,
|
||||||
}
|
profileId: ctx.profile.id,
|
||||||
|
...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}),
|
||||||
const joinPolicy = resolveJoinPolicy(worldState);
|
...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}),
|
||||||
if (joinPolicy.blockGeneralCreate === 1) {
|
...(auth.user.canUseGeneralPicture !== undefined
|
||||||
throw new TRPCError({
|
? {
|
||||||
code: 'FORBIDDEN',
|
ownerCanUsePicture: auth.user.canUseGeneralPicture,
|
||||||
message: '장수 생성이 제한된 서버입니다.',
|
}
|
||||||
});
|
: {}),
|
||||||
}
|
...(auth.sanctions.legacyPenalty !== undefined
|
||||||
|
? {
|
||||||
const inheritConst = resolveInheritConstants(worldState);
|
ownerLegacyPenalty: auth.sanctions.legacyPenalty,
|
||||||
const configConst = asRecord(asRecord(worldState.config).const);
|
}
|
||||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
: {}),
|
||||||
const inheritBonus = input.inheritBonusStat ?? null;
|
...(input.inheritSpecial !== undefined ? { inheritSpecial: input.inheritSpecial } : {}),
|
||||||
if (inheritBonus) {
|
...(input.inheritTurntimeZone !== undefined ? { inheritTurntimeZone: input.inheritTurntimeZone } : {}),
|
||||||
const bonusSum = inheritBonus.reduce((acc, value) => acc + value, 0);
|
...(input.inheritCity !== undefined ? { inheritCity: input.inheritCity } : {}),
|
||||||
if (inheritBonus.some((value) => value < 0)) {
|
...(input.inheritBonusStat !== undefined ? { inheritBonusStat: input.inheritBonusStat } : {}),
|
||||||
throw new TRPCError({
|
});
|
||||||
code: 'BAD_REQUEST',
|
return resolveJoinCreateCommandResult(result);
|
||||||
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);
|
|
||||||
}),
|
}),
|
||||||
listPossessCandidates: authedProcedure
|
listPossessCandidates: authedProcedure
|
||||||
.input(
|
.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);
|
||||||
|
});
|
||||||
@@ -281,9 +281,9 @@ describe('appRouter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
||||||
await expect(
|
await expect(appRouter.createCaller(buildContext({ auth: null })).auth.status()).rejects.toMatchObject({
|
||||||
appRouter.createCaller(buildContext({ auth: null })).auth.status()
|
code: 'UNAUTHORIZED',
|
||||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter
|
||||||
.createCaller(
|
.createCaller(
|
||||||
@@ -346,6 +346,7 @@ describe('appRouter', () => {
|
|||||||
leadership: 55,
|
leadership: 55,
|
||||||
strength: 55,
|
strength: 55,
|
||||||
intel: 55,
|
intel: 55,
|
||||||
|
pic: false,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import {
|
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||||
createTurnDaemonRuntime,
|
|
||||||
seedScenarioToDatabase,
|
|
||||||
type TurnDaemonRuntime,
|
|
||||||
} from '@sammo-ts/game-engine';
|
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
type GamePrisma,
|
type GamePrisma,
|
||||||
@@ -29,7 +25,7 @@ const otherUserId = 'select-pool-integration-other-user';
|
|||||||
const foreignUserId = 'select-pool-integration-foreign-user';
|
const foreignUserId = 'select-pool-integration-foreign-user';
|
||||||
const failureUserId = 'select-pool-integration-failure-user';
|
const failureUserId = 'select-pool-integration-failure-user';
|
||||||
const profile = 'hwe:903';
|
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 assertDedicatedDatabase = (rawUrl: string): void => {
|
||||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
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 turnDaemon: TurnDaemonTransport;
|
||||||
let worldStateId: number;
|
let worldStateId: number;
|
||||||
|
|
||||||
const buildContext = (
|
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||||
requestId: string,
|
|
||||||
actorAuth: GameSessionTokenPayload = auth
|
|
||||||
): GameApiContext => {
|
|
||||||
const redisClient = {
|
const redisClient = {
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
set: 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 },
|
select: { nationId: true, type: true, value: true },
|
||||||
});
|
});
|
||||||
expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length);
|
expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length);
|
||||||
expect(initialRankRows.map(({ type }) => type).sort()).toEqual(
|
expect(initialRankRows.map(({ type }) => type).sort()).toEqual([...RANK_DATA_TYPES].sort());
|
||||||
[...RANK_DATA_TYPES].sort()
|
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true);
|
||||||
);
|
|
||||||
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: { generalId: initial.id } })).toBe(1);
|
||||||
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
|
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
@@ -293,9 +282,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
const reselection = await appRouter
|
const reselection = await appRouter
|
||||||
.createCaller(buildContext('select-pool-reserve-reselection'))
|
.createCaller(buildContext('select-pool-reserve-reselection'))
|
||||||
.join.getSelectionPool();
|
.join.getSelectionPool();
|
||||||
const target = reselection.candidates.find(
|
const target = reselection.candidates.find((candidate) => candidate.generalName !== initial.name)!;
|
||||||
(candidate) => candidate.generalName !== initial.name
|
|
||||||
)!;
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter
|
||||||
.createCaller(buildContext('select-pool-reselect'))
|
.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.count({ where: { generalId: initial.id } })).toBe(1);
|
||||||
expect(
|
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })).toMatchObject({
|
||||||
await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })
|
generalId: initial.id,
|
||||||
).toMatchObject({ generalId: initial.id, ownerUserId: null, reservedUntil: null });
|
ownerUserId: null,
|
||||||
|
reservedUntil: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
await db.logEntry.count({
|
await db.logEntry.count({
|
||||||
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
||||||
@@ -345,9 +334,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
patch: { meta: { postReselectionFlush: 1 } },
|
patch: { meta: { postReselectionFlush: 1 } },
|
||||||
})
|
})
|
||||||
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||||
await expect(
|
await expect(db.general.findUniqueOrThrow({ where: { id: initial.id } })).resolves.toMatchObject({
|
||||||
db.general.findUniqueOrThrow({ where: { id: initial.id } })
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
name: target.generalName,
|
name: target.generalName,
|
||||||
leadership: target.leadership,
|
leadership: target.leadership,
|
||||||
strength: target.strength,
|
strength: target.strength,
|
||||||
@@ -376,23 +363,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
.createCaller(buildContext('select-pool-full-reselection-reserve'))
|
.createCaller(buildContext('select-pool-full-reselection-reserve'))
|
||||||
.join.getSelectionPool();
|
.join.getSelectionPool();
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-full-reselection')).join.reselectPoolGeneral({
|
||||||
.createCaller(buildContext('select-pool-full-reselection'))
|
uniqueName: fullReselection.candidates[0]!.uniqueName,
|
||||||
.join.reselectPoolGeneral({
|
})
|
||||||
uniqueName: fullReselection.candidates[0]!.uniqueName,
|
|
||||||
})
|
|
||||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||||
|
|
||||||
const otherReservation = await appRouter
|
const otherReservation = await appRouter
|
||||||
.createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth))
|
.createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth))
|
||||||
.join.getSelectionPool();
|
.join.getSelectionPool();
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-full-new-user-create', otherAuth)).join.selectPoolGeneral({
|
||||||
.createCaller(buildContext('select-pool-full-new-user-create', otherAuth))
|
uniqueName: otherReservation.candidates[0]!.uniqueName,
|
||||||
.join.selectPoolGeneral({
|
personality: 'che_안전',
|
||||||
uniqueName: otherReservation.candidates[0]!.uniqueName,
|
})
|
||||||
personality: 'che_안전',
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' });
|
).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' });
|
||||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
||||||
await db.worldState.update({
|
await db.worldState.update({
|
||||||
@@ -408,12 +391,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
const candidate = reservation.candidates[0]!;
|
const candidate = reservation.candidates[0]!;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-foreign-token', foreignAuth)).join.selectPoolGeneral({
|
||||||
.createCaller(buildContext('select-pool-foreign-token', foreignAuth))
|
uniqueName: candidate.uniqueName,
|
||||||
.join.selectPoolGeneral({
|
personality: 'che_안전',
|
||||||
uniqueName: candidate.uniqueName,
|
})
|
||||||
personality: 'che_안전',
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
|
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
|
||||||
expect(await db.general.count({ where: { userId: foreignUserId } })).toBe(0);
|
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) },
|
data: { reservedUntil: new Date(Date.now() - 60_000) },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
|
||||||
.createCaller(buildContext('select-pool-expired-token', otherAuth))
|
uniqueName: candidate.uniqueName,
|
||||||
.join.selectPoolGeneral({
|
personality: 'che_안전',
|
||||||
uniqueName: candidate.uniqueName,
|
})
|
||||||
personality: 'che_안전',
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
|
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
|
||||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-generic-bypass', otherAuth)).join.createGeneral({
|
||||||
.createCaller(buildContext('select-pool-generic-bypass', otherAuth))
|
name: '우회장수',
|
||||||
.join.createGeneral({
|
leadership: 55,
|
||||||
name: '우회장수',
|
strength: 55,
|
||||||
leadership: 55,
|
intel: 55,
|
||||||
strength: 55,
|
pic: false,
|
||||||
intel: 55,
|
character: 'che_안전',
|
||||||
character: 'che_안전',
|
})
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' });
|
).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' });
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
@@ -453,24 +431,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
});
|
});
|
||||||
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
|
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
|
||||||
const persistedAllocatorBefore = (
|
const persistedAllocatorBefore = (
|
||||||
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
|
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record<string, unknown>
|
||||||
.meta as Record<string, unknown>
|
|
||||||
).lastGeneralId;
|
).lastGeneralId;
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-invalid-personality', otherAuth)).join.selectPoolGeneral({
|
||||||
.createCaller(buildContext('select-pool-invalid-personality', otherAuth))
|
...input,
|
||||||
.join.selectPoolGeneral({
|
personality: 'not-a-personality',
|
||||||
...input,
|
clientRequestId: '11111111-1111-4111-8111-111111111111',
|
||||||
personality: 'not-a-personality',
|
})
|
||||||
clientRequestId: '11111111-1111-4111-8111-111111111111',
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' });
|
).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' });
|
||||||
expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore);
|
expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore);
|
||||||
expect(
|
expect(
|
||||||
(
|
((await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record<string, unknown>)
|
||||||
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
|
.lastGeneralId
|
||||||
.meta as Record<string, unknown>
|
|
||||||
).lastGeneralId
|
|
||||||
).toBe(persistedAllocatorBefore);
|
).toBe(persistedAllocatorBefore);
|
||||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
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 {
|
try {
|
||||||
await expect(
|
await expect(
|
||||||
appRouter
|
appRouter.createCaller(buildContext('select-pool-failure-http', failureAuth)).join.selectPoolGeneral({
|
||||||
.createCaller(buildContext('select-pool-failure-http', failureAuth))
|
uniqueName: candidate.uniqueName,
|
||||||
.join.selectPoolGeneral({
|
personality: 'che_안전',
|
||||||
uniqueName: candidate.uniqueName,
|
clientRequestId: requestUuid,
|
||||||
personality: 'che_안전',
|
})
|
||||||
clientRequestId: requestUuid,
|
|
||||||
})
|
|
||||||
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
|
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
|
||||||
} finally {
|
} finally {
|
||||||
await db.$executeRawUnsafe(
|
await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`);
|
||||||
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`
|
await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`);
|
||||||
);
|
|
||||||
await db.$executeRawUnsafe(
|
|
||||||
`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await db.general.findFirstOrThrow({ where: { userId: failureUserId } });
|
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.general.count({ where: { userId: failureUserId } })).toBe(1);
|
||||||
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
|
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.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1);
|
||||||
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(
|
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
|
||||||
RANK_DATA_TYPES.length
|
|
||||||
);
|
|
||||||
expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1);
|
expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1);
|
||||||
expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1);
|
expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
@@ -570,9 +535,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
where: { meta: { path: ['ownerUserId'], equals: failureUserId } },
|
where: { meta: { path: ['ownerUserId'], equals: failureUserId } },
|
||||||
})
|
})
|
||||||
).toBe(2);
|
).toBe(2);
|
||||||
await expect(
|
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||||
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
attempts: 2,
|
attempts: 2,
|
||||||
actorUserId: failureUserId,
|
actorUserId: failureUserId,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export * from './turn/engineStateManager.js';
|
|||||||
export * from './turn/inMemoryStateStore.js';
|
export * from './turn/inMemoryStateStore.js';
|
||||||
export * from './turn/inMemoryTurnProcessor.js';
|
export * from './turn/inMemoryTurnProcessor.js';
|
||||||
export * from './turn/databaseHooks.js';
|
export * from './turn/databaseHooks.js';
|
||||||
|
export * from './turn/joinCreateGeneralService.js';
|
||||||
export * from './turn/selectPoolService.js';
|
export * from './turn/selectPoolService.js';
|
||||||
export * from './turn/turnDaemon.js';
|
export * from './turn/turnDaemon.js';
|
||||||
export * from './turn/cli.js';
|
export * from './turn/cli.js';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
buildScenarioBootstrap,
|
buildScenarioBootstrap,
|
||||||
resolveScenarioGeneralDeathMonth,
|
resolveScenarioGeneralDeathMonth,
|
||||||
@@ -233,7 +233,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
const tickSeconds =
|
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 turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||||
const sync = install?.sync ?? false;
|
const sync = install?.sync ?? false;
|
||||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
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 generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||||
|
|
||||||
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
||||||
if (
|
if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) {
|
||||||
typeof scenarioConst.openingPartYear !== 'number' ||
|
|
||||||
Number.isNaN(scenarioConst.openingPartYear)
|
|
||||||
) {
|
|
||||||
scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR;
|
scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR;
|
||||||
}
|
}
|
||||||
const scenarioConfig = {
|
const scenarioConfig = {
|
||||||
@@ -268,6 +267,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
const worldMeta: Record<string, unknown> = {
|
const worldMeta: Record<string, unknown> = {
|
||||||
scenarioId: options.scenarioId,
|
scenarioId: options.scenarioId,
|
||||||
scenarioMeta: seed.scenarioMeta,
|
scenarioMeta: seed.scenarioMeta,
|
||||||
|
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
|
||||||
starttime: formatDateTime(startState.startTime),
|
starttime: formatDateTime(startState.startTime),
|
||||||
turntime: formatDateTime(now),
|
turntime: formatDateTime(now),
|
||||||
opentime: 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();
|
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim();
|
||||||
worldMeta.hiddenSeed =
|
worldMeta.hiddenSeed =
|
||||||
integrationSeed && integrationSeed.length > 0
|
integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex');
|
||||||
? integrationSeed
|
|
||||||
: randomBytes(16).toString('hex');
|
|
||||||
|
|
||||||
if (install?.preopenAt) {
|
if (install?.preopenAt) {
|
||||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||||
@@ -484,8 +482,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
: resolveScenarioGeneralDeathMonth({
|
: resolveScenarioGeneralDeathMonth({
|
||||||
scenarioTitle: String(seed.scenarioMeta?.title ?? ''),
|
scenarioTitle: String(seed.scenarioMeta?.title ?? ''),
|
||||||
startYear: seed.scenarioMeta?.startYear ?? null,
|
startYear: seed.scenarioMeta?.startYear ?? null,
|
||||||
contextLabel:
|
contextLabel: typeof meta.source === 'string' ? meta.source : 'general',
|
||||||
typeof meta.source === 'string' ? meta.source : 'general',
|
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
deathYear: general.deathYear,
|
deathYear: general.deathYear,
|
||||||
|
|||||||
@@ -243,6 +243,31 @@ const zPatchGeneral = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zJoinCreateGeneral = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('joinCreateGeneral'),
|
||||||
|
requestId: z.string().optional(),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
ownerDisplayName: z.string(),
|
||||||
|
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||||
|
name: z.string().min(1).max(18),
|
||||||
|
leadership: zFiniteNumber,
|
||||||
|
strength: zFiniteNumber,
|
||||||
|
intel: zFiniteNumber,
|
||||||
|
pic: z.boolean(),
|
||||||
|
character: z.string().min(1),
|
||||||
|
profileId: z.string().min(1),
|
||||||
|
ownerPicture: z.string().optional(),
|
||||||
|
ownerImageServer: zFiniteNumber.optional(),
|
||||||
|
ownerCanUsePicture: z.boolean().optional(),
|
||||||
|
ownerLegacyPenalty: zRecord.optional(),
|
||||||
|
inheritSpecial: z.string().optional(),
|
||||||
|
inheritTurntimeZone: zFiniteNumber.optional(),
|
||||||
|
inheritCity: zFiniteNumber.optional(),
|
||||||
|
inheritBonusStat: z.tuple([zFiniteNumber, zFiniteNumber, zFiniteNumber]).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const zSelectPoolCreate = z
|
const zSelectPoolCreate = z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal('selectPoolCreate'),
|
type: z.literal('selectPoolCreate'),
|
||||||
@@ -295,7 +320,12 @@ const zShutdown = z.object({
|
|||||||
const zShiftSchedule = z.object({
|
const zShiftSchedule = z.object({
|
||||||
type: z.literal('shiftSchedule'),
|
type: z.literal('shiftSchedule'),
|
||||||
actionId: z.string().uuid(),
|
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) => {
|
const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope) => {
|
||||||
@@ -506,6 +536,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
|
|||||||
return { ...command, requestId: envelope.requestId };
|
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 normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => {
|
||||||
const command = parseWith(zSelectPoolCreate, envelope.command);
|
const command = parseWith(zSelectPoolCreate, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -585,6 +623,7 @@ const normalizers: CommandNormalizerMap = {
|
|||||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||||
patchGeneral: normalizePatchGeneral,
|
patchGeneral: normalizePatchGeneral,
|
||||||
|
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||||
selectPoolCreate: normalizeSelectPoolCreate,
|
selectPoolCreate: normalizeSelectPoolCreate,
|
||||||
selectPoolReselect: normalizeSelectPoolReselect,
|
selectPoolReselect: normalizeSelectPoolReselect,
|
||||||
getStatus: normalizeGetStatus,
|
getStatus: normalizeGetStatus,
|
||||||
|
|||||||
@@ -302,7 +302,13 @@ const buildPersistedGeneralMeta = (
|
|||||||
const buildInitialRankRows = (
|
const buildInitialRankRows = (
|
||||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||||
): Array<{ generalId: number; nationId: number; type: string; value: 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;
|
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||||
|
|
||||||
@@ -362,6 +368,7 @@ const buildGeneralUpdate = (
|
|||||||
specialCode: toCode(general.role.specialDomestic),
|
specialCode: toCode(general.role.specialDomestic),
|
||||||
special2Code: toCode(general.role.specialWar),
|
special2Code: toCode(general.role.specialWar),
|
||||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||||
|
penalty: asJson(general.penalty ?? {}),
|
||||||
meta: buildPersistedGeneralMeta(general),
|
meta: buildPersistedGeneralMeta(general),
|
||||||
turnTime: general.turnTime,
|
turnTime: general.turnTime,
|
||||||
recentWarTime: general.recentWarTime ?? null,
|
recentWarTime: general.recentWarTime ?? null,
|
||||||
@@ -405,6 +412,7 @@ const buildGeneralCreate = (
|
|||||||
specialCode: toCode(general.role.specialDomestic),
|
specialCode: toCode(general.role.specialDomestic),
|
||||||
special2Code: toCode(general.role.specialWar),
|
special2Code: toCode(general.role.specialWar),
|
||||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||||
|
penalty: asJson(general.penalty ?? {}),
|
||||||
meta: buildPersistedGeneralMeta(general),
|
meta: buildPersistedGeneralMeta(general),
|
||||||
turnTime: general.turnTime,
|
turnTime: general.turnTime,
|
||||||
recentWarTime: general.recentWarTime ?? null,
|
recentWarTime: general.recentWarTime ?? null,
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
import type {
|
||||||
|
City,
|
||||||
|
LogEntryDraft,
|
||||||
|
MessageDraft,
|
||||||
|
Nation,
|
||||||
|
ScenarioConfig,
|
||||||
|
Troop,
|
||||||
|
TurnSchedule,
|
||||||
|
UnitSetDefinition,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||||
@@ -338,12 +347,14 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
|
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
|
||||||
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
|
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
|
||||||
private readonly scenarioConfig: ScenarioConfig;
|
private readonly scenarioConfig: ScenarioConfig;
|
||||||
|
private readonly unitSet?: UnitSetDefinition;
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
private state: TurnWorldState;
|
private state: TurnWorldState;
|
||||||
|
|
||||||
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
|
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
|
||||||
this.state = { ...state };
|
this.state = { ...state };
|
||||||
this.scenarioConfig = snapshot.scenarioConfig;
|
this.scenarioConfig = snapshot.scenarioConfig;
|
||||||
|
this.unitSet = snapshot.unitSet;
|
||||||
this.schedule = options.schedule;
|
this.schedule = options.schedule;
|
||||||
this.generalTurnHandler =
|
this.generalTurnHandler =
|
||||||
options.generalTurnHandler ??
|
options.generalTurnHandler ??
|
||||||
@@ -547,6 +558,10 @@ export class InMemoryTurnWorld {
|
|||||||
return this.scenarioConfig;
|
return this.scenarioConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUnitSet(): UnitSetDefinition | undefined {
|
||||||
|
return this.unitSet;
|
||||||
|
}
|
||||||
|
|
||||||
getGeneralById(id: number): TurnGeneral | null {
|
getGeneralById(id: number): TurnGeneral | null {
|
||||||
return this.generals.get(id) ?? null;
|
return this.generals.get(id) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,857 @@
|
|||||||
|
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import {
|
||||||
|
ActionLogger,
|
||||||
|
buildAuctionAlias,
|
||||||
|
buildAuctionAliasPool,
|
||||||
|
getLegacyStringWidth,
|
||||||
|
isPersonalityTraitKey,
|
||||||
|
isWarTraitKey,
|
||||||
|
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||||
|
loadWarTraitModules,
|
||||||
|
LogFormat,
|
||||||
|
normalizeTroopName,
|
||||||
|
simpleSerialize,
|
||||||
|
TraitSelector,
|
||||||
|
WarTraitLoader,
|
||||||
|
WAR_TRAIT_KEYS,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import type { WarTraitKey } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||||
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
|
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||||
|
|
||||||
|
export type JoinCreateGeneralErrorCode =
|
||||||
|
'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||||
|
|
||||||
|
export class JoinCreateGeneralError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: JoinCreateGeneralErrorCode,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JoinCreateGeneralError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinCreateGeneralInput {
|
||||||
|
userId: string;
|
||||||
|
ownerDisplayName: string;
|
||||||
|
seedOwnerIdentity: string | number;
|
||||||
|
name: string;
|
||||||
|
leadership: number;
|
||||||
|
strength: number;
|
||||||
|
intel: number;
|
||||||
|
pic: boolean;
|
||||||
|
character: string;
|
||||||
|
profileId: string;
|
||||||
|
ownerPicture?: string;
|
||||||
|
ownerImageServer?: number;
|
||||||
|
ownerCanUsePicture?: boolean;
|
||||||
|
ownerLegacyPenalty?: Record<string, unknown>;
|
||||||
|
inheritSpecial?: string;
|
||||||
|
inheritTurntimeZone?: number;
|
||||||
|
inheritCity?: number;
|
||||||
|
inheritBonusStat?: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InheritConstants {
|
||||||
|
inheritBornSpecialPoint: number;
|
||||||
|
inheritBornTurntimePoint: number;
|
||||||
|
inheritBornCityPoint: number;
|
||||||
|
inheritBornStatPoint: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_JOIN_STAT = {
|
||||||
|
total: 165,
|
||||||
|
min: 15,
|
||||||
|
max: 80,
|
||||||
|
};
|
||||||
|
const DEFAULT_MAX_GENERAL = 500;
|
||||||
|
const DEFAULT_MAX_GENIUS = 5;
|
||||||
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
|
const DEFAULT_GENERAL_RICE = 1000;
|
||||||
|
const DEFAULT_CREW_TYPE_ID = 1100;
|
||||||
|
const MAX_GENERAL_TURNS = 30;
|
||||||
|
const DEFAULT_TURN_ACTION = '휴식';
|
||||||
|
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||||
|
const LEGACY_JOIN_REMOVED_CHARACTERS = /[ⓝⓜⓖⓞⓧ㉥\\/`#|-]/gu;
|
||||||
|
|
||||||
|
const fail = (code: JoinCreateGeneralErrorCode, message: string): never => {
|
||||||
|
throw new JoinCreateGeneralError(code, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeJoinName = (value: string): string =>
|
||||||
|
normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, '');
|
||||||
|
|
||||||
|
const resolveLegacyPenalty = (
|
||||||
|
rawPenalty: Record<string, unknown> | undefined,
|
||||||
|
profileId: string,
|
||||||
|
acceptedAt: Date
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
if (!rawPenalty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const merged = {
|
||||||
|
...asRecord(rawPenalty.any),
|
||||||
|
...asRecord(rawPenalty[profileId]),
|
||||||
|
};
|
||||||
|
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, rawEntry] of Object.entries(merged)) {
|
||||||
|
const entry = asRecord(rawEntry);
|
||||||
|
if (asNumber(entry.expire, 0) > acceptedAtSeconds && Object.hasOwn(entry, 'value')) {
|
||||||
|
result[key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveStatRule = (worldState: WorldStateRow) => {
|
||||||
|
const stat = asRecord(asRecord(worldState.config).stat);
|
||||||
|
return {
|
||||||
|
total: asNumber(stat.total, DEFAULT_JOIN_STAT.total),
|
||||||
|
min: asNumber(stat.min, DEFAULT_JOIN_STAT.min),
|
||||||
|
max: asNumber(stat.max, DEFAULT_JOIN_STAT.max),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants => {
|
||||||
|
const configConst = asRecord(asRecord(worldState.config).const);
|
||||||
|
return {
|
||||||
|
inheritBornSpecialPoint: asNumber(configConst.inheritBornSpecialPoint, 6000),
|
||||||
|
inheritBornTurntimePoint: asNumber(configConst.inheritBornTurntimePoint, 2500),
|
||||||
|
inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, 1000),
|
||||||
|
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, 1000),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
|
||||||
|
const config = asRecord(worldState.config);
|
||||||
|
const map = asRecord(config.map);
|
||||||
|
return asNumber(config.npcMode, 0) === 2 && map.targetGeneralPool === 'SPoolUnderU30';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveMaxGeneral = (worldState: WorldStateRow): number => {
|
||||||
|
const config = asRecord(worldState.config);
|
||||||
|
const configConst = asRecord(config.const);
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||||
|
const meta = asRecord(worldState.meta);
|
||||||
|
const value = meta.hiddenSeed ?? meta.seed;
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return fail('INTERNAL_SERVER_ERROR', '장수 생성 비밀 seed가 설정되지 않았습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLegacySeedTime = (value: Date): string => {
|
||||||
|
const pad = (part: number): string => String(part).padStart(2, '0');
|
||||||
|
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||||
|
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
||||||
|
koreaTime.getUTCDate()
|
||||||
|
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildJoinCreateGeneralSeed = (
|
||||||
|
hiddenSeed: string | number,
|
||||||
|
ownerIdentity: string | number,
|
||||||
|
acceptedAt: Date
|
||||||
|
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, formatLegacySeedTime(acceptedAt));
|
||||||
|
|
||||||
|
const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||||
|
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`);
|
||||||
|
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise<void> => {
|
||||||
|
const persistedIds = (
|
||||||
|
await db.general.findMany({
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
})
|
||||||
|
).map(({ id }) => id);
|
||||||
|
const runtimeIds = world
|
||||||
|
.listGenerals()
|
||||||
|
.map(({ id }) => id)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) {
|
||||||
|
throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyInheritanceUser = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
userId: string,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): Promise<number> => {
|
||||||
|
const rows = await db.$queryRaw<Array<{ key: string; value: number }>>(
|
||||||
|
GamePrisma.sql`
|
||||||
|
SELECT key, value
|
||||||
|
FROM inheritance_point
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
ORDER BY id ASC
|
||||||
|
FOR UPDATE
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (rows.length === 1 && rows[0]?.key === 'previous') {
|
||||||
|
return rows[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPoint = rows.find((row) => row.key === 'previous')?.value ?? 0;
|
||||||
|
const totalPoint = Math.trunc(rows.reduce((sum, row) => sum + row.value, 0));
|
||||||
|
for (const row of rows) {
|
||||||
|
await db.inheritanceLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
text: `${row.key} 포인트 ${row.value} 증가`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.inheritanceLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
text: `포인트 ${previousPoint} => ${totalPoint}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.inheritancePoint.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
key: { not: 'previous' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await setInheritancePoint(db, userId, totalPoint);
|
||||||
|
return totalPoint;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setInheritancePoint = async (db: DatabaseClient, userId: string, value: number): Promise<void> => {
|
||||||
|
await db.inheritancePoint.upsert({
|
||||||
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
|
update: { value },
|
||||||
|
create: { userId, key: 'previous', value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendInheritanceLog = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
userId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
text: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.inheritanceLog.create({
|
||||||
|
data: { userId, year, month, text },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateInheritanceCost = (
|
||||||
|
input: JoinCreateGeneralInput,
|
||||||
|
constants: InheritConstants,
|
||||||
|
inheritBonus: [number, number, number] | null
|
||||||
|
): number => {
|
||||||
|
let required = 0;
|
||||||
|
if (input.inheritCity !== undefined) {
|
||||||
|
required += constants.inheritBornCityPoint;
|
||||||
|
}
|
||||||
|
if (inheritBonus) {
|
||||||
|
required += constants.inheritBornStatPoint;
|
||||||
|
}
|
||||||
|
if (input.inheritSpecial !== undefined) {
|
||||||
|
required += constants.inheritBornSpecialPoint;
|
||||||
|
}
|
||||||
|
if (input.inheritTurntimeZone !== undefined) {
|
||||||
|
required += constants.inheritBornTurntimePoint;
|
||||||
|
}
|
||||||
|
return required;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAndNormalizeBonus = (bonus: [number, number, number] | undefined): [number, number, number] | null => {
|
||||||
|
if (!bonus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (bonus.some((value) => !Number.isInteger(value) || value < 0)) {
|
||||||
|
fail('BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
const sum = bonus.reduce((acc, value) => acc + value, 0);
|
||||||
|
if (sum === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (sum < 3 || sum > 5) {
|
||||||
|
fail('BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRandomBonus = (rng: RandUtil, baseStats: [number, number, number]): [number, number, number] => {
|
||||||
|
const result: [number, number, number] = [0, 0, 0];
|
||||||
|
const count = rng.nextRangeInt(3, 5);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const selected = Number(
|
||||||
|
rng.choiceUsingWeight({
|
||||||
|
0: baseStats[0],
|
||||||
|
1: baseStats[1],
|
||||||
|
2: baseStats[2],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
result[selected as 0 | 1 | 2] += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRelativeYear = (worldState: WorldStateRow): number => {
|
||||||
|
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
|
||||||
|
const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear);
|
||||||
|
return Math.max(worldState.currentYear - startYear, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||||
|
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||||
|
|
||||||
|
export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
||||||
|
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||||
|
const baseTime =
|
||||||
|
Date.UTC(koreaTime.getUTCFullYear(), koreaTime.getUTCMonth(), koreaTime.getUTCDate() - 1, 1) -
|
||||||
|
LEGACY_TIMEZONE_OFFSET_MS;
|
||||||
|
const elapsedSeconds = Math.floor((value.getTime() - baseTime) / 1000);
|
||||||
|
const alignedSeconds = elapsedSeconds - (elapsedSeconds % tickSeconds);
|
||||||
|
return new Date(baseTime + alignedSeconds * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTurnTime = (
|
||||||
|
rng: RandUtil,
|
||||||
|
worldState: WorldStateRow,
|
||||||
|
acceptedAt: Date,
|
||||||
|
runtimeTurnTime: Date,
|
||||||
|
inheritTurntimeZone: number | undefined
|
||||||
|
): Date => {
|
||||||
|
const tickSeconds = Math.max(1, Math.floor(worldState.tickSeconds));
|
||||||
|
const base = runtimeTurnTime;
|
||||||
|
let offsetSeconds: number;
|
||||||
|
let offsetMicros: number;
|
||||||
|
let turnTimeBase: Date;
|
||||||
|
if (inheritTurntimeZone !== undefined) {
|
||||||
|
const legacyTurnTermMinutes = Math.max(1, Math.floor(tickSeconds / 60));
|
||||||
|
turnTimeBase = cutJoinTurnTime(base, tickSeconds);
|
||||||
|
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||||
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
|
} else {
|
||||||
|
turnTimeBase = base;
|
||||||
|
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||||
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
|
}
|
||||||
|
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
|
||||||
|
if (turnTime.getTime() <= acceptedAt.getTime()) {
|
||||||
|
turnTime = new Date(turnTime.getTime() + tickSeconds * 1000);
|
||||||
|
}
|
||||||
|
return turnTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCatchupExperience = async (db: DatabaseClient, relativeYear: number): Promise<number> => {
|
||||||
|
if (relativeYear < 3) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const count = await db.general.count({
|
||||||
|
where: { nationId: { not: 0 }, npcState: { lt: 4 } },
|
||||||
|
});
|
||||||
|
if (count === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const order = Math.max(1, Math.round(count * 0.2));
|
||||||
|
const row = await db.general.findFirst({
|
||||||
|
where: { nationId: { not: 0 }, npcState: { lt: 4 } },
|
||||||
|
orderBy: [{ experience: 'asc' }, { id: 'asc' }],
|
||||||
|
skip: order - 1,
|
||||||
|
select: { experience: true },
|
||||||
|
});
|
||||||
|
return Math.round((row?.experience ?? 0) * 0.8);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRestInheritanceBonus = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
worldState: WorldStateRow,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> => {
|
||||||
|
const meta = asRecord(worldState.meta);
|
||||||
|
const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
|
||||||
|
const season = Math.floor(asNumber(meta.season, 0));
|
||||||
|
if (!serverId || season < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(await db.inheritanceResult.count({
|
||||||
|
where: { serverId, owner: userId },
|
||||||
|
})) > 0
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const oldGames = await db.gameHistory.findMany({
|
||||||
|
where: {
|
||||||
|
winnerNation: { not: null },
|
||||||
|
season: { gte: 1 },
|
||||||
|
serverId: { not: serverId },
|
||||||
|
},
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 8,
|
||||||
|
select: { serverId: true },
|
||||||
|
});
|
||||||
|
if (oldGames.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const oldServerIds = oldGames.map(({ serverId: oldServerId }) => oldServerId);
|
||||||
|
const playedRows = await db.oldGeneral.findMany({
|
||||||
|
where: {
|
||||||
|
owner: userId,
|
||||||
|
serverId: { in: oldServerIds },
|
||||||
|
},
|
||||||
|
distinct: ['serverId'],
|
||||||
|
select: { serverId: true },
|
||||||
|
});
|
||||||
|
const played = new Set(playedRows.map(({ serverId: oldServerId }) => oldServerId));
|
||||||
|
let missedGames = 0;
|
||||||
|
for (const { serverId: oldServerId } of oldGames) {
|
||||||
|
if (played.has(oldServerId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
missedGames += 1;
|
||||||
|
}
|
||||||
|
return missedGames * 500;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushCreationLogs = (
|
||||||
|
world: InMemoryTurnWorld,
|
||||||
|
options: {
|
||||||
|
generalId: number;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
cityName: string;
|
||||||
|
bonus: [number, number, number];
|
||||||
|
age: number;
|
||||||
|
genius: boolean;
|
||||||
|
specialWarName: string;
|
||||||
|
}
|
||||||
|
): void => {
|
||||||
|
const logger = new ActionLogger({ generalId: options.generalId });
|
||||||
|
const josaRa = JosaUtil.pick(options.name, '라');
|
||||||
|
if (options.genius) {
|
||||||
|
logger.pushGlobalActionLog(
|
||||||
|
`<G><b>${options.cityName}</b></>에서 <Y>${options.name}</>${josaRa}는 기재가 천하에 이름을 알립니다.`
|
||||||
|
);
|
||||||
|
logger.pushGlobalActionLog(
|
||||||
|
`<C>${options.specialWarName}</> 특기를 가진 <C>천재</>의 등장으로 온 천하가 떠들썩합니다.`
|
||||||
|
);
|
||||||
|
logger.pushGlobalHistoryLog(`<L><b>【천재】</b></><G><b>${options.cityName}</b></>에 천재가 등장했습니다.`);
|
||||||
|
} else {
|
||||||
|
logger.pushGlobalActionLog(
|
||||||
|
`<G><b>${options.cityName}</b></>에서 <Y>${options.name}</>${josaRa}는 호걸이 천하에 이름을 알립니다.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.pushGeneralHistoryLog(`<Y>${options.name}</>, <G>${options.cityName}</>에서 큰 뜻을 품다.`);
|
||||||
|
logger.pushGeneralActionLog(
|
||||||
|
[
|
||||||
|
'삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^',
|
||||||
|
'처음 하시는 경우에는 <D>도움말</>을 참고하시고,',
|
||||||
|
'문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~',
|
||||||
|
'부디 즐거운 삼모전 되시길 바랍니다 ^^',
|
||||||
|
`통솔 <C>${options.bonus[0]}</> 무력 <C>${options.bonus[1]}</> 지력 <C>${options.bonus[2]}</> 의 보너스를 받으셨습니다.`,
|
||||||
|
`연령은 <C>${options.age}</>세로 시작합니다.`,
|
||||||
|
],
|
||||||
|
LogFormat.PLAIN
|
||||||
|
);
|
||||||
|
if (options.genius) {
|
||||||
|
logger.pushGeneralActionLog(
|
||||||
|
`축하합니다! 천재로 태어나 처음부터 <C>${options.specialWarName}</> 특기를 가지게 됩니다!`,
|
||||||
|
LogFormat.PLAIN
|
||||||
|
);
|
||||||
|
logger.pushGeneralHistoryLog(`<C>${options.specialWarName}</> 특기를 가진 천재로 탄생.`);
|
||||||
|
}
|
||||||
|
for (const entry of logger.flush()) {
|
||||||
|
world.pushLog({
|
||||||
|
...entry,
|
||||||
|
meta: {
|
||||||
|
...(entry.meta ?? {}),
|
||||||
|
ownerUserId: options.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createGeneralFromJoin = async (options: {
|
||||||
|
db: DatabaseClient;
|
||||||
|
world: InMemoryTurnWorld;
|
||||||
|
worldState: WorldStateRow;
|
||||||
|
input: JoinCreateGeneralInput;
|
||||||
|
acceptedAt: Date;
|
||||||
|
}): Promise<{ ok: true; generalId: number }> => {
|
||||||
|
const { db, world, worldState, input, acceptedAt } = options;
|
||||||
|
await lockJoinMutation(db, input.userId);
|
||||||
|
await assertGeneralIdSnapshotMatches(db, world);
|
||||||
|
|
||||||
|
if (isSelectionPoolWorld(worldState)) {
|
||||||
|
fail('PRECONDITION_FAILED', '장수 선택 목록에서 장수를 골라 주세요.');
|
||||||
|
}
|
||||||
|
const config = asRecord(worldState.config);
|
||||||
|
const configConst = asRecord(config.const);
|
||||||
|
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||||
|
if ((blockGeneralCreate & 1) !== 0) {
|
||||||
|
fail('FORBIDDEN', '장수 직접 생성이 불가능한 모드입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
world.listGenerals().some((general) => general.userId === input.userId) ||
|
||||||
|
(await db.general.findFirst({
|
||||||
|
where: { userId: input.userId },
|
||||||
|
select: { id: true },
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
fail('PRECONDITION_FAILED', '이미 등록하셨습니다!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = normalizeJoinName(input.name);
|
||||||
|
if (!normalizedName || getLegacyStringWidth(normalizedName) < 1) {
|
||||||
|
fail('BAD_REQUEST', '이름이 짧습니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
if (getLegacyStringWidth(normalizedName) > 18) {
|
||||||
|
fail('BAD_REQUEST', '이름이 유효하지 않습니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
world.listGenerals().some((general) => general.name === normalizedName) ||
|
||||||
|
(await db.general.findFirst({
|
||||||
|
where: { name: normalizedName },
|
||||||
|
select: { id: true },
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
fail('CONFLICT', '이미 있는 장수입니다. 다른 이름으로 등록해 주세요!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } });
|
||||||
|
if (activeCount >= resolveMaxGeneral(worldState)) {
|
||||||
|
fail('PRECONDITION_FAILED', '더이상 등록할 수 없습니다!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statRule = resolveStatRule(worldState);
|
||||||
|
const stats = [input.leadership, input.strength, input.intel] as const;
|
||||||
|
if (stats.some((value) => !Number.isInteger(value) || value < statRule.min || value > statRule.max)) {
|
||||||
|
fail('BAD_REQUEST', '능력치 범위를 벗어났습니다.');
|
||||||
|
}
|
||||||
|
if (stats.reduce((sum, value) => sum + value, 0) > statRule.total) {
|
||||||
|
fail('BAD_REQUEST', `능력치가 ${statRule.total}을 넘어섰습니다. 다시 가입해주세요!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.character !== 'Random' &&
|
||||||
|
(!isPersonalityTraitKey(input.character) || !JOIN_PERSONALITY_TRAIT_KEYS.some((key) => key === input.character))
|
||||||
|
) {
|
||||||
|
fail('BAD_REQUEST', '성격이 잘못 지정되었습니다.');
|
||||||
|
}
|
||||||
|
const availableSpecialWar = Array.isArray(configConst.availableSpecialWar)
|
||||||
|
? configConst.availableSpecialWar.filter(
|
||||||
|
(value): value is WarTraitKey => typeof value === 'string' && isWarTraitKey(value)
|
||||||
|
)
|
||||||
|
: [...WAR_TRAIT_KEYS];
|
||||||
|
if (
|
||||||
|
input.inheritSpecial !== undefined &&
|
||||||
|
(!isWarTraitKey(input.inheritSpecial) || !availableSpecialWar.includes(input.inheritSpecial))
|
||||||
|
) {
|
||||||
|
fail('BAD_REQUEST', '전투 특기가 잘못 지정되었습니다.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.inheritTurntimeZone !== undefined &&
|
||||||
|
(!Number.isInteger(input.inheritTurntimeZone) ||
|
||||||
|
input.inheritTurntimeZone < 0 ||
|
||||||
|
input.inheritTurntimeZone > 59)
|
||||||
|
) {
|
||||||
|
fail('BAD_REQUEST', '턴 시간 지정 범위가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
||||||
|
const inheritConstants = resolveInheritConstants(worldState);
|
||||||
|
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
||||||
|
const currentInheritancePoint = await applyInheritanceUser(
|
||||||
|
db,
|
||||||
|
input.userId,
|
||||||
|
worldState.currentYear,
|
||||||
|
worldState.currentMonth
|
||||||
|
);
|
||||||
|
if (currentInheritancePoint < inheritRequiredPoint) {
|
||||||
|
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCities = world.listCities().sort((left, right) => left.id - right.id);
|
||||||
|
const selectedCity =
|
||||||
|
input.inheritCity === undefined ? null : (allCities.find((city) => city.id === input.inheritCity) ?? null);
|
||||||
|
if (input.inheritCity !== undefined && !selectedCity) {
|
||||||
|
fail('BAD_REQUEST', '도시가 잘못 지정되었습니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
const neutralCities = allCities.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0);
|
||||||
|
const randomCities =
|
||||||
|
neutralCities.length > 0 ? neutralCities : allCities.filter((city) => city.level >= 5 && city.level <= 6);
|
||||||
|
if (!selectedCity && randomCities.length === 0) {
|
||||||
|
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenSeed = readHiddenSeed(worldState);
|
||||||
|
const rng = new RandUtil(
|
||||||
|
new LiteHashDRBG(buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, acceptedAt))
|
||||||
|
);
|
||||||
|
const worldMeta = asRecord(worldState.meta);
|
||||||
|
const currentGenius = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(asNumber(worldMeta.genius, asNumber(configConst.defaultMaxGenius, DEFAULT_MAX_GENIUS)))
|
||||||
|
);
|
||||||
|
if (input.inheritSpecial !== undefined && currentGenius === 0) {
|
||||||
|
fail('PRECONDITION_FAILED', '이미 천재가 모두 나타났습니다. 다시 가입해주세요!');
|
||||||
|
}
|
||||||
|
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
||||||
|
const genius = geniusRequested && currentGenius > 0;
|
||||||
|
|
||||||
|
const city = selectedCity ?? rng.choice(randomCities);
|
||||||
|
const bonus = inheritBonus ?? buildRandomBonus(rng, [...stats]);
|
||||||
|
const finalStats = {
|
||||||
|
leadership: input.leadership + bonus[0],
|
||||||
|
strength: input.strength + bonus[1],
|
||||||
|
intelligence: input.intel + bonus[2],
|
||||||
|
};
|
||||||
|
const age = 20 + (bonus[0] + bonus[1] + bonus[2]) * 2 - rng.nextRangeInt(0, 1);
|
||||||
|
const relativeYear = resolveRelativeYear(worldState);
|
||||||
|
const retirementYear = asNumber(configConst.retirementYear, 80);
|
||||||
|
const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode);
|
||||||
|
let specialityDomesticAge = resolveSpecialityAge(retirementYear, age, relativeYear, 12);
|
||||||
|
let specialityWarAge = resolveSpecialityAge(retirementYear, age, relativeYear, 6);
|
||||||
|
let specialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||||
|
let specialWarName = specialWar;
|
||||||
|
if (genius) {
|
||||||
|
specialityWarAge = age;
|
||||||
|
if (input.inheritSpecial) {
|
||||||
|
specialWar = input.inheritSpecial;
|
||||||
|
} else {
|
||||||
|
const traits = await loadWarTraitModules(availableSpecialWar, new WarTraitLoader());
|
||||||
|
specialWar =
|
||||||
|
TraitSelector.pickWarTrait(
|
||||||
|
rng,
|
||||||
|
finalStats,
|
||||||
|
[0, 0, 0, 0, 0],
|
||||||
|
traits,
|
||||||
|
[],
|
||||||
|
world.getScenarioConfig().stat
|
||||||
|
) ?? specialWar;
|
||||||
|
}
|
||||||
|
const [trait] = await loadWarTraitModules(
|
||||||
|
[specialWar].filter((key) => isWarTraitKey(key)),
|
||||||
|
new WarTraitLoader()
|
||||||
|
);
|
||||||
|
specialWarName = trait?.name ?? specialWar;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(scenarioId) && scenarioId >= 1000) {
|
||||||
|
specialityDomesticAge = age + 3;
|
||||||
|
specialityWarAge = age + 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||||
|
const turnTime = resolveTurnTime(
|
||||||
|
rng,
|
||||||
|
worldState,
|
||||||
|
acceptedAt,
|
||||||
|
world.getState().lastTurnTime,
|
||||||
|
input.inheritTurntimeZone
|
||||||
|
);
|
||||||
|
const personality = input.character === 'Random' ? rng.choice([...JOIN_PERSONALITY_TRAIT_KEYS]) : input.character;
|
||||||
|
const affinity = rng.nextRangeInt(1, 150);
|
||||||
|
const generalId = world.getNextGeneralId();
|
||||||
|
let obfuscatedNamePool = Array.isArray(worldMeta.obfuscatedNamePool)
|
||||||
|
? worldMeta.obfuscatedNamePool.filter((value): value is string => typeof value === 'string')
|
||||||
|
: [];
|
||||||
|
if ((blockGeneralCreate & 2) !== 0 && obfuscatedNamePool.length === 0) {
|
||||||
|
obfuscatedNamePool = buildAuctionAliasPool(hiddenSeed, configConst);
|
||||||
|
world.updateWorldMeta({ obfuscatedNamePool });
|
||||||
|
}
|
||||||
|
const finalName =
|
||||||
|
(blockGeneralCreate & 2) !== 0
|
||||||
|
? buildAuctionAlias(generalId, hiddenSeed, {
|
||||||
|
...configConst,
|
||||||
|
obfuscatedNamePool,
|
||||||
|
})
|
||||||
|
: normalizedName;
|
||||||
|
const showImgLevel = Math.floor(asNumber(config.showImgLevel, 0));
|
||||||
|
const canUseOwnerPicture =
|
||||||
|
showImgLevel >= 1 &&
|
||||||
|
input.pic === true &&
|
||||||
|
input.ownerCanUsePicture === true &&
|
||||||
|
typeof input.ownerPicture === 'string' &&
|
||||||
|
input.ownerPicture.length > 0 &&
|
||||||
|
input.ownerPicture !== 'default.jpg';
|
||||||
|
const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg';
|
||||||
|
const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0;
|
||||||
|
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||||
|
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||||
|
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||||
|
const general: TurnGeneral = {
|
||||||
|
id: generalId,
|
||||||
|
userId: input.userId,
|
||||||
|
name: finalName,
|
||||||
|
nationId: 0,
|
||||||
|
cityId: city.id,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
affinity,
|
||||||
|
// Ref Join은 두 컬럼을 지정하지 않아 schema 기본값 180/300을 유지한다.
|
||||||
|
bornYear: 180,
|
||||||
|
deadYear: 300,
|
||||||
|
picture,
|
||||||
|
imageServer,
|
||||||
|
stats: finalStats,
|
||||||
|
experience,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: asNumber(configConst.defaultGold, DEFAULT_GENERAL_GOLD),
|
||||||
|
rice: asNumber(configConst.defaultRice, DEFAULT_GENERAL_RICE),
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: world.getUnitSet()?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
turnTime,
|
||||||
|
age,
|
||||||
|
startAge: age,
|
||||||
|
role: {
|
||||||
|
personality,
|
||||||
|
specialDomestic:
|
||||||
|
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None',
|
||||||
|
specialWar,
|
||||||
|
items: {
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
triggerState: {
|
||||||
|
flags: {},
|
||||||
|
counters: {},
|
||||||
|
modifiers: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
lastTurn: { command: DEFAULT_TURN_ACTION },
|
||||||
|
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
|
||||||
|
inheritancePoints: {
|
||||||
|
previous: finalInheritancePoint,
|
||||||
|
},
|
||||||
|
refreshScoreTotal: 0,
|
||||||
|
meta: {
|
||||||
|
createdBy: 'join',
|
||||||
|
ownerName: input.ownerDisplayName,
|
||||||
|
owner_name: input.ownerDisplayName,
|
||||||
|
killturn: 6,
|
||||||
|
npc_org: 0,
|
||||||
|
newmsg: 0,
|
||||||
|
makelimit: 0,
|
||||||
|
block: 0,
|
||||||
|
dedlevel: 0,
|
||||||
|
explevel: 0,
|
||||||
|
officerCity: 0,
|
||||||
|
officer_city: 0,
|
||||||
|
permission: 'normal',
|
||||||
|
belong: 1,
|
||||||
|
betray: relativeYear >= 4 ? 2 : 0,
|
||||||
|
specage: specialityDomesticAge,
|
||||||
|
specage2: specialityWarAge,
|
||||||
|
defence_train: 80,
|
||||||
|
tnmt: 1,
|
||||||
|
myset: 6,
|
||||||
|
tournament: 0,
|
||||||
|
newvote: 0,
|
||||||
|
inherit_spent_dyn: inheritRequiredPoint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!world.addGeneral(general)) {
|
||||||
|
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
|
||||||
|
}
|
||||||
|
if (genius) {
|
||||||
|
world.updateWorldMeta({ genius: currentGenius - 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.generalTurn.createMany({
|
||||||
|
data: Array.from({ length: MAX_GENERAL_TURNS }, (_, turnIdx) => ({
|
||||||
|
generalId,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: DEFAULT_TURN_ACTION,
|
||||||
|
arg: {},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await db.generalTurnRevision.create({
|
||||||
|
data: { generalId, revision: 0 },
|
||||||
|
});
|
||||||
|
await db.generalAccessLog.upsert({
|
||||||
|
where: { generalId },
|
||||||
|
update: { userId: input.userId, lastRefresh: acceptedAt },
|
||||||
|
create: {
|
||||||
|
generalId,
|
||||||
|
userId: input.userId,
|
||||||
|
lastRefresh: acceptedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (inheritRequiredPoint > 0) {
|
||||||
|
await setInheritancePoint(db, input.userId, nextInheritancePoint);
|
||||||
|
const inheritLogs: string[] = [];
|
||||||
|
if (input.inheritSpecial) {
|
||||||
|
inheritLogs.push(`${specialWarName} 전투 특기를 가진 천재 생성`);
|
||||||
|
}
|
||||||
|
if (input.inheritCity !== undefined) {
|
||||||
|
inheritLogs.push(`${city.name}에 장수 생성`);
|
||||||
|
}
|
||||||
|
if (inheritBonus) {
|
||||||
|
inheritLogs.push(`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`);
|
||||||
|
}
|
||||||
|
if (input.inheritTurntimeZone !== undefined) {
|
||||||
|
const zoneStart = input.inheritTurntimeZone * Math.max(1, Math.floor(worldState.tickSeconds / 60));
|
||||||
|
inheritLogs.push(
|
||||||
|
`턴 시간 ${String(Math.floor(zoneStart / 60)).padStart(2, '0')}:${String(zoneStart % 60).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)} 로 지정`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inheritLogs.push(`장수 생성으로 포인트 ${inheritRequiredPoint} 소모`);
|
||||||
|
for (const text of inheritLogs) {
|
||||||
|
await appendInheritanceLog(db, input.userId, worldState.currentYear, worldState.currentMonth, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (restInheritanceBonus > 0) {
|
||||||
|
await setInheritancePoint(db, input.userId, finalInheritancePoint);
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
input.userId,
|
||||||
|
worldState.currentYear,
|
||||||
|
worldState.currentMonth,
|
||||||
|
`신규/복귀 생성으로 포인트 ${restInheritanceBonus} 지급`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pushCreationLogs(world, {
|
||||||
|
generalId,
|
||||||
|
userId: input.userId,
|
||||||
|
name: finalName,
|
||||||
|
cityName: city.name,
|
||||||
|
bonus,
|
||||||
|
age,
|
||||||
|
genius,
|
||||||
|
specialWarName,
|
||||||
|
});
|
||||||
|
return { ok: true, generalId };
|
||||||
|
};
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
reselectGeneralFromSelectionPool,
|
reselectGeneralFromSelectionPool,
|
||||||
SelectPoolError,
|
SelectPoolError,
|
||||||
} from './selectPoolService.js';
|
} from './selectPoolService.js';
|
||||||
|
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||||
|
|
||||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||||
|
|
||||||
@@ -131,21 +132,85 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
|||||||
|
|
||||||
const resolveCommandAcceptedAt = async (
|
const resolveCommandAcceptedAt = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' | 'selectPoolReselect' }>
|
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||||
): Promise<Date> => {
|
): Promise<Date> => {
|
||||||
if (!command.requestId) {
|
if (!command.requestId) {
|
||||||
throw new Error(`${command.type} requestId is required.`);
|
throw new Error(`${command.type} requestId is required.`);
|
||||||
}
|
}
|
||||||
const event = await db.inputEvent.findUnique({
|
const event = await db.inputEvent.findUnique({
|
||||||
where: { requestId: command.requestId },
|
where: { requestId: command.requestId },
|
||||||
select: { createdAt: true },
|
select: { createdAt: true, actorUserId: true },
|
||||||
});
|
});
|
||||||
if (!event) {
|
if (!event) {
|
||||||
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
|
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;
|
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(
|
async function handleSelectPoolCreate(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
||||||
@@ -1952,16 +2017,12 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
||||||
patchGeneral: (command) =>
|
patchGeneral: (command) =>
|
||||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||||
|
joinCreateGeneral: (command) =>
|
||||||
|
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
||||||
selectPoolCreate: (command) =>
|
selectPoolCreate: (command) =>
|
||||||
handleSelectPoolCreate(
|
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
|
||||||
ctx,
|
|
||||||
command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
|
||||||
),
|
|
||||||
selectPoolReselect: (command) =>
|
selectPoolReselect: (command) =>
|
||||||
handleSelectPoolReselect(
|
handleSelectPoolReselect(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>),
|
||||||
ctx,
|
|
||||||
command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
|
|
||||||
),
|
|
||||||
shiftSchedule: (command) =>
|
shiftSchedule: (command) =>
|
||||||
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
|
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"]
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
|
"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: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: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": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ import { cityLevelMap, regionMap } from '../utils/nationFormat';
|
|||||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||||
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[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 router = useRouter();
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
@@ -21,6 +28,7 @@ const submitting = ref(false);
|
|||||||
|
|
||||||
const joinConfig = ref<JoinConfig | null>(null);
|
const joinConfig = ref<JoinConfig | null>(null);
|
||||||
const activeTab = ref<'create' | 'possess'>('create');
|
const activeTab = ref<'create' | 'possess'>('create');
|
||||||
|
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
||||||
|
|
||||||
const form = ref<JoinForm>({
|
const form = ref<JoinForm>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -32,6 +40,55 @@ const form = ref<JoinForm>({
|
|||||||
inheritBonusStat: [0, 0, 0],
|
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 npcCandidates = ref<PossessCandidate[]>([]);
|
||||||
const npcLoading = ref(false);
|
const npcLoading = ref(false);
|
||||||
const npcError = ref<string | null>(null);
|
const npcError = ref<string | null>(null);
|
||||||
@@ -199,8 +256,13 @@ const loadConfig = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
joinConfig.value = config;
|
joinConfig.value = config;
|
||||||
form.value.name = config.user.displayName || '';
|
const pending = readPendingJoin();
|
||||||
applyBalancedStats();
|
if (pending?.ownerUserId === config.user.id) {
|
||||||
|
form.value = pending.input;
|
||||||
|
} else {
|
||||||
|
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
||||||
|
applyBalancedStats();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'join_config_failed';
|
error.value = err instanceof Error ? err.message : 'join_config_failed';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -234,13 +296,21 @@ const submitJoin = async () => {
|
|||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
const pending = getPendingJoin();
|
||||||
try {
|
try {
|
||||||
await trpc.join.createGeneral.mutate(form.value);
|
await trpc.join.createGeneral.mutate({
|
||||||
|
...pending.input,
|
||||||
|
clientRequestId: pending.clientRequestId,
|
||||||
|
});
|
||||||
|
clearPendingJoin(pending);
|
||||||
await session.refreshGeneralStatus();
|
await session.refreshGeneralStatus();
|
||||||
if (session.hasGeneral) {
|
if (session.hasGeneral) {
|
||||||
await router.push({ name: 'home' });
|
await router.push({ name: 'home' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!isIndeterminateTimeout(err)) {
|
||||||
|
clearPendingJoin(pending);
|
||||||
|
}
|
||||||
error.value = err instanceof Error ? err.message : 'join_failed';
|
error.value = err instanceof Error ? err.message : 'join_failed';
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
@@ -315,7 +385,13 @@ onMounted(() => {
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>장수명</span>
|
<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>
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>성격</span>
|
<span>성격</span>
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ const readObject = <T extends object>(value: unknown, fallback: T): T => {
|
|||||||
const readLegacyMemberNo = (value: unknown): number | undefined => {
|
const readLegacyMemberNo = (value: unknown): number | undefined => {
|
||||||
const legacyData = readObject<Record<string, unknown>>(value, {});
|
const legacyData = readObject<Record<string, unknown>>(value, {});
|
||||||
const memberNo = legacyData.memberNo;
|
const memberNo = legacyData.memberNo;
|
||||||
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0
|
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0 ? memberNo : undefined;
|
||||||
? 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: {
|
const mapUser = (row: {
|
||||||
@@ -71,6 +75,7 @@ const mapUser = (row: {
|
|||||||
passwordSalt: row.passwordSalt,
|
passwordSalt: row.passwordSalt,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
||||||
|
legacyGrade: readLegacyGrade(row.legacyData),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createPostgresUserRepository = (
|
export const createPostgresUserRepository = (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface UserRecord {
|
|||||||
passwordSalt: string;
|
passwordSalt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
legacyMemberNo?: number;
|
legacyMemberNo?: number;
|
||||||
|
legacyGrade?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUser {
|
export interface PublicUser {
|
||||||
@@ -43,6 +44,7 @@ export interface UserSanctions {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
profileIconResetAt?: string;
|
profileIconResetAt?: string;
|
||||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||||
|
legacyPenalty?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserServerRestriction {
|
export interface UserServerRestriction {
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ import type { UserOAuthInfo } from './auth/userRepository.js';
|
|||||||
import { adminRouter } from './adminRouter.js';
|
import { adminRouter } from './adminRouter.js';
|
||||||
import { accountRouter } from './account/router.js';
|
import { accountRouter } from './account/router.js';
|
||||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||||
import {
|
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||||
openPassword,
|
|
||||||
zDisplayName,
|
|
||||||
zPasswordEnvelope,
|
|
||||||
zRegistrationUsername,
|
|
||||||
} from './auth/registrationInput.js';
|
|
||||||
|
|
||||||
const zUsername = z
|
const zUsername = z
|
||||||
.string()
|
.string()
|
||||||
@@ -667,14 +662,17 @@ export const appRouter = router({
|
|||||||
expiresAt: addSeconds(now, ctx.gameSessionTtlSeconds).toISOString(),
|
expiresAt: addSeconds(now, ctx.gameSessionTtlSeconds).toISOString(),
|
||||||
sessionId: gameSession.gameToken,
|
sessionId: gameSession.gameToken,
|
||||||
user: {
|
user: {
|
||||||
id: gameSession.userId,
|
id: user.id,
|
||||||
username: gameSession.username,
|
username: user.username,
|
||||||
displayName: gameSession.displayName,
|
displayName: user.displayName,
|
||||||
roles: gameSession.roles,
|
picture: user.picture,
|
||||||
createdAt: gameSession.createdAt,
|
imageServer: user.imageServer,
|
||||||
legacyMemberNo: gameSession.legacyMemberNo,
|
canUseGeneralPicture: user.legacyGrade === undefined || user.legacyGrade >= 1,
|
||||||
|
roles: user.roles,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
legacyMemberNo: user.legacyMemberNo,
|
||||||
},
|
},
|
||||||
sanctions: gameSession.sanctions,
|
sanctions: user.sanctions,
|
||||||
identity: {
|
identity: {
|
||||||
kakaoVerified: localAccountPolicy.kakaoVerified,
|
kakaoVerified: localAccountPolicy.kakaoVerified,
|
||||||
canCreateGeneral: localAccountPolicy.canCreateGeneral,
|
canCreateGeneral: localAccountPolicy.canCreateGeneral,
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
|
|||||||
];
|
];
|
||||||
const profiles = {
|
const profiles = {
|
||||||
listProfiles: async () => profileRows,
|
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 () => {
|
upsertProfile: async () => {
|
||||||
throw new Error('not used');
|
throw new Error('not used');
|
||||||
},
|
},
|
||||||
@@ -544,6 +545,49 @@ describe('gateway auth flow', () => {
|
|||||||
expect(validated?.user).not.toHaveProperty('legacyMemberNo');
|
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 () => {
|
it('revokes the gateway session and every linked game session on logout', async () => {
|
||||||
const { caller, users, sessions } = buildCaller();
|
const { caller, users, sessions } = buildCaller();
|
||||||
const user = await users.createUser({
|
const user = await users.createUser({
|
||||||
@@ -573,10 +617,10 @@ describe('account self service', () => {
|
|||||||
const session = await sessions.createSession(user);
|
const session = await sessions.createSession(user);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.account.changePassword({
|
caller.account.changePassword({
|
||||||
sessionToken: session.sessionToken,
|
sessionToken: session.sessionToken,
|
||||||
currentCredential: sealPassword('wrong-password'),
|
currentCredential: sealPassword('wrong-password'),
|
||||||
newCredential: sealPassword('next-password'),
|
newCredential: sealPassword('next-password'),
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface UserSanctions {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
profileIconResetAt?: string;
|
profileIconResetAt?: string;
|
||||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||||
|
legacyPenalty?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserServerRestriction {
|
export interface UserServerRestriction {
|
||||||
@@ -23,6 +24,9 @@ export interface GatewayUserInfo {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
picture?: string;
|
||||||
|
imageServer?: number;
|
||||||
|
canUseGeneralPicture?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
legacyMemberNo?: number;
|
legacyMemberNo?: number;
|
||||||
}
|
}
|
||||||
@@ -58,7 +62,7 @@ export const encryptGameSessionToken = (payload: GameSessionTokenPayload, secret
|
|||||||
return `${toBase64Url(iv)}.${toBase64Url(ciphertext)}.${toBase64Url(tag)}`;
|
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') {
|
if (!value || typeof value !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -84,8 +88,10 @@ const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
|
|||||||
typeof user.username !== 'string' ||
|
typeof user.username !== 'string' ||
|
||||||
typeof user.displayName !== 'string' ||
|
typeof user.displayName !== 'string' ||
|
||||||
!Array.isArray(user.roles) ||
|
!Array.isArray(user.roles) ||
|
||||||
(user.legacyMemberNo !== undefined &&
|
(user.picture !== undefined && typeof user.picture !== 'string') ||
|
||||||
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
(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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -123,7 +129,7 @@ export const decryptGameSessionToken = (token: string, secret: string): GameSess
|
|||||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||||
return parsePayload(JSON.parse(plaintext));
|
return parseGameSessionTokenPayload(JSON.parse(plaintext));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,28 @@ export type TurnDaemonCommand =
|
|||||||
specialWar?: string;
|
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';
|
type: 'selectPoolCreate';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@@ -476,6 +498,17 @@ export type TurnDaemonCommandResult =
|
|||||||
generalId: number;
|
generalId: number;
|
||||||
reason: string;
|
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';
|
type: 'selectPoolCreate';
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export interface TurnEngineGeneralUpdateInput {
|
|||||||
specialCode: string;
|
specialCode: string;
|
||||||
special2Code: string;
|
special2Code: string;
|
||||||
lastTurn: InputJsonValue;
|
lastTurn: InputJsonValue;
|
||||||
|
penalty: InputJsonValue;
|
||||||
meta: InputJsonValue;
|
meta: InputJsonValue;
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
recentWarTime: Date | null;
|
recentWarTime: Date | null;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const PERSONALITY_TRAIT_KEYS = [
|
|||||||
|
|
||||||
export type PersonalityTraitKey = (typeof PERSONALITY_TRAIT_KEYS)[number];
|
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 PersonalityTraitModule = TraitModule;
|
||||||
|
|
||||||
export type PersonalityTraitImporter = () => Promise<TraitModuleExport>;
|
export type PersonalityTraitImporter = () => Promise<TraitModuleExport>;
|
||||||
|
|||||||
@@ -179,11 +179,14 @@ const readNameParts = (value: unknown, fallback: readonly string[]): string[] =>
|
|||||||
return result.length > 0 ? result : [...fallback];
|
return result.length > 0 ? result : [...fallback];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildAuctionAlias = (
|
export const buildAuctionAliasPool = (
|
||||||
generalId: number,
|
|
||||||
hiddenSeed: string | number,
|
hiddenSeed: string | number,
|
||||||
configConst: Record<string, unknown> = {}
|
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 firstNames = readNameParts(configConst.randGenFirstName, LEGACY_RANDOM_GENERAL_FIRST_NAMES);
|
||||||
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
|
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
|
||||||
const lastNames = readNameParts(configConst.randGenLastName, LEGACY_RANDOM_GENERAL_LAST_NAMES);
|
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 normalizedId = Math.max(0, Math.floor(generalId));
|
||||||
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
|
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
|
||||||
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
|
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { buildAuctionAlias } from '../src/auction/alias.js';
|
import { buildAuctionAlias, buildAuctionAliasPool } from '../src/auction/alias.js';
|
||||||
|
|
||||||
describe('buildAuctionAlias', () => {
|
describe('buildAuctionAlias', () => {
|
||||||
it('returns a stable alias for the same world seed and general id', () => {
|
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(1, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||||
expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/);
|
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "ludo_rathowm",
|
"id": "ludo_rathowm",
|
||||||
"name": "ludo_rathowm",
|
"name": "ludo_rathowm",
|
||||||
"defaultCrewTypeId": 1100,
|
"defaultCrewTypeId": 217100,
|
||||||
"armTypes": {
|
"armTypes": {
|
||||||
"1": "보병",
|
"1": "보병",
|
||||||
"2": "궁병",
|
"2": "궁병",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "siegetank",
|
"id": "siegetank",
|
||||||
"name": "siegetank",
|
"name": "siegetank",
|
||||||
"defaultCrewTypeId": 1100,
|
"defaultCrewTypeId": 1500,
|
||||||
"armTypes": {
|
"armTypes": {
|
||||||
"1": "보병",
|
"1": "보병",
|
||||||
"2": "궁병",
|
"2": "궁병",
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ describe('auction integration flow', () => {
|
|||||||
strength: 55,
|
strength: 55,
|
||||||
intel: 55,
|
intel: 55,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
|
pic: true,
|
||||||
});
|
});
|
||||||
userSessions.push({
|
userSessions.push({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ describe('integration initialization flow', () => {
|
|||||||
strength: 55,
|
strength: 55,
|
||||||
intel: 55,
|
intel: 55,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
|
pic: true,
|
||||||
});
|
});
|
||||||
const generalInfo = await userGameClient.general.me.query();
|
const generalInfo = await userGameClient.general.me.query();
|
||||||
if (!generalInfo?.general) {
|
if (!generalInfo?.general) {
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ describe('pm2 orchestrator e2e', () => {
|
|||||||
strength: 55,
|
strength: 55,
|
||||||
intel: 55,
|
intel: 55,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
|
pic: true,
|
||||||
});
|
});
|
||||||
expect(createdGeneral.generalId).toBeGreaterThan(0);
|
expect(createdGeneral.generalId).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
|||||||
@@ -230,10 +230,7 @@ describe('actual tournament lifecycle', () => {
|
|||||||
for (const [username, displayName] of users) {
|
for (const [username, displayName] of users) {
|
||||||
const login = await gatewayClient.auth.login.mutate({
|
const login = await gatewayClient.auth.login.mutate({
|
||||||
username,
|
username,
|
||||||
credential: sealGatewayPassword(
|
credential: sealGatewayPassword(`${username}-pass`, await gatewayClient.auth.passwordKey.query()),
|
||||||
`${username}-pass`,
|
|
||||||
await gatewayClient.auth.passwordKey.query()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||||
sessionToken: login.sessionToken,
|
sessionToken: login.sessionToken,
|
||||||
@@ -252,6 +249,7 @@ describe('actual tournament lifecycle', () => {
|
|||||||
strength: 55,
|
strength: 55,
|
||||||
intel: 55,
|
intel: 55,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
|
pic: true,
|
||||||
});
|
});
|
||||||
generalIds.set(username, created.generalId);
|
generalIds.set(username, created.generalId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user