diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index c9a7b89..f78baa1 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -21,6 +21,16 @@ import { type MessageRecordDraft, type MessageType, } from '@sammo-ts/logic'; +import { + isPersonalityTraitKey, + isWarTraitKey, + loadPersonalityTraitModules, + loadWarTraitModules, + PersonalityTraitLoader, + PERSONALITY_TRAIT_KEYS, + WarTraitLoader, + WAR_TRAIT_KEYS, +} from '@sammo-ts/logic'; import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from './messages/targets.js'; import { fetchMessagesFromMailbox, @@ -82,6 +92,76 @@ const getMyGeneral = async (ctx: Pick) => { return general; }; +const DEFAULT_JOIN_STAT = { + total: 165, + min: 15, + max: 80, + bonusMin: 3, + bonusMax: 5, +}; + +const asRecord = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; + +const asNumber = (value: unknown, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +const resolveJoinStat = (worldState: WorldStateRow) => { + const config = asRecord(worldState.config); + const stat = asRecord(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), + bonusMin: DEFAULT_JOIN_STAT.bonusMin, + bonusMax: DEFAULT_JOIN_STAT.bonusMax, + }; +}; + +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; +}; + +let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null; + +const loadPersonalityOptions = async () => { + if (cachedPersonalityOptions) { + return cachedPersonalityOptions; + } + const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader()); + cachedPersonalityOptions = modules.map((trait) => ({ + key: trait.key, + name: trait.name, + info: trait.info ?? '', + })); + return cachedPersonalityOptions; +}; + +const loadWarOptions = async (keys: string[]) => { + const unique = Array.from(new Set(keys.filter((key) => isWarTraitKey(key)))); + const modules = await loadWarTraitModules(unique, new WarTraitLoader()); + return modules.map((trait) => ({ + key: trait.key, + name: trait.name, + info: trait.info ?? '', + })); +}; + export const appRouter = router({ health: router({ ping: procedure.query(({ ctx }) => ({ @@ -143,6 +223,290 @@ export const appRouter = router({ }; }), }), + join: router({ + getConfig: authedProcedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const config = asRecord(worldState.config); + const configConst = asRecord(config.const); + const availableSpecialWar = asStringArray(configConst.availableSpecialWar); + const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; + + const [personalities, warSpecials, nationRows] = await Promise.all([ + loadPersonalityOptions(), + loadWarOptions(warKeys), + ctx.db.nation.findMany({ + select: { + id: true, + name: true, + color: true, + meta: true, + }, + orderBy: { id: 'asc' }, + }), + ]); + + const nations = nationRows.map((nation) => { + const meta = asRecord(nation.meta); + return { + id: nation.id, + name: nation.name, + color: nation.color, + scoutMessage: typeof meta.infoText === 'string' ? meta.infoText : null, + }; + }); + + return { + rules: { + stat: resolveJoinStat(worldState), + allowCustomName: true, + }, + user: { + id: ctx.auth?.user.id ?? '', + displayName: ctx.auth?.user.displayName ?? '', + }, + personalities: [ + { key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, + ...personalities, + ], + warSpecials, + nations, + }; + }), + createGeneral: authedProcedure + .input( + z.object({ + name: z.string().min(1).max(18), + leadership: z.number().int(), + strength: z.number().int(), + intel: z.number().int(), + pic: z.boolean().optional(), + character: z.string(), + inheritSpecial: z.string().optional(), + inheritTurntimeZone: z.number().int().optional(), + inheritCity: z.number().int().optional(), + inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + 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 chosenPersonality = + input.character === 'Random' + ? pickFromList(personalityKeys, `${userId}:${input.name}`) ?? 'None' + : isPersonalityTraitKey(input.character) + ? input.character + : 'None'; + + return ctx.db.$transaction(async (db) => { + 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: input.name } }); + 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 }, orderBy: { id: 'asc' } }); + if (!cityList.length) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '도시 정보를 찾을 수 없습니다.', + }); + } + const cityIndex = hashString(userId) % cityList.length; + const cityId = cityList[cityIndex]?.id ?? cityList[0].id; + + const general = await db.general.create({ + data: { + id: nextId, + userId, + name: input.name, + nationId: 0, + cityId, + troopId: 0, + npcState: 0, + leadership: input.leadership, + strength: input.strength, + intel: input.intel, + personalCode: chosenPersonality ?? 'None', + specialCode: 'None', + special2Code: 'None', + turnTime: new Date(), + meta: { + createdBy: 'join', + }, + }, + }); + + return { ok: true, generalId: general.id }; + }); + }), + listPossessCandidates: authedProcedure + .input( + z.object({ + limit: z.number().int().min(1).max(50).optional(), + offset: z.number().int().min(0).optional(), + }) + ) + .query(async ({ ctx, input }) => { + const limit = input.limit ?? 20; + const offset = input.offset ?? 0; + + const candidates = await ctx.db.general.findMany({ + where: { + userId: null, + npcState: { gte: 2 }, + }, + orderBy: { id: 'asc' }, + skip: offset, + take: limit, + select: { + id: true, + name: true, + npcState: true, + nationId: true, + cityId: true, + leadership: true, + strength: true, + intel: true, + age: true, + officerLevel: true, + personalCode: true, + specialCode: true, + special2Code: true, + picture: true, + imageServer: true, + }, + }); + + const [nationRows, cityRows] = await Promise.all([ + ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), + ctx.db.city.findMany({ select: { id: true, name: true } }), + ]); + const nationMap = new Map(nationRows.map((nation) => [nation.id, nation])); + const cityMap = new Map(cityRows.map((city) => [city.id, city])); + + return candidates.map((candidate) => { + const nation = nationMap.get(candidate.nationId); + const city = cityMap.get(candidate.cityId); + return { + id: candidate.id, + name: candidate.name, + npcState: candidate.npcState, + nation: nation + ? { id: nation.id, name: nation.name, color: nation.color } + : { id: 0, name: '재야', color: '#666666' }, + city: city ? { id: city.id, name: city.name } : null, + stats: { + leadership: candidate.leadership, + strength: candidate.strength, + intelligence: candidate.intel, + }, + age: candidate.age, + officerLevel: candidate.officerLevel, + personality: candidate.personalCode, + special: candidate.specialCode, + specialWar: candidate.special2Code, + picture: candidate.picture, + imageServer: candidate.imageServer, + }; + }); + }), + possessGeneral: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const existing = await ctx.db.general.findFirst({ where: { userId } }); + if (existing) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '이미 장수가 생성되어 있습니다.', + }); + } + + const updated = await ctx.db.general.updateMany({ + where: { + id: input.generalId, + userId: null, + npcState: { gte: 2 }, + }, + data: { + userId, + npcState: 1, + updatedAt: new Date(), + }, + }); + + if (updated.count === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '빙의 가능한 장수를 찾지 못했습니다.', + }); + } + + return { ok: true }; + }), + }), battle: router({ simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 26f2986..37be7f9 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -1,10 +1,534 @@ - + + + diff --git a/docs/architecture/game-frontend-spa-plan.md b/docs/architecture/game-frontend-spa-plan.md index ecf57c2..233227b 100644 --- a/docs/architecture/game-frontend-spa-plan.md +++ b/docs/architecture/game-frontend-spa-plan.md @@ -131,6 +131,8 @@ - 지도 선택 연동: 클릭 시 선택 도시 패널/명령 패널에 연결 - 레거시 맵 렌더링 보강: 테마/계절 배경, 도로 레이어, 성/이벤트 아이콘, 상태색 로직 이식 - 지도 아이콘 베이스 경로: `VITE_GAME_ASSET_URL`로 레거시 이미지 경로 주입 +- Join/빙의 UI 구현: 장수 생성/빙의 탭, NPC 목록 로딩, 생성/빙의 후 세션 상태 갱신 및 메인 이동 +- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가 ## Next Frontend Tasks @@ -140,7 +142,7 @@ - MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식 - 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑 - CommandSelectForm/MessagePanel 이식 마무리(예약/전송 플로우 연결) -- Join/빙의 UI 구현 및 완료 후 상태 갱신(장수 생성 감지) +- 유산 포인트/추가 옵션(도시·특기·턴타임) 이식 및 서버 검증 규칙 합의 - 화면 라우트 매핑 표 및 데이터 계약 문서화 ## Deliverables