fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다
은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다. 유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
@@ -2,36 +2,27 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, parseJson, LiteHashDRBG, type TurnDaemonInheritanceAction } from '@sammo-ts/common';
|
||||
import {
|
||||
ItemLoader,
|
||||
isItemKey,
|
||||
loadWarTraitModules,
|
||||
sendMessage,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
isWarTraitKey,
|
||||
isCentennialStatResetAllowed,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
|
||||
import type { InheritBuffType, ItemSlot } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
buildResetCost,
|
||||
computeInheritanceItems,
|
||||
readInheritancePoint,
|
||||
readUserStateMeta,
|
||||
resolveInheritConstants,
|
||||
setInheritancePoint,
|
||||
sumInheritanceItems,
|
||||
writeUserStateMeta,
|
||||
} from '../../services/inheritance.js';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||
import { buildTargetFromGeneral } from '../../messages/targets.js';
|
||||
import { insertMessage } from '../../messages/store.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
|
||||
const BUFF_KEYS: InheritBuffType[] = [
|
||||
'warAvoidRatio',
|
||||
@@ -46,17 +37,6 @@ const BUFF_KEYS: InheritBuffType[] = [
|
||||
|
||||
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||
|
||||
const BUFF_LABELS: Record<InheritBuffType, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
domesticSuccessProb: '내정 성공률 증가',
|
||||
domesticFailProb: '내정 실패율 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
};
|
||||
|
||||
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
||||
|
||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
@@ -74,13 +54,6 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
||||
|
||||
const readStringList = (raw: unknown): string[] => {
|
||||
const parsed = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||
};
|
||||
|
||||
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||
@@ -131,31 +104,24 @@ const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<
|
||||
};
|
||||
};
|
||||
|
||||
const patchGeneral = async (
|
||||
ctx: Pick<GameApiContext, 'turnDaemon'>,
|
||||
generalId: number,
|
||||
patch: {
|
||||
meta?: Record<string, unknown>;
|
||||
turnTime?: string;
|
||||
stats?: {
|
||||
leadership?: number;
|
||||
strength?: number;
|
||||
intelligence?: number;
|
||||
};
|
||||
specialWar?: string | null;
|
||||
}
|
||||
): Promise<void> => {
|
||||
const requestInheritanceAction = async (
|
||||
ctx: Pick<GameApiContext, 'turnDaemon' | 'requestId'>,
|
||||
userId: string,
|
||||
input: TurnDaemonInheritanceAction
|
||||
) => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'patchGeneral',
|
||||
generalId,
|
||||
patch,
|
||||
type: 'inheritanceAction',
|
||||
userId,
|
||||
input,
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:inherit.${input.action}:engine:0:inheritanceAction` } : {}),
|
||||
});
|
||||
if (!result || result.type !== 'patchGeneral') {
|
||||
if (!result || result.type !== 'inheritanceAction') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
throw new TRPCError({ code: result.code, message: result.reason });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
||||
@@ -189,54 +155,6 @@ export const resolveResetTurnTimeBase = (options: {
|
||||
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -380,7 +298,7 @@ export const inheritRouter = router({
|
||||
});
|
||||
return logs;
|
||||
}),
|
||||
buyHiddenBuff: authedProcedure
|
||||
buyHiddenBuff: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
type: z.enum(BUFF_KEYS),
|
||||
@@ -393,56 +311,14 @@ export const inheritRouter = router({
|
||||
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 },
|
||||
const result = await requestInheritanceAction(ctx, userId, {
|
||||
action: 'buyHiddenBuff',
|
||||
buffType: input.type,
|
||||
level: input.level,
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
}
|
||||
|
||||
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||
const prevLevel = readBuffLevel(buff, input.type);
|
||||
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 patchGeneral(ctx, general.id, {
|
||||
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 };
|
||||
return { ok: true, remainPoint: result.remainPoint };
|
||||
}),
|
||||
setNextSpecialWar: authedProcedure
|
||||
setNextSpecialWar: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
specialKey: z.string(),
|
||||
@@ -454,197 +330,32 @@ export const inheritRouter = router({
|
||||
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 patchGeneral(ctx, general.id, {
|
||||
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} 지정`
|
||||
);
|
||||
await requestInheritanceAction(ctx, userId, { action: 'setNextSpecialWar', specialKey: input.specialKey });
|
||||
return { ok: true };
|
||||
}),
|
||||
resetSpecialWar: authedProcedure.mutation(async ({ ctx }) => {
|
||||
resetSpecialWar: engineAuthedProcedure.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 (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 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 = readStringList(meta.prev_types_special2);
|
||||
prevList.push(general.special2Code);
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
specialWar: null,
|
||||
meta: {
|
||||
...meta,
|
||||
inheritResetSpecialWar: nextLevel,
|
||||
prev_types_special2: prevList,
|
||||
},
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${cost} 포인트로 전투 특기 초기화`
|
||||
);
|
||||
await requestInheritanceAction(ctx, userId, { action: 'resetSpecialWar' });
|
||||
return { ok: true };
|
||||
}),
|
||||
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
|
||||
resetTurnTime: engineAuthedProcedure.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, turnTick: 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 generalMeta = asRecord(general.meta);
|
||||
const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0;
|
||||
const seedTurnTime =
|
||||
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
|
||||
? rawSeedTurnTime
|
||||
: typeof rawSeedTurnTime === 'bigint'
|
||||
? Number(rawSeedTurnTime)
|
||||
: 0;
|
||||
const hiddenSeed =
|
||||
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||
? worldMeta.hiddenSeed
|
||||
: 'inherit';
|
||||
const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({
|
||||
hiddenSeed,
|
||||
userId,
|
||||
previousTurnTimeBase: seedTurnTime,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
meta: {
|
||||
...generalMeta,
|
||||
inheritResetTurnTime: nextLevel,
|
||||
nextTurnTimeBase,
|
||||
},
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용`
|
||||
);
|
||||
return { ok: true, nextTurnTimeBase, nextTurnTimeLabel };
|
||||
const result = await requestInheritanceAction(ctx, userId, { action: 'resetTurnTime' });
|
||||
return {
|
||||
ok: true,
|
||||
nextTurnTimeBase: result.nextTurnTimeBase!,
|
||||
nextTurnTimeLabel: result.nextTurnTimeLabel!,
|
||||
};
|
||||
}),
|
||||
resetStat: authedProcedure
|
||||
resetStat: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
leadership: z.number().int(),
|
||||
@@ -658,191 +369,21 @@ export const inheritRouter = router({
|
||||
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 general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, npcState: true },
|
||||
const result = await requestInheritanceAction(ctx, userId, {
|
||||
action: 'resetStat',
|
||||
leadership: input.leadership,
|
||||
strength: input.strength,
|
||||
intel: input.intel,
|
||||
...(input.inheritBonusStat ? { inheritBonusStat: input.inheritBonusStat } : {}),
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
}
|
||||
if (general.npcState >= 2) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' });
|
||||
}
|
||||
if (!isCentennialStatResetAllowed(config)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
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 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 patchGeneral(ctx, general.id, {
|
||||
stats: {
|
||||
leadership: nextStats.leadership,
|
||||
strength: nextStats.strength,
|
||||
intelligence: 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 };
|
||||
return { ok: true, stats: result.stats! };
|
||||
}),
|
||||
buyRandomUnique: authedProcedure.mutation(async ({ ctx }) => {
|
||||
buyRandomUnique: engineAuthedProcedure.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 patchGeneral(ctx, general.id, {
|
||||
meta: {
|
||||
...meta,
|
||||
inheritRandomUnique: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritItemRandomPoint);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${inheritConst.inheritItemRandomPoint} 포인트로 랜덤 유니크 구입`
|
||||
);
|
||||
await requestInheritanceAction(ctx, userId, { action: 'buyRandomUnique' });
|
||||
return { ok: true };
|
||||
}),
|
||||
openUniqueAuction: engineAuthedProcedure
|
||||
@@ -885,7 +426,7 @@ export const inheritRouter = router({
|
||||
);
|
||||
return { ok: true, ...result };
|
||||
}),
|
||||
checkOwner: authedProcedure
|
||||
checkOwner: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
targetGeneralId: z.number().int().positive(),
|
||||
@@ -896,79 +437,10 @@ export const inheritRouter = router({
|
||||
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 } }),
|
||||
ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }),
|
||||
]);
|
||||
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 rawOwnerName = asRecord(target.meta).ownerName;
|
||||
const ownerName =
|
||||
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인`
|
||||
);
|
||||
|
||||
const [generalTarget, checkedTarget, gameTime] = await Promise.all([
|
||||
buildTargetFromGeneral(ctx.db, general),
|
||||
buildTargetFromGeneral(ctx.db, target),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
const systemTarget: MessageDraft['src'] = {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
const validUntil = new Date('9999-12-31T00:00:00.000Z');
|
||||
const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise<void> => {
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
|
||||
},
|
||||
{
|
||||
msgType: 'private',
|
||||
src: systemTarget,
|
||||
dest,
|
||||
text,
|
||||
time: gameTime.now,
|
||||
validUntil,
|
||||
option: {},
|
||||
},
|
||||
{ sendDestOnly: true }
|
||||
);
|
||||
ctx.changeJournal?.mark('messages.mailbox', dest.generalId);
|
||||
};
|
||||
|
||||
await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`);
|
||||
await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.');
|
||||
return { ok: true, ownerName, targetName: target.name };
|
||||
const result = await requestInheritanceAction(ctx, userId, {
|
||||
action: 'checkOwner',
|
||||
targetGeneralId: input.targetGeneralId,
|
||||
});
|
||||
return { ok: true, ownerName: result.ownerName!, targetName: result.targetName! };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/in
|
||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import {
|
||||
readCentennialRecordableDexterity,
|
||||
type CentennialDexKey,
|
||||
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||
|
||||
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
|
||||
import {
|
||||
@@ -182,11 +186,17 @@ export const rankingRouter = router({
|
||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||
},
|
||||
],
|
||||
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
||||
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
||||
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
||||
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
||||
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
||||
...(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map(
|
||||
(key, index) =>
|
||||
[
|
||||
['보 병 숙 련 도', '궁 병 숙 련 도', '기 병 숙 련 도', '귀 병 숙 련 도', '차 병 숙 련 도'][
|
||||
index
|
||||
]!,
|
||||
'int',
|
||||
(general: (typeof generals)[number]) =>
|
||||
readCentennialRecordableDexterity(asRecord(general.meta), key as CentennialDexKey),
|
||||
] as [string, 'int', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
|
||||
),
|
||||
[
|
||||
'전 력 전 승 률',
|
||||
'percent',
|
||||
@@ -415,7 +425,7 @@ export const rankingRouter = router({
|
||||
return Array.from(optionMap.values());
|
||||
}
|
||||
const rows = await ctx.db.gameHistory.findMany({
|
||||
where: { status: 'COMPLETED' },
|
||||
where: { status: { in: ['OPEN', 'COMPLETED'] } },
|
||||
select: { season: true, scenario: true, scenarioName: true },
|
||||
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
|
||||
});
|
||||
|
||||
@@ -1,68 +1,258 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SystemClock } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||
import {
|
||||
createDatabaseTurnHooks,
|
||||
DatabaseTurnDaemonCommandQueue,
|
||||
EngineStateManager,
|
||||
InMemoryTurnStateStore,
|
||||
InMemoryTurnWorld,
|
||||
loadTurnWorldFromDatabase,
|
||||
TurnDaemonLifecycle,
|
||||
type TurnGeneral,
|
||||
type TurnWorldSnapshot,
|
||||
type TurnWorldState,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
type GamePrismaClient,
|
||||
type RedisConnector,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const actorGeneralId = 8_701;
|
||||
const checkedGeneralId = 8_702;
|
||||
const actorNationId = 871;
|
||||
const checkedNationId = 872;
|
||||
const worldId = 992_320;
|
||||
const actorId = 7_320;
|
||||
const targetId = 7_321;
|
||||
const actorNationId = 7_322;
|
||||
const targetNationId = 7_323;
|
||||
const actorUserId = 'inherit-owner-message-actor';
|
||||
const checkedUserId = 'inherit-owner-message-checked';
|
||||
const targetUserId = 'inherit-owner-message-target';
|
||||
const requestId = 'integration:inherit-owner-message:success';
|
||||
const engineRequestId = `${requestId}:inherit.checkOwner:engine:0:inheritanceAction`;
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const scenarioConfig: ScenarioConfig = {
|
||||
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '.',
|
||||
map: {},
|
||||
const: { inheritCheckOwnerPoint: 1_000 },
|
||||
environment: { mapName: 'che', unitSet: 'che' },
|
||||
};
|
||||
const scenarioMeta: ScenarioMeta = {
|
||||
title: '소유자 확인 통합',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
};
|
||||
const map: MapDefinition = { id: 'inherit-owner-message', name: scenarioMeta.title, cities: [] };
|
||||
const state: TurnWorldState = {
|
||||
id: worldId,
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
||||
};
|
||||
|
||||
const general = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
||||
id: actorId,
|
||||
userId: actorUserId,
|
||||
name: '확인장수',
|
||||
nationId: actorNationId,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 45, intelligence: 85 },
|
||||
turnTime: new Date('2026-08-19T00:10:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24, inherit_spent_dyn: 0 },
|
||||
inheritancePoints: { previous: 1_500 },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
...overrides,
|
||||
});
|
||||
const actor = general({});
|
||||
const target = general({
|
||||
id: targetId,
|
||||
userId: targetUserId,
|
||||
name: '피확인장수',
|
||||
nationId: targetNationId,
|
||||
meta: { killturn: 24, owner_name: '피확인 계정' },
|
||||
inheritancePoints: { previous: 0 },
|
||||
});
|
||||
const nation = (id: number, name: string, chiefGeneralId: number) => ({
|
||||
id,
|
||||
name,
|
||||
color: id === actorNationId ? '#123456' : '#654321',
|
||||
capitalCityId: null,
|
||||
chiefGeneralId,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_def',
|
||||
meta: {},
|
||||
});
|
||||
const actorNation = nation(actorNationId, '확인국', actorId);
|
||||
const targetNation = nation(targetNationId, '피확인국', targetId);
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:inherit-owner-message',
|
||||
issuedAt: '2026-08-19T00:00:00.000Z',
|
||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||
sessionId: 'inherit-owner-message-session',
|
||||
user: {
|
||||
id: actorUserId,
|
||||
username: actorUserId,
|
||||
displayName: '확인자 계정',
|
||||
roles: ['user'],
|
||||
},
|
||||
user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] },
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const hasMailboxChange = (payload: unknown): boolean => {
|
||||
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
||||
const changes = (payload as { changes?: unknown }).changes;
|
||||
if (!Array.isArray(changes)) return false;
|
||||
const mailboxes = new Set([actorGeneralId, checkedGeneralId]);
|
||||
return changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
typeof change[1] === 'number' &&
|
||||
mailboxes.has(change[1])
|
||||
);
|
||||
};
|
||||
const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
|
||||
id: entry.id,
|
||||
userId: entry.userId,
|
||||
name: entry.name,
|
||||
nationId: entry.nationId,
|
||||
cityId: entry.cityId,
|
||||
npcState: entry.npcState,
|
||||
leadership: entry.stats.leadership,
|
||||
strength: entry.stats.strength,
|
||||
intel: entry.stats.intelligence,
|
||||
turnTime: entry.turnTime,
|
||||
meta: entry.meta as GamePrisma.InputJsonValue,
|
||||
penalty: entry.penalty as GamePrisma.InputJsonValue,
|
||||
});
|
||||
|
||||
integration('inherit owner lookup private messages', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (requestId: string): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
beforeAll(async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`);
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
id: worldId,
|
||||
scenarioCode: 'inherit-owner-message',
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await db.nation.createMany({
|
||||
data: [
|
||||
{ id: actorNationId, name: actorNation.name, color: actorNation.color, level: 1 },
|
||||
{ id: targetNationId, name: targetNation.name, color: targetNation.color, level: 1 },
|
||||
],
|
||||
});
|
||||
await db.general.createMany({ data: [actor, target].map(toCreate) });
|
||||
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 1_500 } });
|
||||
await db.rankData.create({
|
||||
data: { generalId: actorId, nationId: actorNationId, type: 'inherit_spent_dyn', value: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('commits once and returns the durable result without double charging on the same API retry', async () => {
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [actor, target],
|
||||
cities: [],
|
||||
nations: [actorNation, targetNation],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig,
|
||||
scenarioMeta,
|
||||
map,
|
||||
};
|
||||
return {
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await queue.initialize();
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('world', {
|
||||
capture: () => world.captureState(),
|
||||
restore: (value) => world.restoreState(value),
|
||||
});
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new SystemClock(),
|
||||
controlQueue: queue,
|
||||
commandResponder: queue,
|
||||
getNextTickTime: () => new Date(Date.now() + 3_600_000),
|
||||
stateStore: new InMemoryTurnStateStore(world),
|
||||
processor: {
|
||||
run: async () => {
|
||||
throw new Error('scheduled turn must not run in inheritance API integration');
|
||||
},
|
||||
},
|
||||
commandHandler: createTurnDaemonCommandHandler({ world }),
|
||||
hooks: hooks.hooks,
|
||||
stateManager,
|
||||
},
|
||||
{ profile: 'inherit-owner-message', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } }
|
||||
);
|
||||
const redisClient = { get: async () => null, set: async () => null };
|
||||
const context: GameApiContext = {
|
||||
requestId,
|
||||
db,
|
||||
redis: redisClient as unknown as RedisConnector['client'],
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
|
||||
uploadDir: 'uploads',
|
||||
@@ -72,157 +262,46 @@ integration('inherit owner lookup private messages', () => {
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
readModelOutbox: { wake: vi.fn() },
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
});
|
||||
|
||||
await db.nation.createMany({
|
||||
data: [
|
||||
{ id: actorNationId, name: '확인국', color: '#123456', level: 2 },
|
||||
{ id: checkedNationId, name: '피확인국', color: '#654321', level: 3 },
|
||||
],
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: actorGeneralId,
|
||||
userId: actorUserId,
|
||||
name: '확인장수',
|
||||
nationId: actorNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { ownerName: '확인자 계정' },
|
||||
},
|
||||
{
|
||||
id: checkedGeneralId,
|
||||
userId: checkedUserId,
|
||||
name: '피확인장수',
|
||||
nationId: checkedNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { ownerName: '피확인 계정' },
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId: actorUserId, key: 'previous', value: 1_500 },
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'inherit-owner-message',
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
config: { const: { inheritCheckOwnerPoint: 1_000 } },
|
||||
meta: { isUnited: 0 },
|
||||
},
|
||||
});
|
||||
worldStateId = world.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||
const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id);
|
||||
if (outboxIds.length > 0) {
|
||||
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
|
||||
let loop: Promise<void> | undefined;
|
||||
try {
|
||||
loop = lifecycle.start();
|
||||
const caller = appRouter.createCaller(context);
|
||||
const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' };
|
||||
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
|
||||
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
|
||||
} finally {
|
||||
await lifecycle.stop('inherit owner integration finished');
|
||||
await loop;
|
||||
await hooks.close();
|
||||
}
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
});
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('commits the point charge, log, and both Ref-compatible private messages', async () => {
|
||||
const requestId = 'integration:inherit-owner-message:success';
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId })
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
ownerName: '피확인 계정',
|
||||
targetName: '피확인장수',
|
||||
});
|
||||
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||
})
|
||||
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } })
|
||||
).resolves.toMatchObject({ value: 500 });
|
||||
await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
}),
|
||||
]);
|
||||
|
||||
const messages = await db.message.findMany({
|
||||
where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
orderBy: { mailbox: 'asc' },
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(
|
||||
messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message }))
|
||||
).toEqual([
|
||||
{
|
||||
mailbox: actorGeneralId,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: actorGeneralId,
|
||||
message: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }),
|
||||
text: '피확인장수의 소유자는 피확인 계정 입니다.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
mailbox: checkedGeneralId,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: checkedGeneralId,
|
||||
message: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }),
|
||||
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } })
|
||||
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId });
|
||||
await expect(
|
||||
db.readModelRevision.findMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
orderBy: { entityId: 'asc' },
|
||||
db.rankData.findUniqueOrThrow({
|
||||
where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } },
|
||||
})
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }),
|
||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }),
|
||||
).resolves.toMatchObject({ value: 1_000 });
|
||||
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1);
|
||||
const messages = await db.message.findMany({
|
||||
where: { mailbox: { in: [actorId, targetId] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { mailbox: true, message: true },
|
||||
});
|
||||
expect(messages.map(({ mailbox, message }) => [mailbox, (message as { text: string }).text])).toEqual([
|
||||
[actorId, '피확인장수의 소유자는 피확인 계정 입니다.'],
|
||||
[targetId, '소유자명이 누군가에 의해 확인되었습니다.'],
|
||||
]);
|
||||
});
|
||||
await expect(db.inputEvent.findMany({ where: { requestId: engineRequestId } })).resolves.toEqual([
|
||||
expect.objectContaining({ status: 'SUCCEEDED', attempts: 1, actorUserId }),
|
||||
]);
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(reloaded.snapshot.generals.find((entry) => entry.id === actorId)).toMatchObject({
|
||||
meta: { inherit_spent_dyn: 1_000 },
|
||||
inheritancePoints: { previous: 500 },
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChangeJournal } from '@sammo-ts/common';
|
||||
import { ChangeJournal, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
import type { MessagePayload } from '@sammo-ts/logic';
|
||||
@@ -111,6 +111,7 @@ const buildContext = (options: {
|
||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||
configConst?: Record<string, unknown>;
|
||||
configMap?: Record<string, unknown>;
|
||||
daemonResult?: TurnDaemonCommandResult;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -118,11 +119,19 @@ const buildContext = (options: {
|
||||
options.target === undefined
|
||||
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
||||
: options.target;
|
||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
|
||||
type: command.type,
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
}));
|
||||
const requestCommand = vi.fn(async (command: TurnDaemonCommand): Promise<TurnDaemonCommandResult> => {
|
||||
if (options.daemonResult) return options.daemonResult;
|
||||
if (command.type === 'inheritanceAction') {
|
||||
return {
|
||||
type: 'inheritanceAction',
|
||||
ok: true,
|
||||
action: command.input.action,
|
||||
generalId: general?.id ?? 7,
|
||||
remainPoint: options.inheritancePoint ?? 10_000,
|
||||
};
|
||||
}
|
||||
return { type: 'patchGeneral', ok: true, generalId: 7 };
|
||||
});
|
||||
const pointUpsert = vi.fn(async () => ({}));
|
||||
const logCreate = vi.fn(async () => ({}));
|
||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||
@@ -272,10 +281,17 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
|
||||
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
|
||||
const fixture = buildContext({
|
||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||
inheritancePoint: 0,
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: false,
|
||||
action: 'resetStat',
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||
},
|
||||
});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
@@ -291,7 +307,17 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
code: 'BAD_REQUEST',
|
||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-1',
|
||||
input: {
|
||||
action: 'resetStat',
|
||||
leadership: 70,
|
||||
strength: 45,
|
||||
intel: 85,
|
||||
inheritBonusStat: [2, 1, 1],
|
||||
},
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -429,10 +455,17 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
||||
it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => {
|
||||
const fixture = buildContext({
|
||||
auth: buildAuth('user-2'),
|
||||
general: buildGeneral({ userId: 'user-1' }),
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: false,
|
||||
action: 'buyHiddenBuff',
|
||||
code: 'PRECONDITION_FAILED',
|
||||
reason: '장수가 존재하지 않습니다.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -444,12 +477,25 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '장수가 존재하지 않습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-2',
|
||||
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mutates only the authenticated user general and inheritance balance', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1000 });
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 1000,
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: true,
|
||||
action: 'buyHiddenBuff',
|
||||
generalId: 7,
|
||||
remainPoint: 800,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
@@ -458,29 +504,26 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
})
|
||||
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||
update: { value: 800 },
|
||||
})
|
||||
);
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-1',
|
||||
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 5_000,
|
||||
configConst: { availableSpecialWar: ['che_의술'] },
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: true,
|
||||
action: 'setNextSpecialWar',
|
||||
generalId: 7,
|
||||
remainPoint: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -488,19 +531,12 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '4000 포인트로 다음 전투 특기로 의술 지정',
|
||||
},
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-1',
|
||||
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' },
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when a different war trait is already reserved', async () => {
|
||||
@@ -508,12 +544,19 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
inheritancePoint: 5_000,
|
||||
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
|
||||
configConst: { availableSpecialWar: ['che_의술'] },
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: false,
|
||||
action: 'setNextSpecialWar',
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '이미 예약한 특기가 있습니다.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand).toHaveBeenCalledOnce();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -522,41 +565,44 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 2_000,
|
||||
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: true,
|
||||
action: 'resetSpecialWar',
|
||||
generalId: 7,
|
||||
remainPoint: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: {
|
||||
specialWar: null,
|
||||
meta: {
|
||||
prev_types_special2: ['che_돌격', 'che_선봉'],
|
||||
marker: 3,
|
||||
inheritResetSpecialWar: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 전투 특기 초기화',
|
||||
},
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-1',
|
||||
input: { action: 'resetSpecialWar' },
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when the current war trait is already blank', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 2_000,
|
||||
general: buildGeneral({ special2Code: 'None' }),
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: false,
|
||||
action: 'resetSpecialWar',
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '이미 전투 특기가 공란입니다.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '이미 전투 특기가 공란입니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand).toHaveBeenCalledOnce();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -572,34 +618,41 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
});
|
||||
fixture.requestCommand.mockResolvedValueOnce({
|
||||
type: 'inheritanceAction',
|
||||
ok: true,
|
||||
action: 'resetTurnTime',
|
||||
generalId: 7,
|
||||
remainPoint: 1_000,
|
||||
...expected,
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
|
||||
ok: true,
|
||||
...expected,
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: {
|
||||
meta: {
|
||||
nextTurnTimeBase: expected.nextTurnTimeBase,
|
||||
inheritResetTurnTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
|
||||
},
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-1',
|
||||
input: { action: 'resetTurnTime' },
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 1500,
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: true,
|
||||
action: 'checkOwner',
|
||||
generalId: 7,
|
||||
remainPoint: 500,
|
||||
ownerName: '위유저',
|
||||
targetName: '조조',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||
@@ -608,64 +661,27 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
ownerName: '위유저',
|
||||
targetName: '조조',
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||
update: { value: 500 },
|
||||
})
|
||||
);
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
},
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'inheritanceAction',
|
||||
userId: 'user-1',
|
||||
input: { action: 'checkOwner', targetGeneralId: 8 },
|
||||
});
|
||||
expect(fixture.messageRows).toHaveLength(2);
|
||||
expect(fixture.messageRows).toEqual([
|
||||
expect.objectContaining({
|
||||
mailbox: 7,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: 7,
|
||||
payload: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: 7, generalName: '유비' }),
|
||||
text: '조조의 소유자는 위유저 입니다.',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mailbox: 8,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: 8,
|
||||
payload: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: 8, generalName: '조조' }),
|
||||
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
|
||||
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
|
||||
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
{ domain: 'messages.mailbox', entityId: 8 },
|
||||
]);
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
expect(fixture.messageRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not charge or send messages when the owner lookup target is the actor', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 1_500,
|
||||
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: false,
|
||||
action: 'checkOwner',
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '자신의 정보는 확인할 수 없습니다.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -681,13 +697,22 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
|
||||
it('does not charge or send messages when inheritance points are insufficient', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 999 });
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 999,
|
||||
daemonResult: {
|
||||
type: 'inheritanceAction',
|
||||
ok: false,
|
||||
action: 'checkOwner',
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 포인트가 부족합니다.',
|
||||
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
@@ -108,6 +108,7 @@ const buildContext = (options?: {
|
||||
profileId?: string;
|
||||
generals?: RankingGeneralRow[];
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
|
||||
}): GameApiContext => {
|
||||
const selectedGeneralRows = options?.generals ?? generalRows;
|
||||
const selectedProfile = options?.profileId
|
||||
@@ -198,10 +199,12 @@ const buildContext = (options?: {
|
||||
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
|
||||
},
|
||||
gameHistory: {
|
||||
findMany: async () => [
|
||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||
],
|
||||
findMany:
|
||||
options?.gameHistoryFindMany ??
|
||||
(async () => [
|
||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||
]),
|
||||
},
|
||||
hallOfFame: {
|
||||
findMany: async (args: { where: { type: string } }) =>
|
||||
@@ -303,6 +306,28 @@ describe('ranking.getBestGeneral', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes 100th-season event mastery from current Best General values', async () => {
|
||||
const generals = [
|
||||
{
|
||||
...generalRows[0]!,
|
||||
meta: {
|
||||
...generalRows[0]!.meta,
|
||||
dex1: 120,
|
||||
event100_allstar: { granted: { dex1: 70 } },
|
||||
} as unknown as RankingGeneralRow['meta'],
|
||||
},
|
||||
];
|
||||
const result = await appRouter.createCaller(buildContext({ generals })).ranking.getBestGeneral({
|
||||
view: 'user',
|
||||
});
|
||||
|
||||
expect(result.sections.find((section) => section.title === '보 병 숙 련 도')?.entries[0]).toMatchObject({
|
||||
id: 1,
|
||||
value: 50,
|
||||
printValue: '50',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns positions one through ten for every populated ranking section', async () => {
|
||||
const generals = Array.from({ length: 12 }, (_, index) => {
|
||||
const id = index + 1;
|
||||
@@ -386,6 +411,21 @@ describe('ranking hall of fame', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the active OPEN game while excluding ABANDONED options', async () => {
|
||||
const findMany = vi.fn(async () => [
|
||||
{ season: 4, scenario: 23, scenarioName: '현재 시나리오' },
|
||||
{ season: 3, scenario: 22, scenarioName: '완료 시나리오' },
|
||||
]);
|
||||
const options = await appRouter
|
||||
.createCaller(buildContext({ authenticated: false, gameHistoryFindMany: findMany }))
|
||||
.ranking.getHallOfFameOptions();
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { status: { in: ['OPEN', 'COMPLETED'] } } })
|
||||
);
|
||||
expect(options.map((entry) => entry.season)).toEqual([4, 3]);
|
||||
});
|
||||
|
||||
it('scopes previous-server options and rankings to the request profile', async () => {
|
||||
const cheCaller = appRouter.createCaller(buildContext({ authenticated: false }));
|
||||
await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
|
||||
|
||||
Reference in New Issue
Block a user