From 3ce1f441d07f7199fc9d56c60dc34fc564c902f5 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 18 Jan 2026 10:59:04 +0000 Subject: [PATCH] feat: add unification handler and inheritance system - Implemented `unificationHandler.ts` to manage nation unification logic, including inheritance point calculations and logging. - Created `InheritView.vue` for frontend management of inheritance points, buffs, and logs. - Added database migration for new inheritance tables: `inheritance_point`, `inheritance_log`, `inheritance_result`, and `inheritance_user_state`. - Developed inheritance buff logic in `inheritBuff.ts` to apply buffs during domestic and war actions. --- app/game-api/src/battleSim/processor.ts | 9 +- app/game-api/src/router.ts | 2 + app/game-api/src/router/inherit/index.ts | 833 ++++++++++++++++ app/game-api/src/router/join/index.ts | 297 +++++- app/game-api/src/services/inheritance.ts | 278 ++++++ app/game-engine/src/turn/inMemoryWorld.ts | 14 + .../src/turn/reservedTurnCommands.ts | 4 + .../src/turn/reservedTurnHandler.ts | 22 +- app/game-engine/src/turn/turnDaemon.ts | 13 +- .../src/turn/unificationHandler.ts | 200 ++++ app/game-frontend/src/router/index.ts | 10 + app/game-frontend/src/views/InheritView.vue | 889 ++++++++++++++++++ app/game-frontend/src/views/JoinView.vue | 221 ++++- app/game-frontend/src/views/MainView.vue | 1 + packages/infra/prisma/game.prisma | 47 + .../migration.sql | 41 + packages/infra/src/db.ts | 6 +- packages/logic/src/index.ts | 1 + packages/logic/src/inheritance/inheritBuff.ts | 119 +++ 19 files changed, 2947 insertions(+), 60 deletions(-) create mode 100644 app/game-api/src/router/inherit/index.ts create mode 100644 app/game-api/src/services/inheritance.ts create mode 100644 app/game-engine/src/turn/unificationHandler.ts create mode 100644 app/game-frontend/src/views/InheritView.vue create mode 100644 packages/infra/prisma/migrations/20250309000000_add_inheritance_tables/migration.sql create mode 100644 packages/logic/src/inheritance/inheritBuff.ts diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index d6576a7..8fc07a3 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -12,6 +12,7 @@ import { createItemModuleRegistry, ITEM_KEYS, loadItemModules, + createInheritBuffModules, type City, type General, type Nation, @@ -27,9 +28,11 @@ import { convertLog } from './logFormatter.js'; const DEFAULT_GENERAL_AGE = 20; -const itemWarModules: WarActionModule[] = createItemActionModules( - createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])) -).war; +const inheritBuffModules = createInheritBuffModules(); +const itemWarModules: WarActionModule[] = [ + ...createItemActionModules(createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))).war, + inheritBuffModules.war, +]; const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value); diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 652ad19..b6935a6 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -5,6 +5,7 @@ import { authRouter } from './router/auth/index.js'; import { generalRouter } from './router/general/index.js'; import { healthRouter } from './router/health/index.js'; import { joinRouter } from './router/join/index.js'; +import { inheritRouter } from './router/inherit/index.js'; import { lobbyRouter } from './router/lobby/index.js'; import { messagesRouter } from './router/messages/index.js'; import { nationRouter } from './router/nation/index.js'; @@ -20,6 +21,7 @@ export const appRouter = router({ lobby: lobbyRouter, public: publicRouter, join: joinRouter, + inherit: inheritRouter, battle: battleRouter, world: worldRouter, turns: turnsRouter, diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts new file mode 100644 index 0000000..e9ecfeb --- /dev/null +++ b/app/game-api/src/router/inherit/index.ts @@ -0,0 +1,833 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { authedProcedure, router } from '../../trpc.js'; +import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common'; +import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic'; +import type { InheritBuffType } from '@sammo-ts/logic'; +import { + appendInheritanceLog, + buildResetCost, + computeInheritanceItems, + readInheritancePoint, + readUserStateMeta, + resolveInheritConstants, + setInheritancePoint, + sumInheritanceItems, + writeUserStateMeta, +} from '../../services/inheritance.js'; +import type { WorldStateRow } from '../../context.js'; + +const BUFF_KEYS: InheritBuffType[] = [ + 'warAvoidRatio', + 'warCriticalRatio', + 'warMagicTrialProb', + 'success', + 'fail', + 'warAvoidRatioOppose', + 'warCriticalRatioOppose', + 'warMagicTrialProbOppose', +]; + +const BUFF_LABELS: Record = { + warAvoidRatio: '회피 확률 증가', + warCriticalRatio: '필살 확률 증가', + warMagicTrialProb: '전투계략 시도 확률 증가', + success: '내정 성공률 증가', + fail: '내정 실패율 감소', + warAvoidRatioOppose: '상대 회피 확률 감소', + warCriticalRatioOppose: '상대 필살 확률 감소', + warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', +}; + +const parseBuffRecord = (raw: unknown): Record => { + if (typeof raw === 'string') { + const parsed = parseJson>(raw); + return parsed ?? {}; + } + const record = asRecord(raw); + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (typeof value === 'number' && Number.isFinite(value)) { + result[key] = value; + } + } + return result; +}; + +const serializeBuffRecord = (buff: Record): string => JSON.stringify(buff); + +const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise } } }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState || typeof worldState !== 'object') { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + return worldState as { + config: unknown; + meta: unknown; + currentYear: number; + currentMonth: number; + tickSeconds: number; + }; +}; + +const buildTurnTimeZoneList = (tickMinutes: number): string[] => { + const zones: string[] = []; + for (let i = 0; i < 60; i += 1) { + const totalMinutes = i * tickMinutes; + const hour = Math.floor(totalMinutes / 60) % 24; + const minute = totalMinutes % 60; + zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`); + } + 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 formatTimeLabel = (value: Date): string => { + const hours = String(value.getHours()).padStart(2, '0'); + const minutes = String(value.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; +}; + +const resolveSeasonValue = (meta: Record): number | null => { + const raw = meta.season; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.floor(raw); + } + if (typeof raw === 'string') { + const parsed = Number(raw); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return null; +}; + +const readResetSeasons = (meta: Record): number[] => { + if (!Array.isArray(meta.last_stat_reset)) { + return []; + } + return meta.last_stat_reset + .map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null)) + .filter((value): value is number => value !== null); +}; + +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 bonusCount = rng.nextInt(2) + 3; + const bonus = [0, 0, 0] as [number, number, number]; + for (let i = 0; i < bonusCount; i += 1) { + const index = pickWeightedIndex(rng, baseStats); + bonus[index] += 1; + } + return bonus; +}; + +export const inheritRouter = router({ + getStatus: authedProcedure.query(async ({ ctx }) => { + 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 general = await ctx.db.general.findFirst({ + where: { userId }, + select: { + id: true, + name: true, + nationId: true, + npcState: true, + special2Code: true, + meta: true, + turnTime: true, + }, + }); + + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + + const meta = asRecord(worldState.meta); + const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; + const items = await computeInheritanceItems({ + db: ctx.db, + userId, + generalMeta: asRecord(general.meta), + isUnited, + }); + const totalPoint = sumInheritanceItems(items); + + const inheritConst = resolveInheritConstants(worldState); + const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff); + const buffLevels = BUFF_KEYS.reduce>((acc, key) => { + acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0))); + return acc; + }, {}); + + const resetSpecialLevel = asNumber(asRecord(general.meta).inheritResetSpecialWar, -1) + 1; + const resetTurnLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1) + 1; + + const config = asRecord(worldState.config); + const constValues = asRecord(config.const); + const availableSpecialWar = Array.isArray(constValues.availableSpecialWar) + ? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string') + : []; + const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; + const warTraitKeys = warKeys.filter(isWarTraitKey); + const warModules = await loadWarTraitModules(warTraitKeys, new WarTraitLoader()); + const warSpecials = warModules.map((trait) => ({ + key: trait.key, + name: trait.name, + info: trait.info ?? '', + })); + + const others = await ctx.db.general.findMany({ + where: { id: { not: general.id }, userId: { not: null } }, + select: { id: true, name: true }, + orderBy: { id: 'asc' }, + }); + + return { + items, + totalPoint, + inheritConst, + buffLevels, + resetCosts: { + resetSpecialWar: buildResetCost(inheritConst.inheritResetAttrPointBase, resetSpecialLevel), + resetTurnTime: buildResetCost(inheritConst.inheritResetAttrPointBase, resetTurnLevel), + }, + resetLevels: { + resetSpecialWar: resetSpecialLevel, + resetTurnTime: resetTurnLevel, + }, + availableSpecialWar: warSpecials, + availableTargetGenerals: others, + turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))), + isUnited, + currentSpecialWar: general.special2Code ?? 'None', + }; + }), + getLogs: authedProcedure + .input( + z.object({ + lastId: z.number().int().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const lastId = input.lastId ?? Number.MAX_SAFE_INTEGER; + const logs = await ctx.db.inheritanceLog.findMany({ + where: { + userId, + id: { lt: lastId }, + }, + orderBy: { id: 'desc' }, + take: 30, + select: { id: true, year: true, month: true, text: true }, + }); + return logs; + }), + buyHiddenBuff: authedProcedure + .input( + z.object({ + type: z.enum(BUFF_KEYS), + level: z.number().int().min(1).max(5), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, meta: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + + const buff = parseBuffRecord(asRecord(general.meta).inheritBuff); + const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0))); + if (input.level === prevLevel) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' }); + } + if (input.level < prevLevel) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 더 높은 등급을 구입했습니다.' }); + } + const cost = inheritConst.inheritBuffPoints[input.level] - inheritConst.inheritBuffPoints[prevLevel]; + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < cost) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const buffText = BUFF_LABELS[input.type]; + const moreText = prevLevel > 0 ? '추가' : ''; + buff[input.type] = input.level; + await ctx.db.general.update({ + where: { id: general.id }, + data: { + meta: { + ...asRecord(general.meta), + inheritBuff: serializeBuffRecord(buff), + }, + }, + }); + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${cost} 포인트로 ${buffText} ${input.level} 단계 ${moreText}구입` + ); + return { ok: true, remainPoint: currentPoint - cost }; + }), + setNextSpecialWar: authedProcedure + .input( + z.object({ + specialKey: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + + if (!isWarTraitKey(input.specialKey)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '잘못된 전투 특기입니다.' }); + } + const config = asRecord(worldState.config); + const constValues = asRecord(config.const); + const allowedSpecialWar = Array.isArray(constValues.availableSpecialWar) + ? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string') + : []; + if (allowedSpecialWar.length > 0 && !allowedSpecialWar.includes(input.specialKey)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '허용되지 않은 전투 특기입니다.' }); + } + + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < inheritConst.inheritSpecificSpecialPoint) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, meta: true, special2Code: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + if (general.special2Code === input.specialKey) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 보유하고 있습니다.' }); + } + const meta = asRecord(general.meta); + const reservedSpecial = + typeof meta.inheritSpecificSpecialWar === 'string' ? meta.inheritSpecificSpecialWar : null; + if (reservedSpecial === input.specialKey) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 예약하였습니다.' }); + } + if (reservedSpecial) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' }); + } + + const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader()); + const warName = warModule?.name ?? input.specialKey; + + await ctx.db.general.update({ + where: { id: general.id }, + data: { + meta: { + ...meta, + inheritSpecificSpecialWar: input.specialKey, + }, + }, + }); + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${inheritConst.inheritSpecificSpecialPoint} 포인트로 다음 전투 특기로 ${warName} 지정` + ); + return { ok: true }; + }), + resetSpecialWar: authedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, special2Code: true, meta: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + if (!general.special2Code || general.special2Code === 'None') { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 전투 특기가 공란입니다.' }); + } + + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const currentLevel = asNumber(asRecord(general.meta).inheritResetSpecialWar, -1); + const nextLevel = currentLevel + 1; + const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel); + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < cost) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const meta = asRecord(general.meta); + const prevList = parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; + prevList.push(general.special2Code); + + await ctx.db.general.update({ + where: { id: general.id }, + data: { + special2Code: 'None', + meta: { + ...meta, + inheritResetSpecialWar: nextLevel, + prev_types_special2: JSON.stringify(prevList), + }, + }, + }); + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); + await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`); + return { ok: true }; + }), + resetTurnTime: authedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, meta: true, turnTime: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const currentLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1); + const nextLevel = currentLevel + 1; + const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel); + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < cost) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); + const baseTime = alignToTurnBase(general.turnTime ?? new Date(), tickMinutes); + const seedBase = `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetTurnTime:${userId}:${general.id}`; + const rng = new LiteHashDRBG(seedBase); + const offsetMinutes = rng.nextFloat1() * tickMinutes; + let nextTurnTime = new Date(baseTime.getTime() + offsetMinutes * 60000); + if (nextTurnTime.getTime() <= Date.now()) { + nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000); + } + + await ctx.db.general.update({ + where: { id: general.id }, + data: { + turnTime: nextTurnTime, + meta: { + ...asRecord(general.meta), + inheritResetTurnTime: nextLevel, + }, + }, + }); + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${formatTimeLabel(nextTurnTime)} 적용` + ); + return { ok: true, nextTurnTime: nextTurnTime.toISOString() }; + }), + resetStat: authedProcedure + .input( + z.object({ + leadership: z.number().int(), + strength: z.number().int(), + intel: z.number().int(), + 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 resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + const config = asRecord(worldState.config); + const statConfig = asRecord(config.stat); + const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel); + const statMin = asNumber(statConfig.min, 1); + const statMax = asNumber(statConfig.max, 999); + + const total = input.leadership + input.strength + input.intel; + if (total !== statTotal) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`, + }); + } + if ( + input.leadership < statMin || + input.strength < statMin || + input.intel < statMin || + input.leadership > statMax || + input.strength > statMax || + input.intel > statMax + ) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '능력치 범위를 벗어났습니다.' }); + } + + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const bonus = input.inheritBonusStat ?? [0, 0, 0]; + const bonusSum = bonus.reduce((acc, value) => acc + value, 0); + if (bonus.some((value) => value < 0)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '보너스 능력치가 음수입니다. 다시 입력해주세요!', + }); + } + if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!', + }); + } + + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0; + if (currentPoint < cost) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, npcState: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + if (general.npcState >= 2) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' }); + } + + const seasonValue = resolveSeasonValue(worldMeta); + if (seasonValue !== null) { + const userState = await readUserStateMeta(ctx.db, userId); + const resetSeasons = readResetSeasons(userState); + if (resetSeasons.includes(seasonValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '이번 시즌에 이미 능력치를 초기화하셨습니다.', + }); + } + } + + const finalBonus = + bonusSum === 0 + ? buildRandomBonus( + new LiteHashDRBG( + `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}` + ), + [input.leadership, input.strength, input.intel] + ) + : (bonus as [number, number, number]); + const nextStats = { + leadership: input.leadership + finalBonus[0], + strength: input.strength + finalBonus[1], + intel: input.intel + finalBonus[2], + }; + + await ctx.db.general.update({ + where: { id: general.id }, + data: { + leadership: nextStats.leadership, + strength: nextStats.strength, + intel: nextStats.intel, + }, + }); + + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정` + ); + if (bonusSum > 0) { + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용` + ); + } else { + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용` + ); + } + if (cost > 0) { + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); + } + if (seasonValue !== null) { + const userState = await readUserStateMeta(ctx.db, userId); + const resetSeasons = readResetSeasons(userState); + const nextSeasons = resetSeasons.includes(seasonValue) + ? resetSeasons + : [...resetSeasons, seasonValue]; + await writeUserStateMeta(ctx.db, userId, { + ...userState, + last_stat_reset: nextSeasons, + }); + } + return { ok: true, stats: nextStats }; + }), + buyRandomUnique: authedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < inheritConst.inheritItemRandomPoint) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, meta: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + const meta = asRecord(general.meta); + if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' }); + } + + await ctx.db.general.update({ + where: { id: general.id }, + data: { + meta: { + ...meta, + inheritRandomUnique: 1, + }, + }, + }); + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritItemRandomPoint); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${inheritConst.inheritItemRandomPoint} 포인트로 랜덤 유니크 구입` + ); + return { ok: true }; + }), + openUniqueAuction: authedProcedure + .input( + z.object({ + itemId: z.string(), + amount: z.number().int().min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + if (input.amount < inheritConst.inheritItemUniqueMinPoint) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰 포인트가 부족합니다.' }); + } + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < input.amount) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, meta: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + const meta = asRecord(general.meta); + if (meta.inheritSpecificUnique) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' }); + } + + await ctx.db.general.update({ + where: { id: general.id }, + data: { + meta: { + ...meta, + inheritSpecificUnique: JSON.stringify({ + itemId: input.itemId, + amount: input.amount, + requestedAt: new Date().toISOString(), + }), + }, + }, + }); + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - input.amount); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${input.amount} 포인트로 유니크 경매 신청` + ); + return { ok: true }; + }), + checkOwner: authedProcedure + .input( + z.object({ + targetGeneralId: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const worldState = await resolveWorld(ctx); + const worldMeta = asRecord(worldState.meta); + if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); + } + const inheritConst = resolveInheritConstants(worldState as WorldStateRow); + const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (currentPoint < inheritConst.inheritCheckOwnerPoint) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); + } + + const [general, target] = await Promise.all([ + ctx.db.general.findFirst({ where: { userId }, select: { id: true } }), + ctx.db.general.findUnique({ + where: { id: input.targetGeneralId }, + select: { id: true, name: true, userId: true, meta: true }, + }), + ]); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + if (!target || !target.userId) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 장수가 존재하지 않습니다.' }); + } + if (target.id === general.id) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' }); + } + + const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId; + + await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인` + ); + return { ok: true, ownerName, targetName: target.name }; + }), +}); diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 3123f2f..bb98a8a 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto'; import type { WorldStateRow } from '../../context.js'; import { authedProcedure, router } from '../../trpc.js'; -import { asNumber, asRecord, asStringArray, parseBooleanWithFallback } from '@sammo-ts/common'; +import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common'; import { isPersonalityTraitKey, isWarTraitKey, @@ -15,6 +15,12 @@ import { WarTraitLoader, WAR_TRAIT_KEYS, } from '@sammo-ts/logic'; +import { + appendInheritanceLog, + readInheritancePoint, + resolveInheritConstants, + setInheritancePoint, +} from '../../services/inheritance.js'; const DEFAULT_JOIN_STAT = { total: 165, @@ -63,6 +69,56 @@ const pickFromList = (values: string[], seed: string): string | null => { return values[index] ?? null; }; +const buildTurnTimeZones = (tickMinutes: number): string[] => { + const zones: string[] = []; + for (let i = 0; i < 60; i += 1) { + const totalMinutes = i * tickMinutes; + const hour = Math.floor(totalMinutes / 60) % 24; + const minute = totalMinutes % 60; + zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`); + } + return zones; +}; + +const alignToTurnBase = (time: Date, tickMinutes: number): Date => { + const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0); + const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000); + const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes); + return new Date(base.getTime() + alignedMinutes * 60000); +}; + +const nextRangeInt = (rng: LiteHashDRBG, minInclusive: number, maxInclusive: number): number => { + if (maxInclusive <= minInclusive) { + return minInclusive; + } + return minInclusive + rng.nextInt(maxInclusive - minInclusive); +}; + +const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => { + const total = weights.reduce((acc, value) => acc + value, 0); + if (total <= 0) { + return 0; + } + let cursor = rng.nextFloat1() * total; + for (let i = 0; i < weights.length; i += 1) { + cursor -= weights[i] ?? 0; + if (cursor <= 0) { + return i; + } + } + return weights.length - 1; +}; + +const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => { + const count = rng.nextInt(2) + 3; + const bonus: [number, number, number] = [0, 0, 0]; + for (let i = 0; i < count; i += 1) { + const index = pickWeightedIndex(rng, baseStats); + bonus[index] += 1; + } + return bonus; +}; + let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null; const loadPersonalityOptions = async () => { @@ -127,6 +183,25 @@ export const joinRouter = router({ }; }); + const inheritConst = resolveInheritConstants(worldState); + const inheritTotalPoint = ctx.auth?.user.id + ? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous') + : 0; + const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); + const inheritCitiesRaw = await ctx.db.city.findMany({ + where: { level: { in: [5, 6] }, nationId: 0 }, + select: { id: true, name: true, level: true, region: true }, + orderBy: { id: 'asc' }, + }); + const inheritCities = + inheritCitiesRaw.length > 0 + ? inheritCitiesRaw + : await ctx.db.city.findMany({ + where: { level: { in: [5, 6] } }, + select: { id: true, name: true, level: true, region: true }, + orderBy: { id: 'asc' }, + }); + return { rules: { stat: resolveJoinStat(worldState), @@ -142,6 +217,18 @@ export const joinRouter = router({ ], warSpecials, nations, + inherit: { + totalPoint: inheritTotalPoint, + costs: { + inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint, + inheritBornTurntimePoint: inheritConst.inheritBornTurntimePoint, + inheritBornCityPoint: inheritConst.inheritBornCityPoint, + inheritBornStatPoint: inheritConst.inheritBornStatPoint, + }, + availableCities: inheritCities, + turnTimeZones: buildTurnTimeZones(tickMinutes), + availableSpecialWar: warSpecials, + }, }; }), createGeneral: authedProcedure @@ -181,6 +268,48 @@ export const joinRouter = router({ }); } + const inheritConst = resolveInheritConstants(worldState); + const configConst = asRecord(asRecord(worldState.config).const); + const availableSpecialWar = asStringArray(configConst.availableSpecialWar); + const inheritBonus = input.inheritBonusStat ?? null; + if (inheritBonus) { + const bonusSum = inheritBonus.reduce((acc, value) => acc + value, 0); + if (inheritBonus.some((value) => value < 0)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '보너스 능력치가 음수입니다. 다시 가입해주세요!', + }); + } + if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!', + }); + } + } + if (input.inheritSpecial !== undefined) { + if (!isWarTraitKey(input.inheritSpecial)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '전투 특기가 잘못 지정되었습니다.', + }); + } + if (availableSpecialWar.length > 0 && !availableSpecialWar.includes(input.inheritSpecial)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '허용되지 않은 전투 특기입니다.', + }); + } + } + if (input.inheritTurntimeZone !== undefined) { + if (input.inheritTurntimeZone < 0 || input.inheritTurntimeZone > 59) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '턴 시간 지정 범위가 올바르지 않습니다.', + }); + } + } + const statRule = resolveJoinStat(worldState); const statTotal = input.leadership + input.strength + input.intel; @@ -232,7 +361,7 @@ export const joinRouter = router({ ? input.character : 'None'; - return ctx.db.$transaction(async (db) => { + return ctx.db.$transaction!(async (db) => { const existing = await db.general.findFirst({ where: { userId } }); if (existing) { throw new TRPCError({ @@ -251,7 +380,7 @@ export const joinRouter = router({ 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 }, + select: { id: true, level: true, nationId: true, name: true }, orderBy: { id: 'asc' }, }); if (!cityList.length) { @@ -260,49 +389,109 @@ export const joinRouter = router({ message: '도시 정보를 찾을 수 없습니다.', }); } - // 통합 테스트 전용: ENV로 지정한 경우에만 도시 지정 허용. - const allowCityOverride = parseBooleanWithFallback(process.env.INTEGRATION_JOIN_ALLOW_CITY, false); - if (allowCityOverride && typeof input.inheritCity === 'number') { - const override = cityList.find((city) => city.id === input.inheritCity); - if (!override) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '지정한 도시를 찾을 수 없습니다.', - }); - } - if (override.level !== 5 && override.level !== 6) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '통합 테스트에서는 소성/중성 도시만 지정할 수 있습니다.', - }); - } - const general = await db.general.create({ - data: { - id: nextId, - userId, - name: generalName, - nationId: 0, - cityId: override.id, - 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', - }, - }, + 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: '생성 가능한 도시가 없습니다.', }); - - return { ok: true, generalId: general.id }; } - const cityIndex = hashString(userId) % cityList.length; - const cityId = cityList[cityIndex]?.id ?? cityList[0].id; + 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 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: { @@ -313,19 +502,29 @@ export const joinRouter = router({ cityId, troopId: 0, npcState: 0, - leadership: input.leadership, - strength: input.strength, - intel: input.intel, + leadership: finalLeadership, + strength: finalStrength, + intel: finalIntel, personalCode: chosenPersonality ?? 'None', - specialCode: 'None', - special2Code: 'None', - turnTime: new Date(), + specialCode: defaultSpecialDomestic, + special2Code: specialWar, + turnTime, + age, + startAge: age, meta: { createdBy: 'join', + ownerName: ctx.auth?.user.displayName ?? '', }, }, }); + 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 }; }); }), diff --git a/app/game-api/src/services/inheritance.ts b/app/game-api/src/services/inheritance.ts new file mode 100644 index 0000000..cfff4cb --- /dev/null +++ b/app/game-api/src/services/inheritance.ts @@ -0,0 +1,278 @@ +import { asNumber, asRecord } from '@sammo-ts/common'; +import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js'; + +export type InheritPointKey = + | 'previous' + | 'lived_month' + | 'max_domestic_critical' + | 'active_action' + | 'combat' + | 'sabotage' + | 'dex' + | 'unifier' + | 'tournament' + | 'betting' + | 'max_belong'; + +export interface InheritConstants { + minMonthToAllowInheritItem: number; + inheritBornSpecialPoint: number; + inheritBornTurntimePoint: number; + inheritBornCityPoint: number; + inheritBornStatPoint: number; + inheritItemUniqueMinPoint: number; + inheritItemRandomPoint: number; + inheritBuffPoints: number[]; + inheritSpecificSpecialPoint: number; + inheritResetAttrPointBase: number[]; + inheritCheckOwnerPoint: number; +} + +const DEFAULT_INHERIT_CONST: InheritConstants = { + minMonthToAllowInheritItem: 4, + inheritBornSpecialPoint: 6000, + inheritBornTurntimePoint: 2500, + inheritBornCityPoint: 1000, + inheritBornStatPoint: 1000, + inheritItemUniqueMinPoint: 5000, + inheritItemRandomPoint: 3000, + inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000], + inheritSpecificSpecialPoint: 4000, + inheritResetAttrPointBase: [1000, 1000, 2000, 3000], + inheritCheckOwnerPoint: 1000, +}; + +const resolveNumberArray = (value: unknown, fallback: number[]): number[] => { + if (!Array.isArray(value)) { + return fallback; + } + const filtered = value + .map((item) => (typeof item === 'number' && Number.isFinite(item) ? item : null)) + .filter((item): item is number => item !== null); + return filtered.length > 0 ? filtered : fallback; +}; + +export const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants => { + const config = asRecord(worldState.config); + const configConst = asRecord(config.const); + + return { + minMonthToAllowInheritItem: asNumber( + configConst.minMonthToAllowInheritItem, + DEFAULT_INHERIT_CONST.minMonthToAllowInheritItem + ), + inheritBornSpecialPoint: asNumber( + configConst.inheritBornSpecialPoint, + DEFAULT_INHERIT_CONST.inheritBornSpecialPoint + ), + inheritBornTurntimePoint: asNumber( + configConst.inheritBornTurntimePoint, + DEFAULT_INHERIT_CONST.inheritBornTurntimePoint + ), + inheritBornCityPoint: asNumber( + configConst.inheritBornCityPoint, + DEFAULT_INHERIT_CONST.inheritBornCityPoint + ), + inheritBornStatPoint: asNumber( + configConst.inheritBornStatPoint, + DEFAULT_INHERIT_CONST.inheritBornStatPoint + ), + inheritItemUniqueMinPoint: asNumber( + configConst.inheritItemUniqueMinPoint, + DEFAULT_INHERIT_CONST.inheritItemUniqueMinPoint + ), + inheritItemRandomPoint: asNumber( + configConst.inheritItemRandomPoint, + DEFAULT_INHERIT_CONST.inheritItemRandomPoint + ), + inheritBuffPoints: resolveNumberArray(configConst.inheritBuffPoints, DEFAULT_INHERIT_CONST.inheritBuffPoints), + inheritSpecificSpecialPoint: asNumber( + configConst.inheritSpecificSpecialPoint, + DEFAULT_INHERIT_CONST.inheritSpecificSpecialPoint + ), + inheritResetAttrPointBase: resolveNumberArray( + configConst.inheritResetAttrPointBase, + DEFAULT_INHERIT_CONST.inheritResetAttrPointBase + ), + inheritCheckOwnerPoint: asNumber( + configConst.inheritCheckOwnerPoint, + DEFAULT_INHERIT_CONST.inheritCheckOwnerPoint + ), + }; +}; + +export const buildResetCost = (baseCosts: number[], level: number): number => { + const costs = [...baseCosts]; + while (costs.length <= level) { + const size = costs.length; + const next = (costs[size - 1] ?? 0) + (costs[size - 2] ?? 0); + costs.push(next); + } + return costs[level] ?? 0; +}; + +export const readInheritancePoint = async ( + db: DatabaseClient, + userId: string, + key: InheritPointKey +): Promise => { + const row = await db.inheritancePoint.findUnique({ + where: { + userId_key: { + userId, + key, + }, + }, + select: { + value: true, + }, + }); + return row?.value ?? 0; +}; + +export const setInheritancePoint = async ( + db: DatabaseClient, + userId: string, + key: InheritPointKey, + value: number +): Promise => { + await db.inheritancePoint.upsert({ + where: { + userId_key: { + userId, + key, + }, + }, + update: { + value, + }, + create: { + userId, + key, + value, + }, + }); +}; + +export const addInheritancePoint = async ( + db: DatabaseClient, + userId: string, + key: InheritPointKey, + delta: number +): Promise => { + const current = await readInheritancePoint(db, userId, key); + const next = current + delta; + await setInheritancePoint(db, userId, key, next); + return next; +}; + +export const appendInheritanceLog = async ( + db: DatabaseClient, + userId: string, + year: number, + month: number, + text: string +): Promise => { + await db.inheritanceLog.create({ + data: { + userId, + year, + month, + text, + }, + }); +}; + +export const readUserMetaValue = (meta: Record, key: string): number => { + const value = meta[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return value; +}; + +export const computeDexPoint = (meta: Record): number => { + let total = 0; + for (const [key, value] of Object.entries(meta)) { + if (!key.startsWith('dex')) { + continue; + } + if (typeof value === 'number' && Number.isFinite(value)) { + total += value; + } + } + return total * 0.001; +}; + +export const computeInheritanceItems = async (options: { + db: DatabaseClient; + userId: string; + generalMeta: Record | null; + isUnited: boolean; +}): Promise> => { + const previous = await readInheritancePoint(options.db, options.userId, 'previous'); + const unifier = await readInheritancePoint(options.db, options.userId, 'unifier'); + + if (options.isUnited) { + return { + previous, + lived_month: 0, + max_domestic_critical: 0, + active_action: 0, + combat: 0, + sabotage: 0, + dex: 0, + unifier, + tournament: 0, + betting: 0, + max_belong: 0, + }; + } + + const meta = options.generalMeta ?? {}; + const livedMonth = readUserMetaValue(meta, 'inherit_lived_month'); + const maxDomestic = readUserMetaValue(meta, 'max_domestic_critical'); + const activeAction = readUserMetaValue(meta, 'inherit_active_action'); + const combat = readUserMetaValue(meta, 'rank_warnum') * 5; + const sabotage = readUserMetaValue(meta, 'firenum') * 20; + const dex = computeDexPoint(meta); + + return { + previous, + lived_month: livedMonth, + max_domestic_critical: maxDomestic, + active_action: activeAction, + combat, + sabotage, + dex, + unifier, + tournament: 0, + betting: 0, + max_belong: 0, + }; +}; + +export const sumInheritanceItems = (items: Record): number => { + return Object.entries(items).reduce((acc, [key, value]) => (key === 'previous' ? acc : acc + value), items.previous); +}; + +export const readUserStateMeta = async (db: DatabaseClient, userId: string): Promise> => { + const row = await db.inheritanceUserState.findUnique({ + where: { userId }, + select: { meta: true }, + }); + return asRecord(row?.meta); +}; + +export const writeUserStateMeta = async ( + db: DatabaseClient, + userId: string, + meta: Record +): Promise => { + const jsonMeta = meta as InputJsonValue; + await db.inheritanceUserState.upsert({ + where: { userId }, + update: { meta: jsonMeta }, + create: { userId, meta: jsonMeta }, + }); +}; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 3c606a0..74806cd 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -210,6 +210,20 @@ export class InMemoryTurnWorld { return { ...this.state }; } + updateWorldMeta(patch: Record): void { + this.state = { + ...this.state, + meta: { + ...this.state.meta, + ...patch, + }, + }; + } + + pushLog(entry: LogEntryDraft): void { + this.logs.push(entry); + } + getScenarioConfig(): ScenarioConfig { return this.scenarioConfig; } diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 5ab8cff..d364d8f 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -14,6 +14,7 @@ import { createItemModuleRegistry, ITEM_KEYS, loadItemModules, + createInheritBuffModules, } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; @@ -107,8 +108,11 @@ export const buildReservedTurnDefinitions = async (options: { const itemModules = await loadItemModules([...ITEM_KEYS]); const itemRegistry = createItemModuleRegistry(itemModules); const itemActionModules = createItemActionModules(itemRegistry); + const inheritBuffModules = createInheritBuffModules(); options.env.generalActionModules = [...(options.env.generalActionModules ?? []), ...itemActionModules.general]; options.env.warActionModules = [...(options.env.warActionModules ?? []), ...itemActionModules.war]; + options.env.generalActionModules.push(inheritBuffModules.general); + options.env.warActionModules.push(inheritBuffModules.war); const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general); const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 6e1f993..6948171 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -468,7 +468,7 @@ export const createReservedTurnHandler = async (options: { fallbackDefinition: GeneralActionDefinition, command: ReservedTurnEntry, applyNextTurnAt: boolean - ): Date | undefined => { + ): { nextTurnAt?: Date; actionKey: string } => { const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); const rawArgs = extractArgsRecord(command.args); const parsedArgs = resolvedDefinition.parseArgs(rawArgs); @@ -658,7 +658,7 @@ export const createReservedTurnHandler = async (options: { } } - return applyNextTurnAt ? resolution.nextTurnAt : undefined; + return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey }; }; if (currentNation && currentGeneral.officerLevel >= 5) { @@ -718,9 +718,25 @@ export const createReservedTurnHandler = async (options: { generalCommand = { action: candidate.action, args: candidate.args }; } } - const nextTurnAt = runAction(generalDefinitions, generalFallback, generalCommand, true); + const generalResult = runAction(generalDefinitions, generalFallback, generalCommand, true); + const nextTurnAt = generalResult.nextTurnAt; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); + const worldMeta = asRecord(context.world.meta); + if (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) { + const meta = { ...currentGeneral.meta }; + const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0; + const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; + meta.inherit_lived_month = lived + 1; + if (generalResult.actionKey !== DEFAULT_ACTION) { + meta.inherit_active_action = active + 1; + } else { + meta.inherit_active_action = active; + } + currentGeneral = { ...currentGeneral, meta }; + worldOverlay?.syncGeneral(currentGeneral); + } + const result: GeneralTurnResult = { general: currentGeneral, city: currentCity, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 9d25c5f..fe622d1 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -22,6 +22,7 @@ import { createTurnDaemonCommandHandler } from './worldCommandHandler.js'; import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; import { shouldUseAi } from './ai/generalAi.js'; +import { createUnificationHandler } from './unificationHandler.js'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -99,6 +100,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) }) : await loadTurnCommandProfile()); let worldRef: InMemoryTurnWorld | null = null; + const unification = options.calendarHandler + ? null + : createUnificationHandler({ + databaseUrl: options.databaseUrl, + profileName: options.profileName ?? options.profile, + getWorld: () => worldRef, + }); const worldOptions: InMemoryTurnWorldOptions = { schedule, generalTurnHandler: @@ -112,7 +120,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) getWorld: () => worldRef, commandProfile, })), - calendarHandler: options.calendarHandler, + calendarHandler: options.calendarHandler ?? unification?.handler, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); worldRef = world; @@ -250,6 +258,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) const baseClose = close; close = async () => { await baseClose(); + if (unification) { + await unification.close(); + } if (redisConnector) { await redisConnector.disconnect(); } diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts new file mode 100644 index 0000000..bc4757d --- /dev/null +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -0,0 +1,200 @@ +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import { asRecord } from '@sammo-ts/common'; +import type { LogEntryDraft } from '@sammo-ts/logic'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; + +import type { TurnCalendarHandler } from './inMemoryWorld.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +const UNIFIER_POINT = 2000; + +const readMetaNumber = (meta: Record, key: string): number => { + const value = meta[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return value; +}; + +const computeDexPoint = (meta: Record): number => { + let total = 0; + for (const [key, value] of Object.entries(meta)) { + if (!key.startsWith('dex')) { + continue; + } + if (typeof value === 'number' && Number.isFinite(value)) { + total += value; + } + } + return total * 0.001; +}; + +const buildUnificationLog = (nationName: string): LogEntryDraft => ({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `【통일】${nationName}이 전토를 통일하였습니다.`, + meta: {}, +}); + +export const createUnificationHandler = (options: { + databaseUrl: string; + profileName: string; + getWorld: () => InMemoryTurnWorld | null; +}): { handler: TurnCalendarHandler; close: () => Promise } => { + const connector = createGamePostgresConnector({ url: options.databaseUrl }); + const ready = connector.connect(); + + const settleInheritance = async (winnerNationId: number, year: number, month: number): Promise => { + await ready; + const prisma = connector.prisma; + + const generals = await prisma.general.findMany({ + where: { + userId: { not: null }, + npcState: { lt: 2 }, + }, + select: { + id: true, + userId: true, + nationId: true, + officerLevel: true, + meta: true, + }, + }); + + const userIds = Array.from(new Set(generals.map((general) => general.userId).filter(Boolean))) as string[]; + if (userIds.length === 0) { + return; + } + + const pointRows = await prisma.inheritancePoint.findMany({ + where: { + userId: { in: userIds }, + }, + select: { + userId: true, + key: true, + value: true, + }, + }); + const pointMap = new Map>(); + for (const row of pointRows) { + const bucket = pointMap.get(row.userId) ?? new Map(); + bucket.set(row.key, row.value); + pointMap.set(row.userId, bucket); + } + + for (const general of generals) { + if (!general.userId) { + continue; + } + const meta = asRecord(general.meta); + const livedMonth = readMetaNumber(meta, 'inherit_lived_month'); + const maxDomestic = readMetaNumber(meta, 'max_domestic_critical'); + const activeAction = readMetaNumber(meta, 'inherit_active_action'); + const combat = readMetaNumber(meta, 'rank_warnum') * 5; + const sabotage = readMetaNumber(meta, 'firenum') * 20; + const dex = computeDexPoint(meta); + + const points = pointMap.get(general.userId) ?? new Map(); + const previous = points.get('previous') ?? 0; + const unifier = points.get('unifier') ?? 0; + const earned = + livedMonth + + maxDomestic + + activeAction * 3 + + combat + + sabotage + + dex + + unifier + + (general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0); + + const total = previous + earned; + + await prisma.inheritancePoint.upsert({ + where: { + userId_key: { + userId: general.userId, + key: 'previous', + }, + }, + update: { value: total }, + create: { userId: general.userId, key: 'previous', value: total }, + }); + + await prisma.inheritancePoint.deleteMany({ + where: { + userId: general.userId, + key: { not: 'previous' }, + }, + }); + + await prisma.inheritanceResult.create({ + data: { + serverId: options.profileName, + owner: general.userId, + generalId: general.id, + year, + month, + value: { + previous, + lived_month: livedMonth, + max_domestic_critical: maxDomestic, + active_action: activeAction, + combat, + sabotage, + dex, + unifier, + unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, + }, + }, + }); + + await prisma.inheritanceLog.create({ + data: { + userId: general.userId, + year, + month, + text: `천하 통일 정산: ${Math.floor(total).toLocaleString()} 포인트`, + }, + }); + } + }; + + const handler: TurnCalendarHandler = { + onMonthChanged: (context) => { + const world = options.getWorld(); + if (!world) { + return; + } + const state = world.getState(); + const meta = asRecord(state.meta); + if (typeof meta.isUnited === 'number' && meta.isUnited !== 0) { + return; + } + + const activeNations = world.listNations().filter((nation) => nation.level > 0); + if (activeNations.length !== 1) { + return; + } + const winner = activeNations[0]; + const cities = world.listCities(); + const ownedCount = cities.filter((city) => city.nationId === winner.id).length; + if (ownedCount !== cities.length) { + return; + } + + world.updateWorldMeta({ isUnited: 2 }); + world.pushLog(buildUnificationLog(winner.name)); + void settleInheritance(winner.id, context.currentYear, context.currentMonth); + }, + }; + + const close = async (): Promise => { + await ready; + await connector.disconnect(); + }; + + return { handler, close }; +}; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 893d6f8..7e21081 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -3,6 +3,7 @@ import MainView from '../views/MainView.vue'; import PublicView from '../views/PublicView.vue'; import LoginView from '../views/LoginView.vue'; import JoinView from '../views/JoinView.vue'; +import InheritView from '../views/InheritView.vue'; import NationCitiesView from '../views/NationCitiesView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; @@ -33,6 +34,15 @@ const routes = [ requiresNoGeneral: true, }, }, + { + path: '/inherit', + name: 'inherit', + component: InheritView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/cities', name: 'nation-cities', diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue new file mode 100644 index 0000000..8e8261d --- /dev/null +++ b/app/game-frontend/src/views/InheritView.vue @@ -0,0 +1,889 @@ + + + + + diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 37be7f9..9073508 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -5,10 +5,12 @@ import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; import { trpc } from '../utils/trpc'; import { useSessionStore } from '../stores/session'; +import { cityLevelMap, regionMap } from '../utils/nationFormat'; type JoinConfig = Awaited>; type JoinInput = Parameters[0]; type PossessCandidate = Awaited>[0]; +type JoinForm = Omit & { inheritBonusStat: [number, number, number] }; const router = useRouter(); const session = useSessionStore(); @@ -20,13 +22,14 @@ const submitting = ref(false); const joinConfig = ref(null); const activeTab = ref<'create' | 'possess'>('create'); -const form = ref({ +const form = ref({ name: '', leadership: 0, strength: 0, intel: 0, character: 'Random', pic: true, + inheritBonusStat: [0, 0, 0], }); const npcCandidates = ref([]); @@ -63,11 +66,83 @@ const canSubmit = computed(() => { if (statErrors.value.length > 0) { return false; } + if (inheritErrors.value.length > 0) { + return false; + } return true; }); const nationList = computed(() => joinConfig.value?.nations ?? []); const personalities = computed(() => joinConfig.value?.personalities ?? []); +const inheritConfig = computed(() => joinConfig.value?.inherit ?? null); + +const inheritTotalPoint = computed(() => inheritConfig.value?.totalPoint ?? 0); +const inheritCosts = computed( + () => + inheritConfig.value?.costs ?? { + inheritBornSpecialPoint: 0, + inheritBornTurntimePoint: 0, + inheritBornCityPoint: 0, + inheritBornStatPoint: 0, + } +); +const inheritRequiredPoint = computed(() => { + let total = 0; + if (form.value.inheritSpecial) { + total += inheritCosts.value.inheritBornSpecialPoint; + } + if (form.value.inheritCity !== undefined) { + total += inheritCosts.value.inheritBornCityPoint; + } + if (form.value.inheritTurntimeZone !== undefined) { + total += inheritCosts.value.inheritBornTurntimePoint; + } + const bonus = form.value.inheritBonusStat ?? [0, 0, 0]; + const bonusSum = bonus.reduce((acc, value) => acc + value, 0); + if (bonusSum > 0) { + total += inheritCosts.value.inheritBornStatPoint; + } + return total; +}); + +const inheritBonusSum = computed(() => { + const bonus = form.value.inheritBonusStat ?? [0, 0, 0]; + return bonus.reduce((acc, value) => acc + value, 0); +}); + +const inheritErrors = computed(() => { + const errors: string[] = []; + const bonus = form.value.inheritBonusStat ?? [0, 0, 0]; + const bonusSum = bonus.reduce((acc, value) => acc + value, 0); + if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) { + errors.push('보너스 능력치는 합 3~5 사이여야 합니다.'); + } + if (inheritRequiredPoint.value > inheritTotalPoint.value) { + errors.push('보유한 유산 포인트가 부족합니다.'); + } + return errors; +}); + +const inheritSpecialChoice = computed({ + get: () => form.value.inheritSpecial ?? '', + set: (value) => { + form.value.inheritSpecial = value ? value : undefined; + }, +}); + +const inheritCityChoice = computed({ + get: () => (form.value.inheritCity !== undefined ? String(form.value.inheritCity) : ''), + set: (value) => { + form.value.inheritCity = value ? Number(value) : undefined; + }, +}); + +const inheritTurntimeChoice = computed({ + get: () => (form.value.inheritTurntimeZone !== undefined ? String(form.value.inheritTurntimeZone) : ''), + set: (value) => { + form.value.inheritTurntimeZone = value ? Number(value) : undefined; + }, +}); const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; @@ -283,8 +358,106 @@ onMounted(() => { - -
특기/도시 선택, 턴 시간 지정은 향후 UI와 함께 제공됩니다.
+ +
유산 포인트 정보를 불러오지 못했습니다.
+
+
+
보유 포인트: {{ inheritTotalPoint }}
+
필요 포인트: {{ inheritRequiredPoint }}
+
+ +
+ + + + + +
+ +
+
보너스 능력치
+
+ + + +
+ + 보너스 합 {{ inheritBonusSum }} (0 또는 3~5) · 비용 + {{ inheritCosts.inheritBornStatPoint }} 포인트 + +
+ +
+
{{ item }}
+
+
@@ -474,6 +647,48 @@ onMounted(() => { font-size: 0.8rem; } +.inherit-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.inherit-summary { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + font-size: 0.8rem; + color: rgba(232, 221, 196, 0.85); +} + +.inherit-options { + display: grid; + gap: 12px; +} + +.inherit-bonus { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bonus-title { + font-size: 0.8rem; + color: rgba(232, 221, 196, 0.8); +} + +.bonus-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); +} + +.inherit-errors { + color: rgba(240, 150, 150, 0.9); + font-size: 0.75rem; +} + .form-actions .ghost, .join-tabs .ghost, .npc-footer .ghost { diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index f270513..17db152 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -94,6 +94,7 @@ watch( 세력 도시 세력 장수 인사부 + 유산 강화