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:
2026-01-18 10:59:04 +00:00
parent cbe960364d
commit 3ce1f441d0
19 changed files with 2947 additions and 60 deletions
+6 -3
View File
@@ -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);
+2
View File
@@ -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,
+833
View File
@@ -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 };
}),
});
+248 -49
View File
@@ -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 };
});
}),
+278
View File
@@ -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 },
});
};