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.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<InheritBuffType, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
};
|
||||
|
||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = parseJson<Record<string, number>>(raw);
|
||||
return parsed ?? {};
|
||||
}
|
||||
const record = asRecord(raw);
|
||||
const result: Record<string, number> = {};
|
||||
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, number>): string => JSON.stringify(buff);
|
||||
|
||||
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
|
||||
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<string, unknown>): 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<string, unknown>): 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<Record<string, number>>((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<string[]>(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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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<number> => {
|
||||
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<void> => {
|
||||
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<number> => {
|
||||
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<void> => {
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
year,
|
||||
month,
|
||||
text,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const readUserMetaValue = (meta: Record<string, unknown>, key: string): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const computeDexPoint = (meta: Record<string, unknown>): 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<string, unknown> | null;
|
||||
isUnited: boolean;
|
||||
}): Promise<Record<InheritPointKey, number>> => {
|
||||
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<InheritPointKey, number>): 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<Record<string, unknown>> => {
|
||||
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<string, unknown>
|
||||
): Promise<void> => {
|
||||
const jsonMeta = meta as InputJsonValue;
|
||||
await db.inheritanceUserState.upsert({
|
||||
where: { userId },
|
||||
update: { meta: jsonMeta },
|
||||
create: { userId, meta: jsonMeta },
|
||||
});
|
||||
};
|
||||
@@ -210,6 +210,20 @@ export class InMemoryTurnWorld {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
updateWorldMeta(patch: Record<string, unknown>): void {
|
||||
this.state = {
|
||||
...this.state,
|
||||
meta: {
|
||||
...this.state.meta,
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pushLog(entry: LogEntryDraft): void {
|
||||
this.logs.push(entry);
|
||||
}
|
||||
|
||||
getScenarioConfig(): ScenarioConfig {
|
||||
return this.scenarioConfig;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>, key: string): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const computeDexPoint = (meta: Record<string, unknown>): 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: `<C>●</><Y><b>【통일】</b></><D><b>${nationName}</b></>이 전토를 통일하였습니다.`,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
export const createUnificationHandler = (options: {
|
||||
databaseUrl: string;
|
||||
profileName: string;
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
}): { handler: TurnCalendarHandler; close: () => Promise<void> } => {
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
const ready = connector.connect();
|
||||
|
||||
const settleInheritance = async (winnerNationId: number, year: number, month: number): Promise<void> => {
|
||||
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<string, Map<string, number>>();
|
||||
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<void> => {
|
||||
await ready;
|
||||
await connector.disconnect();
|
||||
};
|
||||
|
||||
return { handler, close };
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
||||
type InheritLog = Awaited<ReturnType<typeof trpc.inherit.getLogs.query>>[number];
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
|
||||
type BuffKey =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
|
||||
const buffKeys: BuffKey[] = [
|
||||
'warAvoidRatio',
|
||||
'warCriticalRatio',
|
||||
'warMagicTrialProb',
|
||||
'success',
|
||||
'fail',
|
||||
'warAvoidRatioOppose',
|
||||
'warCriticalRatioOppose',
|
||||
'warMagicTrialProbOppose',
|
||||
];
|
||||
|
||||
const buffLabels: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
};
|
||||
|
||||
const pointLabels: Record<string, string> = {
|
||||
previous: '보유',
|
||||
lived_month: '생존 턴',
|
||||
max_domestic_critical: '내정 최고치',
|
||||
active_action: '활동',
|
||||
combat: '전투',
|
||||
sabotage: '계략',
|
||||
dex: '숙련',
|
||||
unifier: '통일 보상',
|
||||
tournament: '토너먼트',
|
||||
betting: '베팅',
|
||||
max_belong: '최대 충성',
|
||||
};
|
||||
|
||||
const pointOrder = [
|
||||
'previous',
|
||||
'lived_month',
|
||||
'max_domestic_critical',
|
||||
'active_action',
|
||||
'combat',
|
||||
'sabotage',
|
||||
'dex',
|
||||
'unifier',
|
||||
'tournament',
|
||||
'betting',
|
||||
'max_belong',
|
||||
] as const;
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const status = ref<InheritStatus | null>(null);
|
||||
|
||||
const logs = ref<InheritLog[]>([]);
|
||||
const logLoading = ref(false);
|
||||
const logEnd = ref(false);
|
||||
|
||||
const actionError = ref<string | null>(null);
|
||||
const actionMessage = ref<string | null>(null);
|
||||
const actionBusy = ref(false);
|
||||
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
|
||||
const buffTargets = reactive<Record<BuffKey, number>>({
|
||||
warAvoidRatio: 1,
|
||||
warCriticalRatio: 1,
|
||||
warMagicTrialProb: 1,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
warAvoidRatioOppose: 1,
|
||||
warCriticalRatioOppose: 1,
|
||||
warMagicTrialProbOppose: 1,
|
||||
});
|
||||
|
||||
const nextSpecialKey = ref('');
|
||||
const ownerTargetId = ref('');
|
||||
const ownerResult = ref<{ targetName: string; ownerName: string } | null>(null);
|
||||
const turnTimeResult = ref<string | null>(null);
|
||||
|
||||
const uniqueForm = reactive({
|
||||
itemId: '',
|
||||
amount: 0,
|
||||
});
|
||||
|
||||
const resetStatForm = reactive({
|
||||
leadership: 0,
|
||||
strength: 0,
|
||||
intel: 0,
|
||||
bonus: [0, 0, 0] as [number, number, number],
|
||||
});
|
||||
|
||||
const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
||||
|
||||
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
|
||||
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
|
||||
const resetStatCost = computed(() =>
|
||||
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
|
||||
);
|
||||
|
||||
const resetStatErrors = computed(() => {
|
||||
const errors: string[] = [];
|
||||
const rules = statRules.value;
|
||||
if (rules) {
|
||||
if (resetStatTotal.value !== rules.total) {
|
||||
errors.push(`능력치 총합이 ${rules.total}이 아닙니다.`);
|
||||
}
|
||||
if (
|
||||
resetStatForm.leadership < rules.min ||
|
||||
resetStatForm.strength < rules.min ||
|
||||
resetStatForm.intel < rules.min ||
|
||||
resetStatForm.leadership > rules.max ||
|
||||
resetStatForm.strength > rules.max ||
|
||||
resetStatForm.intel > rules.max
|
||||
) {
|
||||
errors.push(`능력치는 ${rules.min} ~ ${rules.max} 범위여야 합니다.`);
|
||||
}
|
||||
}
|
||||
if (resetBonusSum.value !== 0 && (resetBonusSum.value < 3 || resetBonusSum.value > 5)) {
|
||||
errors.push('보너스 능력치 합이 잘못되었습니다.');
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
|
||||
const turnTimeLabel = computed(() => {
|
||||
if (!turnTimeResult.value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(turnTimeResult.value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return turnTimeResult.value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
});
|
||||
|
||||
const isUnited = computed(() => status.value?.isUnited ?? false);
|
||||
|
||||
const pointEntries = computed(() => {
|
||||
if (!status.value) {
|
||||
return [] as Array<{ key: string; label: string; value: number }>;
|
||||
}
|
||||
return pointOrder.map((key) => ({
|
||||
key,
|
||||
label: pointLabels[key] ?? key,
|
||||
value: status.value?.items[key] ?? 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const specialNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of status.value?.availableSpecialWar ?? []) {
|
||||
map.set(entry.key, entry.name);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const currentSpecialName = computed(() => {
|
||||
if (!status.value) {
|
||||
return '-';
|
||||
}
|
||||
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
|
||||
});
|
||||
|
||||
const buffCost = (key: BuffKey, target: number): number => {
|
||||
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
|
||||
};
|
||||
|
||||
const buffTargetOptions = (key: BuffKey): number[] => {
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
const start = Math.min(5, Math.max(1, current + 1));
|
||||
const result: number[] = [];
|
||||
for (let level = start; level <= 5; level += 1) {
|
||||
result.push(level);
|
||||
}
|
||||
return result.length > 0 ? result : [5];
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const applyResetBalanced = () => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
const base = Math.floor(rules.total / 3);
|
||||
resetStatForm.leadership = rules.total - base * 2;
|
||||
resetStatForm.strength = base;
|
||||
resetStatForm.intel = base;
|
||||
};
|
||||
|
||||
const syncSelections = () => {
|
||||
if (!status.value) {
|
||||
return;
|
||||
}
|
||||
for (const key of buffKeys) {
|
||||
const current = status.value.buffLevels[key] ?? 0;
|
||||
buffTargets[key] = Math.min(5, Math.max(1, current + 1));
|
||||
}
|
||||
if (!nextSpecialKey.value) {
|
||||
nextSpecialKey.value = status.value.availableSpecialWar[0]?.key ?? '';
|
||||
}
|
||||
if (!ownerTargetId.value) {
|
||||
ownerTargetId.value = String(status.value.availableTargetGenerals[0]?.id ?? '');
|
||||
}
|
||||
if (!uniqueForm.amount) {
|
||||
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
actionError.value = null;
|
||||
try {
|
||||
status.value = await trpc.inherit.getStatus.query();
|
||||
syncSelections();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadJoinConfig = async () => {
|
||||
try {
|
||||
joinConfig.value = await trpc.join.getConfig.query();
|
||||
} catch {
|
||||
joinConfig.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async (reset = false) => {
|
||||
if (logLoading.value) {
|
||||
return;
|
||||
}
|
||||
logLoading.value = true;
|
||||
try {
|
||||
const lastId = reset ? undefined : logs.value[logs.value.length - 1]?.id;
|
||||
const result = await trpc.inherit.getLogs.query({ lastId });
|
||||
logs.value = reset ? result : [...logs.value, ...result];
|
||||
logEnd.value = result.length < 30;
|
||||
} catch (err) {
|
||||
actionError.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
logLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async (action: () => Promise<void>, message?: string) => {
|
||||
if (actionBusy.value) {
|
||||
return;
|
||||
}
|
||||
actionBusy.value = true;
|
||||
actionError.value = null;
|
||||
actionMessage.value = null;
|
||||
try {
|
||||
await action();
|
||||
if (message) {
|
||||
actionMessage.value = message;
|
||||
}
|
||||
await loadStatus();
|
||||
await loadLogs(true);
|
||||
} catch (err) {
|
||||
actionError.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buyHiddenBuff = async (key: BuffKey) => {
|
||||
if (!status.value) {
|
||||
return;
|
||||
}
|
||||
const target = buffTargets[key];
|
||||
const cost = buffCost(key, target);
|
||||
if (!window.confirm(`${buffLabels[key]} ${target}단계를 ${cost} 포인트로 구입하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.buyHiddenBuff.mutate({ type: key, level: target });
|
||||
});
|
||||
};
|
||||
|
||||
const reserveSpecialWar = async () => {
|
||||
if (!nextSpecialKey.value) {
|
||||
actionError.value = '다음 전투 특기를 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
const name = specialNameMap.value.get(nextSpecialKey.value) ?? nextSpecialKey.value;
|
||||
const cost = status.value?.inheritConst.inheritSpecificSpecialPoint ?? 0;
|
||||
if (!window.confirm(`${name} 특기를 ${cost} 포인트로 예약하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.setNextSpecialWar.mutate({ specialKey: nextSpecialKey.value });
|
||||
});
|
||||
};
|
||||
|
||||
const resetSpecialWar = async () => {
|
||||
const cost = status.value?.resetCosts.resetSpecialWar ?? 0;
|
||||
if (!window.confirm(`전투 특기를 ${cost} 포인트로 초기화하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.resetSpecialWar.mutate();
|
||||
});
|
||||
};
|
||||
|
||||
const resetTurnTime = async () => {
|
||||
const cost = status.value?.resetCosts.resetTurnTime ?? 0;
|
||||
if (!window.confirm(`턴 시간을 ${cost} 포인트로 초기화하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
const result = await trpc.inherit.resetTurnTime.mutate();
|
||||
turnTimeResult.value = result.nextTurnTime;
|
||||
});
|
||||
};
|
||||
|
||||
const resetStats = async () => {
|
||||
if (resetStatErrors.value.length > 0) {
|
||||
actionError.value = resetStatErrors.value[0] ?? '입력값을 확인해주세요.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('능력치를 초기화하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.resetStat.mutate({
|
||||
leadership: resetStatForm.leadership,
|
||||
strength: resetStatForm.strength,
|
||||
intel: resetStatForm.intel,
|
||||
inheritBonusStat: resetStatForm.bonus,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const buyRandomUnique = async () => {
|
||||
const cost = status.value?.inheritConst.inheritItemRandomPoint ?? 0;
|
||||
if (!window.confirm(`랜덤 유니크를 ${cost} 포인트로 구매하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.buyRandomUnique.mutate();
|
||||
});
|
||||
};
|
||||
|
||||
const openUniqueAuction = async () => {
|
||||
if (!uniqueForm.itemId.trim()) {
|
||||
actionError.value = '유니크 아이템 ID를 입력해주세요.';
|
||||
return;
|
||||
}
|
||||
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
||||
if (amount <= 0) {
|
||||
actionError.value = '입찰 포인트를 입력해주세요.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`유니크 경매를 ${amount} 포인트로 신청하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.openUniqueAuction.mutate({
|
||||
itemId: uniqueForm.itemId.trim(),
|
||||
amount,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const checkOwner = async () => {
|
||||
const targetId = Number(ownerTargetId.value);
|
||||
if (!targetId) {
|
||||
actionError.value = '확인할 장수를 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
const cost = status.value?.inheritConst.inheritCheckOwnerPoint ?? 0;
|
||||
if (!window.confirm(`장수 소유자 확인에 ${cost} 포인트를 사용하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
const result = await trpc.inherit.checkOwner.mutate({ targetGeneralId: targetId });
|
||||
ownerResult.value = { targetName: result.targetName, ownerName: result.ownerName };
|
||||
});
|
||||
};
|
||||
|
||||
watch(statRules, (rules) => {
|
||||
if (rules) {
|
||||
applyResetBalanced();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatus();
|
||||
void loadJoinConfig();
|
||||
void loadLogs(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="inherit-page">
|
||||
<header class="inherit-header">
|
||||
<div>
|
||||
<h1 class="inherit-title">유산 포인트</h1>
|
||||
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="inherit-actions">
|
||||
<button class="ghost" @click="loadStatus">새로고침</button>
|
||||
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="inherit-error">{{ error }}</div>
|
||||
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
|
||||
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
|
||||
|
||||
<div v-if="loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
|
||||
<section v-else class="inherit-grid">
|
||||
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
|
||||
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="summary-panel">
|
||||
<div class="summary-head">
|
||||
<div class="summary-total">총 {{ status.totalPoint }} 포인트</div>
|
||||
<div class="summary-state" :class="{ united: status.isUnited }">
|
||||
{{ status.isUnited ? '통일 완료' : '진행 중' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
|
||||
<span>{{ entry.label }}</span>
|
||||
<span>{{ entry.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-footer">
|
||||
<div>현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}회</div>
|
||||
<div>턴 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
|
||||
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="buff-list">
|
||||
<div v-for="key in buffKeys" :key="key" class="buff-row">
|
||||
<div class="buff-info">
|
||||
<div class="buff-name">{{ buffLabels[key] }}</div>
|
||||
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
|
||||
</div>
|
||||
<div class="buff-action">
|
||||
<select v-model.number="buffTargets[key]" class="form-input">
|
||||
<option
|
||||
v-for="level in buffTargetOptions(key)"
|
||||
:key="level"
|
||||
:value="level"
|
||||
>
|
||||
{{ level }} 단계
|
||||
</option>
|
||||
</select>
|
||||
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
|
||||
<button
|
||||
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
|
||||
@click="buyHiddenBuff(key)"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
|
||||
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>다음 전투 특기</span>
|
||||
<select v-model="nextSpecialKey" class="form-input">
|
||||
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
|
||||
{{ special.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<div>
|
||||
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div class="muted">
|
||||
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
|
||||
({{ status.resetLevels.resetSpecialWar }}회)
|
||||
</div>
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
|
||||
<div v-if="!status" class="muted">턴 시간 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">
|
||||
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }}회)
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetTurnTime">턴 시간 변경</button>
|
||||
</div>
|
||||
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
|
||||
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="stat-panel">
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>통솔</span>
|
||||
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>무력</span>
|
||||
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>지력</span>
|
||||
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>보너스 통솔</span>
|
||||
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 무력</span>
|
||||
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 지력</span>
|
||||
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-summary">
|
||||
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
|
||||
<div>보너스 합 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
|
||||
<div v-if="resetStatErrors.length" class="stat-errors">
|
||||
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
|
||||
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>유니크 아이템 ID</span>
|
||||
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>입찰 포인트</span>
|
||||
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
|
||||
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
|
||||
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<label class="form-field">
|
||||
<span>대상 장수</span>
|
||||
<select v-model="ownerTargetId" class="form-input">
|
||||
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
|
||||
<div v-if="ownerResult" class="muted">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
|
||||
<template #actions>
|
||||
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
|
||||
</template>
|
||||
<div v-if="logLoading && logs.length === 0">
|
||||
<SkeletonLines :lines="3" />
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
|
||||
<div v-else class="log-list">
|
||||
<div v-for="entry in logs" :key="entry.id" class="log-entry">
|
||||
<span class="log-date">{{ entry.year }}년 {{ entry.month }}월</span>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-footer">
|
||||
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()">더 보기</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inherit-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inherit-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.inherit-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inherit-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.inherit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inherit-error {
|
||||
border: 1px solid rgba(240, 90, 90, 0.6);
|
||||
padding: 8px 10px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.inherit-message {
|
||||
border: 1px solid rgba(120, 190, 120, 0.5);
|
||||
padding: 8px 10px;
|
||||
color: rgba(180, 230, 180, 0.9);
|
||||
}
|
||||
|
||||
.inherit-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-state {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
}
|
||||
|
||||
.summary-state.united {
|
||||
border-color: rgba(240, 120, 120, 0.6);
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
}
|
||||
|
||||
.summary-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.buff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.buff-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.buff-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.buff-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.buff-level {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.buff-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.buff-cost {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.stat-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-errors {
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[0];
|
||||
type JoinForm = Omit<JoinInput, 'inheritBonusStat'> & { inheritBonusStat: [number, number, number] };
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
@@ -20,13 +22,14 @@ const submitting = ref(false);
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
const activeTab = ref<'create' | 'possess'>('create');
|
||||
|
||||
const form = ref<JoinInput>({
|
||||
const form = ref<JoinForm>({
|
||||
name: '',
|
||||
leadership: 0,
|
||||
strength: 0,
|
||||
intel: 0,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
inheritBonusStat: [0, 0, 0],
|
||||
});
|
||||
|
||||
const npcCandidates = ref<PossessCandidate[]>([]);
|
||||
@@ -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<string>({
|
||||
get: () => form.value.inheritSpecial ?? '',
|
||||
set: (value) => {
|
||||
form.value.inheritSpecial = value ? value : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const inheritCityChoice = computed<string>({
|
||||
get: () => (form.value.inheritCity !== undefined ? String(form.value.inheritCity) : ''),
|
||||
set: (value) => {
|
||||
form.value.inheritCity = value ? Number(value) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const inheritTurntimeChoice = computed<string>({
|
||||
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(() => {
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유산/빙의 안내" subtitle="유산 포인트와 추가 옵션은 추후 이식 예정입니다.">
|
||||
<div class="muted">특기/도시 선택, 턴 시간 지정은 향후 UI와 함께 제공됩니다.</div>
|
||||
<PanelCard title="유산 포인트 옵션" subtitle="보유 포인트를 사용해 시작 옵션을 지정합니다.">
|
||||
<div v-if="!inheritConfig" class="muted">유산 포인트 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="inherit-panel">
|
||||
<div class="inherit-summary">
|
||||
<div>보유 포인트: {{ inheritTotalPoint }}</div>
|
||||
<div>필요 포인트: {{ inheritRequiredPoint }}</div>
|
||||
</div>
|
||||
|
||||
<div class="inherit-options">
|
||||
<label class="form-field">
|
||||
<span>전투 특기 선택</span>
|
||||
<select v-model="inheritSpecialChoice" class="form-input">
|
||||
<option value="">선택 안함</option>
|
||||
<option
|
||||
v-for="special in inheritConfig.availableSpecialWar"
|
||||
:key="special.key"
|
||||
:value="special.key"
|
||||
>
|
||||
{{ special.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ inheritCosts.inheritBornSpecialPoint }} 포인트</small>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>시작 도시 지정</span>
|
||||
<select v-model="inheritCityChoice" class="form-input">
|
||||
<option value="">랜덤 배치</option>
|
||||
<option
|
||||
v-for="city in inheritConfig.availableCities"
|
||||
:key="city.id"
|
||||
:value="String(city.id)"
|
||||
>
|
||||
{{ city.name }} · {{ cityLevelMap[city.level] ?? city.level }} ·
|
||||
{{ regionMap[city.region] ?? city.region }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ inheritCosts.inheritBornCityPoint }} 포인트</small>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>턴 시간대 지정</span>
|
||||
<select v-model="inheritTurntimeChoice" class="form-input">
|
||||
<option value="">랜덤 배치</option>
|
||||
<option
|
||||
v-for="(zone, index) in inheritConfig.turnTimeZones"
|
||||
:key="zone"
|
||||
:value="String(index)"
|
||||
>
|
||||
{{ zone }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ inheritCosts.inheritBornTurntimePoint }} 포인트</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inherit-bonus">
|
||||
<div class="bonus-title">보너스 능력치</div>
|
||||
<div class="bonus-grid">
|
||||
<label class="form-field">
|
||||
<span>통솔</span>
|
||||
<input
|
||||
v-model.number="form.inheritBonusStat[0]"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
class="form-input"
|
||||
/>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>무력</span>
|
||||
<input
|
||||
v-model.number="form.inheritBonusStat[1]"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
class="form-input"
|
||||
/>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>지력</span>
|
||||
<input
|
||||
v-model.number="form.inheritBonusStat[2]"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
class="form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<small class="muted">
|
||||
보너스 합 {{ inheritBonusSum }} (0 또는 3~5) · 비용
|
||||
{{ inheritCosts.inheritBornStatPoint }} 포인트
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="inheritErrors.length" class="inherit-errors">
|
||||
<div v-for="item in inheritErrors" :key="item">{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -94,6 +94,7 @@ watch(
|
||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
||||
<button
|
||||
class="toggle"
|
||||
:class="{ active: realtimeEnabled }"
|
||||
|
||||
@@ -206,3 +206,50 @@ model LogEntry {
|
||||
@@index([userId, category, id])
|
||||
@@map("log_entry")
|
||||
}
|
||||
|
||||
model InheritancePoint {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
key String
|
||||
value Float @default(0)
|
||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, key])
|
||||
@@index([userId])
|
||||
@@map("inheritance_point")
|
||||
}
|
||||
|
||||
model InheritanceLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
year Int
|
||||
month Int
|
||||
text String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId, id])
|
||||
@@map("inheritance_log")
|
||||
}
|
||||
|
||||
model InheritanceResult {
|
||||
id Int @id @default(autoincrement())
|
||||
serverId String @map("server_id")
|
||||
owner String @map("owner")
|
||||
generalId Int @map("general_id")
|
||||
year Int
|
||||
month Int
|
||||
value Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([serverId, owner])
|
||||
@@map("inheritance_result")
|
||||
}
|
||||
|
||||
model InheritanceUserState {
|
||||
userId String @id @map("user_id")
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("inheritance_user_state")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE "inheritance_point" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"aux" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "inheritance_point_user_id_key" ON "inheritance_point"("user_id", "key");
|
||||
CREATE INDEX "inheritance_point_user_id_idx" ON "inheritance_point"("user_id");
|
||||
|
||||
CREATE TABLE "inheritance_log" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "inheritance_log_user_id_idx" ON "inheritance_log"("user_id", "id");
|
||||
|
||||
CREATE TABLE "inheritance_result" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"owner" TEXT NOT NULL,
|
||||
"general_id" INTEGER NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"value" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "inheritance_result_server_owner_idx" ON "inheritance_result"("server_id", "owner");
|
||||
|
||||
CREATE TABLE "inheritance_user_state" (
|
||||
"user_id" TEXT PRIMARY KEY,
|
||||
"meta" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
|
||||
|
||||
export interface DatabaseClient {
|
||||
$transaction: GamePrismaClient['$transaction'];
|
||||
$transaction?: GamePrismaClient['$transaction'];
|
||||
$queryRaw: GamePrismaClient['$queryRaw'];
|
||||
worldState: GamePrisma.WorldStateDelegate;
|
||||
general: GamePrisma.GeneralDelegate;
|
||||
@@ -10,4 +10,8 @@ export interface DatabaseClient {
|
||||
generalTurn: GamePrisma.GeneralTurnDelegate;
|
||||
nationTurn: GamePrisma.NationTurnDelegate;
|
||||
troop: GamePrisma.TroopDelegate;
|
||||
inheritancePoint: GamePrisma.InheritancePointDelegate;
|
||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './logging/index.js';
|
||||
export * from './messages/index.js';
|
||||
export * from './items/index.js';
|
||||
export { ITEM_KEYS, createItemActionModules, createItemModuleRegistry, loadItemModules } from './items/index.js';
|
||||
export * from './inheritance/inheritBuff.js';
|
||||
export * from './resources/index.js';
|
||||
export * from './ports/world.js';
|
||||
export * from './ports/worldSnapshot.js';
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { TriggerDomesticActionType, TriggerDomesticVarType, WarStatName } from '@sammo-ts/logic/triggers/types.js';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||
import { asRecord, parseJson } from '@sammo-ts/common';
|
||||
|
||||
export type InheritBuffType =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
|
||||
const DOMESTIC_TARGETS = new Set<TriggerDomesticActionType>([
|
||||
'상업',
|
||||
'농업',
|
||||
'치안',
|
||||
'성벽',
|
||||
'수비',
|
||||
'민심',
|
||||
'인구',
|
||||
'기술',
|
||||
]);
|
||||
|
||||
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
|
||||
const raw = buff[key];
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(5, Math.floor(raw)));
|
||||
};
|
||||
|
||||
const parseInheritBuff = (value: unknown): Record<string, unknown> => {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseJson<Record<string, unknown>>(value);
|
||||
return parsed ?? {};
|
||||
}
|
||||
return asRecord(value);
|
||||
};
|
||||
|
||||
const resolveBuffRecord = (context: { general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } } }): Record<string, unknown> => {
|
||||
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
|
||||
if (Object.keys(fromTrigger).length > 0) {
|
||||
return fromTrigger;
|
||||
}
|
||||
return parseInheritBuff(context.general.meta.inheritBuff);
|
||||
};
|
||||
|
||||
const applyDomesticBuff = (
|
||||
buff: Record<string, unknown>,
|
||||
turnType: TriggerDomesticActionType,
|
||||
varType: TriggerDomesticVarType,
|
||||
value: number
|
||||
): number => {
|
||||
if (!DOMESTIC_TARGETS.has(turnType)) {
|
||||
return value;
|
||||
}
|
||||
if (varType === 'success') {
|
||||
const level = readBuffLevel(buff, 'success');
|
||||
return value + level * 0.01;
|
||||
}
|
||||
if (varType === 'fail') {
|
||||
const level = readBuffLevel(buff, 'fail');
|
||||
return value - level * 0.01;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const applyWarBuff = (buff: Record<string, unknown>, statName: WarStatName, value: number | [number, number]) => {
|
||||
if (typeof value !== 'number') {
|
||||
return value;
|
||||
}
|
||||
if (statName === 'warAvoidRatio') {
|
||||
return value + readBuffLevel(buff, 'warAvoidRatio') * 0.01;
|
||||
}
|
||||
if (statName === 'warCriticalRatio') {
|
||||
return value + readBuffLevel(buff, 'warCriticalRatio') * 0.01;
|
||||
}
|
||||
if (statName === 'warMagicTrialProb') {
|
||||
return value + readBuffLevel(buff, 'warMagicTrialProb') * 0.01;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const applyOpposeWarBuff = (
|
||||
buff: Record<string, unknown>,
|
||||
statName: WarStatName,
|
||||
value: number | [number, number]
|
||||
) => {
|
||||
if (typeof value !== 'number') {
|
||||
return value;
|
||||
}
|
||||
if (statName === 'warAvoidRatio') {
|
||||
return value - readBuffLevel(buff, 'warAvoidRatioOppose') * 0.01;
|
||||
}
|
||||
if (statName === 'warCriticalRatio') {
|
||||
return value - readBuffLevel(buff, 'warCriticalRatioOppose') * 0.01;
|
||||
}
|
||||
if (statName === 'warMagicTrialProb') {
|
||||
return value - readBuffLevel(buff, 'warMagicTrialProbOppose') * 0.01;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createInheritBuffModules = (): { general: GeneralActionModule; war: WarActionModule } => {
|
||||
const general: GeneralActionModule = {
|
||||
onCalcDomestic: (context, turnType, varType, value) =>
|
||||
applyDomesticBuff(resolveBuffRecord(context), turnType, varType, value),
|
||||
};
|
||||
|
||||
const war: WarActionModule = {
|
||||
onCalcStat: (context, statName, value) => applyWarBuff(resolveBuffRecord(context), statName, value),
|
||||
onCalcOpposeStat: (context, statName, value) => applyOpposeWarBuff(resolveBuffRecord(context), statName, value),
|
||||
};
|
||||
|
||||
return { general, war };
|
||||
};
|
||||
Reference in New Issue
Block a user