fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다

은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
2026-08-24 03:38:12 +00:00
parent 95cf68dffd
commit 4fc20de8d4
33 changed files with 4160 additions and 1166 deletions
+47 -575
View File
@@ -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! };
}),
});
+16 -6
View File
@@ -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);
});
+158 -133
View File
@@ -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();
+45 -5
View File
@@ -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([
@@ -265,6 +265,42 @@ const zPatchGeneral = z.object({
}),
});
const zInheritanceAction = z
.object({
type: z.literal('inheritanceAction'),
requestId: z.string().min(1).optional(),
userId: z.string().min(1),
input: z.discriminatedUnion('action', [
z.object({
action: z.literal('buyHiddenBuff'),
buffType: z.enum([
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
]),
level: z.number().int().min(1).max(5),
}),
z.object({ action: z.literal('setNextSpecialWar'), specialKey: z.string().min(1) }),
z.object({ action: z.literal('resetSpecialWar') }),
z.object({ action: z.literal('resetTurnTime') }),
z.object({
action: z.literal('resetStat'),
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(),
}),
z.object({ action: z.literal('buyRandomUnique') }),
z.object({ action: z.literal('checkOwner'), targetGeneralId: z.number().int().positive() }),
]),
})
.strict();
const zAdjustGeneralIcon = z
.object({
type: z.literal('adjustGeneralIcon'),
@@ -643,6 +679,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeInheritanceAction: CommandNormalizer<'inheritanceAction'> = (envelope) => {
const command = parseWith(zInheritanceAction, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
const command = parseWith(zAdjustGeneralIcon, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
@@ -764,6 +808,7 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral,
inheritanceAction: normalizeInheritanceAction,
adjustGeneralIcon: normalizeAdjustGeneralIcon,
joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral,
+61 -20
View File
@@ -44,7 +44,7 @@ import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
import { persistGeneralLifecycleEvents, type GeneralLifecycleArchiveLog } from './generalTurnLifecyclePersistence.js';
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
@@ -1094,6 +1094,7 @@ export const createDatabaseTurnHooks = async (
lifecycleEvents,
pendingNeutralAuctions,
inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
@@ -1137,6 +1138,20 @@ export const createDatabaseTurnHooks = async (
select: { id: true },
})
)?.id ?? 0;
const logContext = {
year: state.currentYear,
month: state.currentMonth,
at: state.lastTurnTime,
};
const pendingLogRows = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
const pendingLifecycleArchiveLogs: GeneralLifecycleArchiveLog[] = pendingLogRows.flatMap((entry) =>
entry.generalId !== null &&
(entry.category === LogCategory.HISTORY || entry.category === LogCategory.BATTLE_BRIEF)
? [{ generalId: entry.generalId, category: entry.category, text: entry.text }]
: []
);
// Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
@@ -1257,15 +1272,24 @@ export const createDatabaseTurnHooks = async (
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
if (inheritancePointAdjustments.length > 0) {
const persistInheritancePointAdjustments = async (
entries: typeof inheritancePointAdjustments
): Promise<void> => {
if (entries.length === 0) {
return;
}
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
for (const entry of inheritancePointAdjustments) {
for (const entry of entries) {
const groupKey = `${entry.userId}\u0000${entry.key}`;
const current = grouped.get(groupKey);
if (current) {
current.amount += entry.amount;
} else {
grouped.set(groupKey, { ...entry });
grouped.set(groupKey, {
userId: entry.userId,
key: entry.key,
amount: entry.amount,
});
}
}
for (const entry of grouped.values()) {
@@ -1275,14 +1299,41 @@ export const createDatabaseTurnHooks = async (
create: { userId: entry.userId, key: entry.key, value: entry.amount },
});
}
}
};
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
if (entries.length === 0) {
return;
}
await prisma.inheritanceLog.createMany({
data: entries.map((entry) => ({
userId: entry.userId,
year: entry.year,
month: entry.month,
text: entry.text,
})),
});
};
const beforeLifecycleAdjustments = inheritancePointAdjustments.filter(
(entry) => entry.phase !== 'after_lifecycle'
);
const afterLifecycleAdjustments = inheritancePointAdjustments.filter(
(entry) => entry.phase === 'after_lifecycle'
);
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents(
prisma,
lifecycleEvents,
meta,
asRecord(world.getScenarioConfig().const),
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
pendingLifecycleArchiveLogs
);
await persistInheritancePointAdjustments(afterLifecycleAdjustments);
await persistInheritanceLogs(afterLifecycleLogs);
if (accessScoreResetGeneralIds.length > 0) {
await prisma.generalAccessLog.updateMany({
@@ -1611,20 +1662,10 @@ export const createDatabaseTurnHooks = async (
await upsertRankRows(prisma, rankRows);
}
if (logs.length > 0) {
const logContext = {
year: state.currentYear,
month: state.currentMonth,
at: state.lastTurnTime,
};
const payload = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
if (payload.length > 0) {
await prisma.logEntry.createMany({
data: payload,
});
}
if (pendingLogRows.length > 0) {
await prisma.logEntry.createMany({
data: pendingLogRows,
});
}
for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot);
@@ -1,9 +1,23 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import {
asRecord,
HALL_OF_FAME_TYPES,
RANK_DATA_TYPES,
rankDataMetaKey,
resolveLegacyTextColor,
type HallOfFameType,
} from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildPersistedRankRows } from './rankData.js';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -24,12 +38,59 @@ const readWorldNumber = (record: Record<string, unknown>, key: string, fallback:
return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
};
type LifecycleRankValues = Map<string, number>;
export interface GeneralLifecycleArchiveLog {
generalId: number;
category: string;
text: string;
}
const loadLifecycleRankValues = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent
): Promise<LifecycleRankValues> => {
const persisted = await prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
});
const values = new Map(persisted.map((row) => [row.type, row.value]));
const snapshotMeta = asRecord(event.before.meta);
for (const row of buildPersistedRankRows(event.before)) {
if (
row.type === 'experience' ||
row.type === 'dedication' ||
Object.prototype.hasOwnProperty.call(snapshotMeta, rankDataMetaKey(row.type))
) {
values.set(row.type, row.value);
}
}
return values;
};
const persistPostRetirementRankValues = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent
): Promise<void> => {
if (!event.after) {
return;
}
for (const row of buildPersistedRankRows(event.after)) {
await prisma.rankData.upsert({
where: { generalId_type: { generalId: row.generalId, type: row.type } },
update: { nationId: row.nationId, value: row.value },
create: row,
});
}
};
const settleInheritance = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
isRebirth: boolean,
configConst: Record<string, unknown>
configConst: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>
): Promise<void> => {
const userId = event.before.userId;
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
@@ -53,29 +114,23 @@ const settleInheritance = async (
}
}
const [rows, rankRows] = await Promise.all([
prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const rows = await prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
});
const points = new Map(rows.map((row) => [row.key, row.value]));
const previous = points.get('previous') ?? 0;
const randomUniqueRefund = meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
: 0;
const specificSpecialRefund = meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const randomUniqueRefund =
!isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0;
const specificSpecialRefund =
!isRebirth && meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const refund = randomUniqueRefund + specificSpecialRefund;
const calculationMeta = {
...Object.fromEntries(rankValues),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
...meta,
...Object.fromEntries(rankRows.map((row) => [row.type, row.value])),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
};
const settlement = computeInheritanceSettlementBreakdown(
{
@@ -143,14 +198,22 @@ const settleInheritance = async (
},
});
}
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`,
},
});
for (const text of buildInheritanceSettlementLogTexts({
previous: previous + refund,
points: settlement.earned,
storedKeys: new Set([...points.keys(), ...(refund > 0 ? (['previous'] as const) : [])]),
total,
isRebirth,
})) {
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text,
},
});
}
};
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
@@ -159,26 +222,23 @@ const settleHall = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
gameNow: Date
gameNow: Date,
rank: ReadonlyMap<string, number>
): Promise<void> => {
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
const isUnited =
event.isUnitedAtEvent ?? readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
if (isUnited !== 0) {
return;
}
const [ranks, nation, historyCount] = await Promise.all([
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
const [nation, serverIdx] = await Promise.all([
event.before.nationId > 0
? prisma.nation.findUnique({
where: { id: event.before.nationId },
select: { name: true, color: true },
})
: null,
prisma.gameHistory.count(),
resolveOfficialGameIndex(prisma, worldMeta),
]);
const rank = new Map(ranks.map((row) => [row.type, row.value]));
const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
const warnum = value('warnum');
const tt = value('ttw') + value('ttd') + value('ttl');
@@ -221,12 +281,13 @@ const settleHall = async (
picture: event.before.picture ?? null,
imgsvr: event.before.imageServer ?? 0,
serverID: serverId,
serverIdx: historyCount,
serverIdx,
scenarioName,
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
};
for (const type of HALL_OF_FAME_TYPES) {
const eventMeta = asRecord(event.before.meta);
let hallValue =
type === 'experience'
? event.before.experience
@@ -234,7 +295,9 @@ const settleHall = async (
? event.before.dedication
: type.endsWith('rate')
? (calc[type] ?? 0)
: value(type);
: type.startsWith('dex')
? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey)
: value(type);
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
if (type === 'ttrate' && tt < 50) continue;
if (type === 'tlrate' && tl < 50) continue;
@@ -244,72 +307,56 @@ const settleHall = async (
if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
hallValue = Number(hallValue);
const existing = await prisma.hallOfFame.findUnique({
where: {
serverId_type_generalNo: {
serverId,
type: type as HallOfFameType,
generalNo: event.generalId,
},
},
});
if (existing) {
if (hallValue > existing.value) {
await prisma.hallOfFame.update({
where: { id: existing.id },
data: { value: hallValue, aux: asJson(aux) },
});
}
continue;
}
await prisma.hallOfFame.createMany({
data: [
{
serverId,
season,
scenario,
generalNo: event.generalId,
type,
value: hallValue,
owner: event.before.userId ?? null,
aux: asJson(aux),
},
],
skipDuplicates: true,
await persistHallOfFameCandidate(prisma, {
serverId,
season,
scenario,
generalNo: event.generalId,
type: type as HallOfFameType,
value: hallValue,
owner: event.before.userId ?? null,
aux,
});
}
};
const archiveDeletedGeneral = async (
const archiveGeneral = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>
worldMeta: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>,
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[]
): Promise<void> => {
const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const [recordRows, rankRows] = await Promise.all([
prisma.logEntry.findMany({
where: {
generalId: event.generalId,
scope: LogScope.GENERAL,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
orderBy: { id: 'desc' },
select: { category: true, text: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const recordRows = await prisma.logEntry.findMany({
where: {
generalId: event.generalId,
scope: LogScope.GENERAL,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
orderBy: { id: 'desc' },
select: { category: true, text: true },
});
const archivedMeta = {
...asRecord(event.before.meta),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
};
delete archivedMeta.inheritRandomUnique;
delete archivedMeta.inheritSpecificSpecialWar;
const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text);
const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text);
const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId);
const history = [
...pendingGeneralLogs
.filter((row) => row.category === LogCategory.HISTORY)
.map((row) => row.text)
.reverse(),
...recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text),
];
const battleResults = [
...pendingGeneralLogs
.filter((row) => row.category === LogCategory.BATTLE_BRIEF)
.map((row) => row.text)
.reverse(),
...recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text),
];
const data = {
...event.before,
meta: archivedMeta,
@@ -345,7 +392,8 @@ export const persistGeneralLifecycleEvents = async (
events: GeneralLifecycleEvent[],
worldMeta: Record<string, unknown>,
configConst: Record<string, unknown>,
gameNow = new Date()
gameNow = new Date(),
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = []
): Promise<void> => {
if (events.length === 0) {
return;
@@ -359,17 +407,18 @@ export const persistGeneralLifecycleEvents = async (
if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
}
if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
continue;
}
const rankValues = await loadLifecycleRankValues(prisma, event);
if (event.outcome === 'deleted') {
await archiveDeletedGeneral(prisma, event, worldMeta);
await settleInheritance(prisma, event, worldMeta, false, configConst);
await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues);
await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs);
}
if (event.outcome === 'retired') {
await settleHall(prisma, event, worldMeta, gameNow);
await settleInheritance(prisma, event, worldMeta, true, configConst);
await prisma.rankData.updateMany({
where: { generalId: event.generalId },
data: { value: 0 },
});
await settleHall(prisma, event, worldMeta, gameNow, rankValues);
await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues);
await persistPostRetirementRankValues(prisma, event);
}
}
};
@@ -0,0 +1,83 @@
import type { HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const readInteger = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback;
};
/**
* `gameIdx` is fixed when RESET opens a game and deliberately excludes
* retained ABANDONED rows. Older fixtures may not carry it, so reconstruct the
* same sequence from completed games plus the configured first index.
*/
export const resolveOfficialGameIndex = async (
prisma: GamePrisma.TransactionClient,
worldMeta: Record<string, unknown>
): Promise<number> => {
if (worldMeta.gameIdx !== undefined) {
return readInteger(worldMeta.gameIdx, 0);
}
const completedGames = await prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
return completedGames + readInteger(worldMeta.firstGameIdx, 1);
};
export interface HallOfFameCandidate {
serverId: string;
season: number;
scenario: number;
generalNo: number;
type: HallOfFameType;
value: number;
owner: string | null;
aux: unknown;
}
/**
* Ref insertIgnore treats an owner record belonging to another general as a
* complete winner: it does not reassign that row even when the new value is
* higher. Only an existing row for the same general and scenario may replace
* value+aux, keeping every identity column unchanged. This avoids the former
* Core state where an old general number was combined with a new general's aux.
*/
export const persistHallOfFameCandidate = async (
prisma: GamePrisma.TransactionClient,
candidate: HallOfFameCandidate
): Promise<'CREATED' | 'UPDATED' | 'PRESERVED'> => {
const matches = await prisma.hallOfFame.findMany({
where: {
OR: [
{ serverId: candidate.serverId, type: candidate.type, generalNo: candidate.generalNo },
...(candidate.owner
? [{ serverId: candidate.serverId, type: candidate.type, owner: candidate.owner }]
: []),
],
},
});
const sameGeneral = matches.find((entry) => entry.generalNo === candidate.generalNo);
if (!sameGeneral && matches.length > 0) {
return 'PRESERVED';
}
if (!sameGeneral) {
await prisma.hallOfFame.create({
data: {
...candidate,
aux: asJson(candidate.aux),
},
});
return 'CREATED';
}
if (sameGeneral.scenario !== candidate.scenario || candidate.value <= sameGeneral.value) {
return 'PRESERVED';
}
await prisma.hallOfFame.update({
where: { id: sameGeneral.id },
data: {
value: candidate.value,
aux: asJson(candidate.aux),
},
});
return 'UPDATED';
};
+47 -5
View File
@@ -83,6 +83,8 @@ export interface GeneralLifecycleEvent {
outcome: 'active' | 'detached' | 'deleted' | 'retired';
before: TurnGeneral;
after?: TurnGeneral;
/** World unification state observed when this lifecycle transition occurred. */
isUnitedAtEvent?: number;
year: number;
month: number;
}
@@ -123,6 +125,23 @@ export interface InMemoryGameClockState {
lastTurnTick: number;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
export interface PendingInheritancePointAdjustment {
userId: string;
key: string;
amount: number;
phase?: InheritancePersistencePhase;
}
export interface PendingInheritanceLog {
userId: string;
year: number;
month: number;
text: string;
phase?: InheritancePersistencePhase;
}
export interface TurnWorldChanges {
realtimeBacklogShiftTicks: number;
accessScoreResetGeneralIds: number[];
@@ -145,7 +164,8 @@ export interface TurnWorldChanges {
deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -184,7 +204,8 @@ export interface InMemoryTurnWorldStateSnapshot {
messages: MessageDraft[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -487,7 +508,8 @@ export class InMemoryTurnWorld {
private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = [];
private readonly inheritancePointAdjustments: PendingInheritancePointAdjustment[] = [];
private readonly pendingInheritanceLogs: PendingInheritanceLog[] = [];
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
@@ -786,6 +808,7 @@ export class InMemoryTurnWorld {
lifecycleEvents: this.lifecycleEvents,
pendingNeutralAuctions: this.pendingNeutralAuctions,
inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingInheritanceLogs: this.pendingInheritanceLogs,
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
@@ -831,6 +854,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingInheritanceLogs, restored.pendingInheritanceLogs ?? []);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
@@ -990,11 +1014,23 @@ export class InMemoryTurnWorld {
});
}
queueInheritancePointAdjustment(userId: string, key: string, amount: number): void {
queueInheritancePointAdjustment(
userId: string,
key: string,
amount: number,
phase?: InheritancePersistencePhase
): void {
if (!userId || !Number.isFinite(amount) || amount === 0) {
return;
}
this.inheritancePointAdjustments.push({ userId, key, amount });
this.inheritancePointAdjustments.push({ userId, key, amount, ...(phase ? { phase } : {}) });
}
queueInheritanceLog(log: PendingInheritanceLog): void {
if (!log.userId || !log.text) {
return;
}
this.pendingInheritanceLogs.push({ ...log });
}
queueNationBettingOpen(betting: PendingNationBettingOpen): void {
@@ -1271,6 +1307,9 @@ export class InMemoryTurnWorld {
generalId: id,
outcome: 'deleted',
before: structuredClone(general),
isUnitedAtEvent: Math.floor(
readMetaNumber(this.state.meta, 'isunited') ?? readMetaNumber(this.state.meta, 'isUnited') ?? 0
),
year,
month,
});
@@ -1811,6 +1850,7 @@ export class InMemoryTurnWorld {
closeAt: new Date(auction.closeAt.getTime()),
}));
const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry }));
const pendingInheritanceLogs = this.pendingInheritanceLogs.map((entry) => ({ ...entry }));
const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({
...entry,
candidates: entry.candidates.map((candidate) => ({
@@ -1852,6 +1892,7 @@ export class InMemoryTurnWorld {
lifecycleEvents,
pendingNeutralAuctions,
inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
@@ -1889,6 +1930,7 @@ export class InMemoryTurnWorld {
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
this.pendingInheritanceLogs.splice(0, changes.pendingInheritanceLogs.length);
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
@@ -0,0 +1,670 @@
import {
asNumber,
asRecord,
LiteHashDRBG,
parseJson,
RandUtil,
rankDataMetaKey,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
type TurnDaemonInheritanceAction,
} from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
isCentennialStatResetAllowed,
isWarTraitKey,
loadWarTraitModules,
resolveMessageTargetIcon,
WarTraitLoader,
type InheritBuffType,
type MessageDraft,
type MessageTarget,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
type InheritanceActionCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
type InheritanceActionResult = Extract<TurnDaemonCommandResult, { type: 'inheritanceAction' }>;
interface InheritConstants {
inheritBornStatPoint: number;
inheritItemRandomPoint: number;
inheritBuffPoints: number[];
inheritSpecificSpecialPoint: number;
inheritResetAttrPointBase: number[];
inheritCheckOwnerPoint: number;
}
const DEFAULT_INHERIT_CONST: InheritConstants = {
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
};
const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
};
const SYSTEM_TARGET: MessageTarget = {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const resolveNumberArray = (value: unknown, fallback: number[]): number[] => {
if (!Array.isArray(value)) return [...fallback];
const result = value
.map((entry) => (typeof entry === 'number' && Number.isFinite(entry) ? entry : null))
.filter((entry): entry is number => entry !== null);
return result.length > 0 ? result : [...fallback];
};
const resolveInheritConstants = (world: InMemoryTurnWorld): InheritConstants => {
const configConst = asRecord(world.getScenarioConfig().const);
return {
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
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
),
};
};
const buildResetCost = (baseCosts: number[], level: number): number => {
const costs = [...baseCosts];
while (costs.length <= level) {
const size = costs.length;
costs.push((costs[size - 1] ?? 0) + (costs[size - 2] ?? 0));
}
return costs[level] ?? 0;
};
const readBuffRecord = (raw: unknown): Record<string, number> => {
const source = typeof raw === 'string' ? (parseJson<Record<string, unknown>>(raw) ?? {}) : asRecord(raw);
return Object.fromEntries(
Object.entries(source).filter((entry): entry is [string, number] => {
const value = entry[1];
return typeof value === 'number' && Number.isFinite(value);
})
);
};
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)));
};
const readStringList = (raw: unknown): string[] => {
const value = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
};
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.floor(parsed);
}
return fallback;
};
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
const value = meta.season;
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.floor(parsed);
}
return null;
};
const readResetSeasons = (meta: Record<string, unknown>): number[] =>
Array.isArray(meta.last_stat_reset)
? meta.last_stat_reset
.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null))
.filter((value): value is number => value !== null)
: [];
export const buildResetStatRandomBonus = (
rng: RandUtil,
baseStats: [number, number, number]
): [number, number, number] => {
const bonusCount = rng.nextRangeInt(3, 5);
const bonus = [0, 0, 0] as [number, number, number];
for (let index = 0; index < bonusCount; index += 1) {
const selected = Number(
rng.choiceUsingWeight({
0: baseStats[0],
1: baseStats[1],
2: baseStats[2],
})
) as 0 | 1 | 2;
bonus[selected] += 1;
}
return bonus;
};
const formatTurnTimeBaseLabel = (value: number): string => {
const wholeSeconds = Math.trunc(value);
const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0');
const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0');
return `${hours}:${minutes}`;
};
const resolveResetTurnTimeBase = (options: {
hiddenSeed: string | number;
userId: string;
previousTurnTimeBase: string | number;
tickSeconds: number;
}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => {
const rng = new LiteHashDRBG(
simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase)
);
const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds);
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
};
const reject = (
action: TurnDaemonInheritanceAction['action'],
code: Extract<InheritanceActionResult, { ok: false }>['code'],
reason: string
): InheritanceActionResult => ({ type: 'inheritanceAction', ok: false, action, code, reason });
const lockPreviousPoint = async (db: GamePrisma.TransactionClient, userId: string): Promise<number> => {
const rows = await db.$queryRaw<Array<{ value: number }>>(GamePrisma.sql`
SELECT value
FROM inheritance_point
WHERE user_id = ${userId} AND key = 'previous'
FOR UPDATE
`);
return rows[0]?.value ?? 0;
};
const appendInheritanceLog = async (
db: GamePrisma.TransactionClient,
userId: string,
year: number,
month: number,
text: string
): Promise<void> => {
await db.inheritanceLog.create({ data: { userId, year, month, text } });
};
const buildMessageTarget = (world: InMemoryTurnWorld, general: TurnGeneral): MessageTarget => {
const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null;
return {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000',
icon: resolveMessageTargetIcon(general),
};
};
const queueOwnerLookupMessages = (
world: InMemoryTurnWorld,
actor: TurnGeneral,
target: TurnGeneral,
ownerName: string,
gameNow: Date
): void => {
const validUntil = new Date('9999-12-31T00:00:00.000Z');
const messages: MessageDraft[] = [
{
msgType: 'private',
src: SYSTEM_TARGET,
dest: buildMessageTarget(world, actor),
text: `${target.name}의 소유자는 ${ownerName} 입니다.`,
time: gameNow,
validUntil,
option: {},
sendDestOnly: true,
},
{
msgType: 'private',
src: SYSTEM_TARGET,
dest: buildMessageTarget(world, target),
text: '소유자명이 누군가에 의해 확인되었습니다.',
time: gameNow,
validUntil,
option: {},
sendDestOnly: true,
},
];
for (const message of messages) world.queueMessage(message);
};
const applyCharge = (options: {
world: InMemoryTurnWorld;
general: TurnGeneral;
userId: string;
previousPoint: number;
cost: number;
patch: Partial<TurnGeneral>;
}): TurnGeneral => {
const { world, general, userId, previousPoint, cost, patch } = options;
const patchMeta = patch.meta ? asRecord(patch.meta) : general.meta;
const spentKey = rankDataMetaKey('inherit_spent_dyn');
const nextMeta = {
...patchMeta,
[spentKey]: readMetaNumber(general.meta, spentKey, 0) + cost,
} as TurnGeneral['meta'];
const next = world.updateGeneral(general.id, {
...patch,
meta: nextMeta,
inheritancePoints: {
...general.inheritancePoints,
previous: previousPoint - cost,
},
});
if (!next) throw new Error(`Inheritance action general ${general.id} disappeared during mutation.`);
world.queueInheritancePointAdjustment(userId, 'previous', -cost);
return next;
};
const isUnited = (world: InMemoryTurnWorld): boolean => {
const meta = asRecord(world.getState().meta);
return asNumber(meta.isunited, 0) !== 0 || asNumber(meta.isUnited, 0) !== 0;
};
export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
const meta = asRecord(rawMeta);
for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) {
const value = meta[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return '알수없음';
};
export const executeInheritanceAction = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
gameNow: Date;
}): Promise<InheritanceActionResult> => {
const { db, world, command, gameNow } = options;
const { input, userId } = command;
const action = input.action;
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
if (!general) return reject(action, 'PRECONDITION_FAILED', '장수가 존재하지 않습니다.');
const state = world.getState();
const worldMeta = asRecord(state.meta);
const config = world.getScenarioConfig();
const configRecord = asRecord(config);
const constants = resolveInheritConstants(world);
if (action === 'checkOwner') {
if (input.targetGeneralId === general.id) {
return reject(action, 'BAD_REQUEST', '자신의 정보는 확인할 수 없습니다.');
}
const target = world.getGeneralById(input.targetGeneralId);
if (!target) return reject(action, 'BAD_REQUEST', '대상 장수가 존재하지 않습니다.');
if (!target.userId) return reject(action, 'BAD_REQUEST', '대상 장수는 NPC입니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const previousPoint = await lockPreviousPoint(db, userId);
const cost = constants.inheritCheckOwnerPoint;
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const ownerName = resolveOwnerDisplayName(target.meta);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 장수 소유자 확인`
);
queueOwnerLookupMessages(world, general, target, ownerName, gameNow);
applyCharge({ world, general, userId, previousPoint, cost, patch: {} });
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
ownerName,
targetName: target.name,
};
}
if (action === 'buyHiddenBuff') {
const buff = readBuffRecord(general.meta.inheritBuff);
const previousLevel = readBuffLevel(buff, input.buffType);
if (input.level === previousLevel) return reject(action, 'BAD_REQUEST', '이미 구입했습니다.');
if (input.level < previousLevel) return reject(action, 'BAD_REQUEST', '이미 더 높은 등급을 구입했습니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost =
(constants.inheritBuffPoints[input.level] ?? 0) - (constants.inheritBuffPoints[previousLevel] ?? 0);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const moreText = previousLevel > 0 ? '추가' : '';
buff[input.buffType] = input.level;
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 ${BUFF_LABELS[input.buffType]} ${input.level} 단계 ${moreText}구입`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritBuff: JSON.stringify(buff) } },
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'setNextSpecialWar') {
if (!isWarTraitKey(input.specialKey)) return reject(action, 'BAD_REQUEST', '잘못된 전투 특기입니다.');
const configConst = asRecord(config.const);
const allowed = Array.isArray(configConst.availableSpecialWar)
? configConst.availableSpecialWar.filter((key): key is string => typeof key === 'string')
: [];
if (allowed.length > 0 && !allowed.includes(input.specialKey)) {
return reject(action, 'BAD_REQUEST', '허용되지 않은 전투 특기입니다.');
}
if (general.role.specialWar === input.specialKey) {
return reject(action, 'BAD_REQUEST', '이미 그 특기를 보유하고 있습니다.');
}
const reserved =
typeof general.meta.inheritSpecificSpecialWar === 'string' ? general.meta.inheritSpecificSpecialWar : null;
if (reserved === input.specialKey) return reject(action, 'BAD_REQUEST', '이미 그 특기를 예약하였습니다.');
if (reserved) return reject(action, 'BAD_REQUEST', '이미 예약한 특기가 있습니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost = constants.inheritSpecificSpecialPoint;
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
const warName = warModule?.name ?? input.specialKey;
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 다음 전투 특기로 ${warName} 지정`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritSpecificSpecialWar: input.specialKey } },
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'resetSpecialWar') {
const currentSpecial = general.role.specialWar;
if (!currentSpecial || currentSpecial === 'None') {
return reject(action, 'BAD_REQUEST', '이미 전투 특기가 공란입니다.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const currentLevel = readMetaNumber(general.meta, 'inheritResetSpecialWar', -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const previousTypes = readStringList(general.meta.prev_types_special2);
previousTypes.push(currentSpecial);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 전투 특기 초기화`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
role: { ...general.role, specialWar: null },
meta: {
...general.meta,
inheritResetSpecialWar: nextLevel,
prev_types_special2: previousTypes,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'resetTurnTime') {
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const currentLevel = readMetaNumber(general.meta, 'inheritResetTurnTime', -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const rawSeedTurnTime = general.meta.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 timing = resolveResetTurnTimeBase({
hiddenSeed,
userId,
previousTurnTimeBase: seedTurnTime,
tickSeconds: state.tickSeconds,
});
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${timing.nextTurnTimeLabel} 적용`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
meta: {
...general.meta,
inheritResetTurnTime: nextLevel,
nextTurnTimeBase: timing.nextTurnTimeBase,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
...timing,
};
}
if (action === 'resetStat') {
const statConfig = asRecord(configRecord.stat);
const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel);
const statMin = asNumber(statConfig.min, 1);
const statMax = asNumber(statConfig.max, 999);
if (input.leadership + input.strength + input.intel !== statTotal) {
return reject(action, 'BAD_REQUEST', `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`);
}
if (
input.leadership < statMin ||
input.strength < statMin ||
input.intel < statMin ||
input.leadership > statMax ||
input.strength > statMax ||
input.intel > statMax
) {
return reject(action, 'BAD_REQUEST', '능력치 범위를 벗어났습니다.');
}
const bonus = input.inheritBonusStat ?? [0, 0, 0];
const bonusSum = bonus.reduce((sum, value) => sum + value, 0);
if (bonus.some((value) => value < 0)) {
return reject(action, 'BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 입력해주세요!');
}
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
return reject(action, 'BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!');
}
if (general.npcState !== 0) return reject(action, 'BAD_REQUEST', 'NPC는 능력치 초기화를 할 수 없습니다.');
if (!isCentennialStatResetAllowed(config)) {
return reject(action, 'BAD_REQUEST', '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost = bonusSum > 0 ? constants.inheritBornStatPoint : 0;
const season = resolveSeasonValue(worldMeta);
const userStateRow =
season === null
? null
: await db.inheritanceUserState.findUnique({ where: { userId }, select: { meta: true } });
const userState = asRecord(userStateRow?.meta);
const resetSeasons = readResetSeasons(userState);
if (season !== null && resetSeasons.includes(season)) {
return reject(action, 'BAD_REQUEST', '이번 시즌에 이미 능력치를 초기화하셨습니다.');
}
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const statHiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: 'inherit';
const baseStats = [input.leadership, input.strength, input.intel] as [number, number, number];
const finalBonus =
bonusSum === 0
? buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize(statHiddenSeed, 'ResetStat', userId))),
baseStats
)
: (bonus as [number, number, number]);
const nextStats = {
leadership: input.leadership + finalBonus[0],
strength: input.strength + finalBonus[1],
intel: input.intel + finalBonus[2],
};
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정`
);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
bonusSum > 0
? `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
: `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
);
if (season !== null) {
await db.inheritanceUserState.upsert({
where: { userId },
update: { meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
create: { userId, meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
});
}
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
stats: {
leadership: nextStats.leadership,
strength: nextStats.strength,
intelligence: nextStats.intel,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
stats: nextStats,
};
}
if (general.meta.inheritRandomUnique !== undefined && general.meta.inheritRandomUnique !== null) {
return reject(action, 'BAD_REQUEST', '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const previousPoint = await lockPreviousPoint(db, userId);
const cost = constants.inheritItemRandomPoint;
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
await appendInheritanceLog(db, userId, state.currentYear, state.currentMonth, `${cost} 포인트로 랜덤 유니크 구입`);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritRandomUnique: 1 } },
});
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
};
@@ -0,0 +1,63 @@
import {
REBIRTH_INHERITANCE_COEFFICIENTS,
type MergedInheritanceKey,
} from '@sammo-ts/logic/inheritance/pointCalculation.js';
const LEGACY_KEY_ORDER = [
'lived_month',
'max_belong',
'max_domestic_critical',
'active_action',
'combat',
'sabotage',
'unifier',
'dex',
'tournament',
'betting',
] as const satisfies readonly MergedInheritanceKey[];
const LEGACY_CALCULATED_KEYS = new Set<MergedInheritanceKey>(['max_belong', 'combat', 'sabotage', 'dex', 'betting']);
const LEGACY_KEY_LABEL: Readonly<Record<'previous' | MergedInheritanceKey, string>> = {
previous: '기존 보유',
lived_month: '생존',
max_belong: '최대 임관년 수',
max_domestic_critical: '최대 연속 내정 성공',
active_action: '능동 행동 수',
combat: '전투 횟수',
sabotage: '계략 성공 횟수',
unifier: '천통 기여',
dex: '숙련도',
tournament: '토너먼트',
betting: '베팅 당첨',
};
const formatLegacyPoint = (value: number): string => {
if (!Number.isFinite(value)) {
return '0';
}
return String(Object.is(value, -0) ? 0 : value);
};
export const buildInheritanceSettlementLogTexts = (input: {
previous: number;
points: Readonly<Partial<Record<MergedInheritanceKey, number>>>;
storedKeys: ReadonlySet<string>;
total: number;
isRebirth: boolean;
}): string[] => {
const texts = input.storedKeys.has('previous')
? [`${LEGACY_KEY_LABEL.previous} 포인트 ${formatLegacyPoint(input.previous)} 증가`]
: [];
for (const key of LEGACY_KEY_ORDER) {
if (!LEGACY_CALCULATED_KEYS.has(key) && !input.storedKeys.has(key)) {
continue;
}
if (input.isRebirth && REBIRTH_INHERITANCE_COEFFICIENTS[key] === null) {
continue;
}
texts.push(`${LEGACY_KEY_LABEL[key]} 포인트 ${formatLegacyPoint(input.points[key] ?? 0)} 증가`);
}
texts.push(`포인트 ${formatLegacyPoint(input.previous)} => ${formatLegacyPoint(input.total)}`);
return texts;
};
@@ -377,7 +377,11 @@ export const createUpdateNationLevelHandler = (options: {
const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
const amount = 250 * levelDiff;
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount);
// General turns (including rebirth settlement) finish before
// monthly actions in the processor. Persist this award after
// lifecycle so the retirement result cannot claim a later
// promotion as pre-rebirth retained state.
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount, 'after_lifecycle');
world.updateGeneral(chief.id, {
inheritancePoints: {
...chief.inheritancePoints,
@@ -32,7 +32,7 @@ import {
loadItemModules,
resolveUniqueConfig,
readScenarioGeneralPoolClaim,
rollUniqueLottery,
rollUniqueLotteryDetailed,
getNextTurnAt,
getBillByLevel,
LEGACY_DEFAULT_MAX_LEVEL,
@@ -479,6 +479,8 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string;
itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
inheritItemRandomPoint: number;
inheritanceWorld?: InMemoryTurnWorld | null;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => {
if (!options.worldView) {
@@ -523,7 +525,7 @@ const buildUniqueLotteryRunner = (options: {
const relMonthByInit =
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
const itemKey = rollUniqueLottery({
const outcome = rollUniqueLotteryDetailed({
rng,
config: options.uniqueConfig,
itemRegistry: options.itemRegistry,
@@ -539,13 +541,54 @@ const buildUniqueLotteryRunner = (options: {
acquireType,
inheritRandomUnique,
});
if (!itemKey) {
if (outcome.status === 'NO_SLOT' || outcome.status === 'NO_SUPPLY') {
if (inheritRandomUnique) {
const turnGeneral = general as TurnGeneral;
const cost = options.inheritItemRandomPoint;
const nextMeta = {
...turnGeneral.meta,
// Explicit retirement resets every rank before this lottery in Ref,
// so a failed pending purchase leaves the post-rebirth delta at -cost.
inherit_spent_dyn:
reason === '은퇴'
? -cost
: readMetaNumber(asRecord(turnGeneral.meta), 'inherit_spent_dyn', 0) - cost,
} as TurnGeneral['meta'];
delete nextMeta.inheritRandomUnique;
turnGeneral.meta = nextMeta;
turnGeneral.inheritancePoints = {
...turnGeneral.inheritancePoints,
previous: readInheritanceNumber(turnGeneral.inheritancePoints?.previous) + cost,
};
if (turnGeneral.userId) {
const persistencePhase = reason === '은퇴' ? 'after_lifecycle' : undefined;
options.inheritanceWorld?.queueInheritancePointAdjustment(
turnGeneral.userId,
'previous',
cost,
persistencePhase
);
options.inheritanceWorld?.queueInheritanceLog({
userId: turnGeneral.userId,
year: world.currentYear,
month: world.currentMonth,
text:
outcome.status === 'NO_SLOT'
? `유니크를 얻을 공간이 없어 ${cost} 포인트 반환`
: `얻을 유니크가 없어 ${cost} 포인트 반환`,
...(persistencePhase ? { phase: persistencePhase } : {}),
});
}
}
return null;
}
if (outcome.status === 'ROLL_FAILED') {
return null;
}
if (inheritRandomUnique && availableBuyUnique) {
delete asRecord(general.meta).inheritRandomUnique;
}
return options.itemRegistry.get(itemKey) ?? null;
return options.itemRegistry.get(outcome.itemKey) ?? null;
};
};
@@ -885,6 +928,11 @@ export const createReservedTurnHandler = async (options: {
const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.scenarioConfig.const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
@@ -1133,6 +1181,8 @@ export const createReservedTurnHandler = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: worldRef,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let actionRng = sharedActionRng ?? buildRng(actionKey);
@@ -2086,6 +2136,10 @@ export const createReservedTurnHandler = async (options: {
}
generalAiState = ai.getDebugState();
}
// che_은퇴 performs the rebirth inside the action, as Ref does. Preserve
// the fully accumulated pre-command state so lifecycle persistence can
// settle Hall/inheritance before observing that reset.
const explicitRetirementSnapshot = cloneTurnGeneral(currentGeneral);
const generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
const generalResult = isBlocked
? {
@@ -2176,7 +2230,10 @@ export const createReservedTurnHandler = async (options: {
delete currentGeneral.meta.nextTurnTimeBase;
}
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active';
const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed;
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired
? 'retired'
: 'active';
let deleteGeneral = false;
const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
@@ -2353,8 +2410,18 @@ export const createReservedTurnHandler = async (options: {
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: lifecycleOutcome,
before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot,
before:
lifecycleOutcome === 'active'
? lifecycleBefore
: explicitlyRetired
? explicitRetirementSnapshot
: lifecycleSnapshot,
...(deleteGeneral ? {} : { after: currentGeneral }),
isUnitedAtEvent: readMetaNumber(
asRecord(context.world.meta),
'isunited',
readMetaNumber(asRecord(context.world.meta), 'isUnited', 0)
),
year: context.world.currentYear,
month: context.world.currentMonth,
},
@@ -2418,6 +2485,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.world.getScenarioConfig().const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
@@ -2488,6 +2560,8 @@ export const createImmediateGeneralActionExecutor = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: options.world,
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
});
const startYear = resolveStartYear(state, options.scenarioMeta);
@@ -2,11 +2,17 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameTy
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
import type { PendingUnificationAuctionCancellation } from './types.js';
const UNIFIER_POINT = 2000;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -49,14 +55,13 @@ const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
export const resolveStoredInheritancePoint = (
currentPoints: ReadonlyMap<string, number>,
general: Pick<TurnGeneral, 'inheritancePoints'>,
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number],
unifierAward: number
): number =>
currentPoints.get(key) ??
(key === 'unifier'
? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward)
: (general.inheritancePoints?.[key] ?? 0));
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number]
): number => {
// All turn/month/auction mutations are persisted before finalization. A
// missing row therefore means zero; the general snapshot can still contain
// a rebirth-paid bucket that the lifecycle transaction deliberately deleted.
return currentPoints.get(key) ?? 0;
};
const formatHistogram = (value: unknown): string =>
Object.entries(asRecord(value))
@@ -329,7 +334,7 @@ export const persistUnificationFinalization = async (
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
const mergedPoints = Object.fromEntries(
ALL_MERGED_INHERITANCE_KEYS.map((key) => {
const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward);
const stored = resolveStoredInheritancePoint(currentPoints, key);
const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
})
@@ -359,15 +364,23 @@ export const persistUnificationFinalization = async (
},
},
});
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`,
},
});
for (const text of buildInheritanceSettlementLogTexts({
previous,
points: mergedPoints,
storedKeys: new Set([...currentPoints.keys(), ...(unifierAward > 0 ? (['unifier'] as const) : [])]),
total,
isRebirth: false,
})) {
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text,
},
});
}
}
const rankRows = generals.length
@@ -388,7 +401,7 @@ export const persistUnificationFinalization = async (
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = input.completedAt.toISOString();
const serverCount = await transaction.gameHistory.count();
const serverIdx = await resolveOfficialGameIndex(transaction, meta);
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
@@ -429,7 +442,7 @@ export const persistUnificationFinalization = async (
unitedTime,
ownerDisplayName: ownerDisplayName(generalMeta),
serverID: serverId,
serverIdx: serverCount,
serverIdx,
serverName,
scenarioName,
generationKey: input.generationKey,
@@ -445,7 +458,7 @@ export const persistUnificationFinalization = async (
? general.experience
: type === 'dedication'
? general.dedication
: readNumber(generalMeta[type]);
: readCentennialRecordableDexterity(generalMeta, type as CentennialDexKey);
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
if (type === 'ttrate' && totals.tt < 50) continue;
if (type === 'tlrate' && totals.tl < 50) continue;
@@ -454,30 +467,16 @@ export const persistUnificationFinalization = async (
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
if (value <= 0) continue;
const existing = await transaction.hallOfFame.findFirst({
where: {
OR: [
{ serverId, type, generalNo: general.id },
{ serverId, type, owner: general.userId },
],
},
await persistHallOfFameCandidate(transaction, {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
});
if (!existing) {
await transaction.hallOfFame.create({
data: {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
},
});
} else if (value > existing.value) {
await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } });
}
}
}
@@ -631,7 +630,7 @@ export const persistUnificationFinalization = async (
await transaction.emperor.create({
data: {
serverId,
phase: `${serverName}${serverCount}`,
phase: `${serverName}${serverIdx}`,
nationCount,
nationName: statisticNationNames || archivedNationNames.join(', '),
nationHist: formatHistogram(statistics.maxNationHist),
@@ -62,6 +62,7 @@ import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGener
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -162,7 +163,8 @@ const resolveCommandAcceptedAt = async (
| 'selectPoolReserve'
| 'selectPoolCreate'
| 'selectPoolReselect'
| 'adjustGeneralIcon';
| 'adjustGeneralIcon'
| 'inheritanceAction';
}
>
): Promise<Date> => {
@@ -802,6 +804,20 @@ async function handlePatchGeneral(
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
}
async function handleInheritanceAction(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx) as unknown as GamePrisma.TransactionClient;
const acceptedAt = await resolveCommandAcceptedAt(db as unknown as DatabaseClient, command);
return executeInheritanceAction({
db,
world: ctx.world,
command,
gameNow: ctx.world.getGameNow(acceptedAt),
});
}
async function handleAdjustGeneralIcon(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
@@ -2936,6 +2952,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
inheritanceAction: (command) =>
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
joinCreateGeneral: (command) =>
@@ -399,4 +399,153 @@ describe('legacy general turn lifecycle', () => {
}
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
});
it('emits an explicit retirement lifecycle event with the pre-rebirth snapshot', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
age: 65,
experience: 1_001,
dedication: 801,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: 'che_명마_07_백마',
weapon: 'che_무기_07_동추',
book: 'che_서적_07_위료자',
item: 'che_의술_정력견혈산',
},
},
meta: {
killturn: 24,
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 10,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 101,
},
inheritancePoints: { previous: 50 },
}),
]),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_은퇴', args: {} };
harness.reservedTurnStore.getGeneralTurns(1)[1] = { action: 'che_은퇴', args: {} };
await harness.runOneTick();
await harness.runOneTick();
const current = harness.world.getGeneralById(1)!;
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
expect(lifecycle).toMatchObject({
outcome: 'retired',
isUnitedAtEvent: 0,
before: {
age: 65,
experience: 1_001,
dedication: 801,
meta: {
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 12,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 101,
},
},
after: {
age: 20,
meta: { inherit_lived_month: 0, inherit_active_action: 0 },
},
});
expect(current).toMatchObject({
age: 20,
experience: 501,
dedication: 401,
inheritancePoints: { previous: 3_050 },
meta: {
rank_warnum: 0,
firenum: 0,
inherit_earned: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: -3_000,
dex1: 51,
},
});
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'user-1',
key: 'previous',
amount: 3_000,
phase: 'after_lifecycle',
});
expect(harness.world.peekDirtyState().pendingInheritanceLogs).toContainEqual({
userId: 'user-1',
year: 200,
month: 2,
text: '유니크를 얻을 공간이 없어 3000 포인트 반환',
phase: 'after_lifecycle',
});
});
it('refunds a failed pending lottery before automatic retirement and resets the spent rank to zero', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
age: 80,
crew: 100,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: 'che_명마_07_백마',
weapon: 'che_무기_07_동추',
book: 'che_서적_07_위료자',
item: 'che_의술_정력견혈산',
},
},
meta: {
killturn: 24,
inheritRandomUnique: true,
inherit_spent_dyn: 3_000,
inherit_lived_month: 10,
},
inheritancePoints: { previous: 70 },
}),
]),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
await harness.runOneTick();
const current = harness.world.getGeneralById(1)!;
expect(current).toMatchObject({
age: 20,
inheritancePoints: { previous: 3_070 },
meta: { inherit_spent_dyn: 0, inherit_lived_month: 0 },
});
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'user-1',
key: 'previous',
amount: 3_000,
});
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
expect(lifecycle?.before.meta).toMatchObject({ inherit_spent_dyn: 0 });
expect(lifecycle?.before.meta).not.toHaveProperty('inheritRandomUnique');
});
});
@@ -1,17 +1,41 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { asRecord, normalizeArchivedGeneral, RANK_DATA_TYPES, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
import type { TurnGeneral } from '../src/turn/types.js';
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalIds = [990_001, 990_002, 990_003];
const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
const generalIds = [990_001, 990_002, 990_003, 990_004, 990_005, 990_006, 990_007];
const userIds = [
'integration-lifecycle-dead',
'integration-lifecycle-retired',
'integration-lifecycle-possessed',
'integration-lifecycle-explicit-retired',
'integration-lifecycle-automatic-retired',
'integration-lifecycle-death-archive',
'integration-lifecycle-retire-before-unification',
];
const serverId = 'lifecycle-int';
const sameFlushServerId = `${serverId}-retire-before-unification`;
const worldId = 990_004;
const deathArchiveWorldId = 990_006;
const sameFlushWorldId = 990_007;
const nationId = 990_004;
const cityId = 990_004;
const sameFlushNationId = 990_007;
const sameFlushCityId = 990_007;
const archiveServerIds = [serverId, sameFlushServerId];
const historyServerIds = [serverId, `${serverId}-completed`, `${serverId}-abandoned`, sameFlushServerId];
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
@@ -69,12 +93,30 @@ integration('general turn lifecycle persistence', () => {
const cleanup = async () => {
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } });
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } });
await db.unificationFinalization.deleteMany({ where: { serverId: sameFlushServerId } });
await db.emperor.deleteMany({ where: { serverId: sameFlushServerId } });
await db.oldNation.deleteMany({ where: { serverId: sameFlushServerId } });
await db.oldGeneral.deleteMany({
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
});
await db.hallOfFame.deleteMany({
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
});
await db.inheritanceResult.deleteMany({
where: { serverId: { in: archiveServerIds }, owner: { in: userIds } },
});
await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } });
await db.general.deleteMany({ where: { id: { in: generalIds } } });
await db.city.deleteMany({ where: { id: { in: [cityId, sameFlushCityId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, sameFlushNationId] } } });
await db.worldState.deleteMany({
where: { id: { in: [worldId, deathArchiveWorldId, sameFlushWorldId] } },
});
await db.gameHistory.deleteMany({ where: { serverId: { in: historyServerIds } } });
};
beforeAll(async () => {
@@ -189,8 +231,7 @@ integration('general turn lifecycle persistence', () => {
});
const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
expect(asRecord(archivedData.meta)).toMatchObject({ inheritRandomUnique: true });
const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot;
expect(snapshot).toMatchObject({
mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 },
@@ -220,7 +261,19 @@ integration('general turn lifecycle persistence', () => {
select: { text: true },
})
).map(({ text }) => text)
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']);
).toEqual([
'사망으로 랜덤 유니크 구입 3000 포인트 반환',
'기존 보유 포인트 3100 증가',
'최대 임관년 수 포인트 90 증가',
'최대 연속 내정 성공 포인트 80 증가',
'전투 횟수 포인트 10 증가',
'계략 성공 횟수 포인트 20 증가',
'천통 기여 포인트 250 증가',
'숙련도 포인트 1.004 증가',
'토너먼트 포인트 50 증가',
'베팅 당첨 포인트 5 증가',
'포인트 3100 => 3622',
]);
});
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
@@ -255,10 +308,15 @@ integration('general turn lifecycle persistence', () => {
data: { generalId: general.id, nationId: 0, type: 'inherit_earned', value: 4_321 },
});
const retirementEvent = event(general, 'retired');
retirementEvent.after = {
...general,
meta: { ...general.meta, rank_warnum: 0, inherit_earned: 0 },
};
await db.$transaction((tx) =>
persistGeneralLifecycleEvents(
tx,
[event(general, 'retired')],
[retirementEvent],
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
{}
)
@@ -311,6 +369,533 @@ integration('general turn lifecycle persistence', () => {
]);
});
it('archives the death turn history and battle brief through execute and database flush', async () => {
const general = makeGeneral(generalIds[5]!, userIds[5]!, {
npcState: 2,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
});
const scenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: { killturn: 0 },
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta = {
title: '사망 기록 archive integration',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
};
const worldMeta = { serverId, killturn: 0, scenarioMeta };
await db.worldState.create({
data: {
id: deathArchiveWorldId,
scenarioCode: 'death-archive-integration',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: scenarioConfig as GamePrisma.InputJsonValue,
meta: worldMeta as GamePrisma.InputJsonValue,
},
});
await db.general.create({
data: {
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
age: general.age,
bornYear: general.bornYear,
deadYear: general.deadYear,
meta: general.meta as GamePrisma.InputJsonValue,
},
});
await db.logEntry.createMany({
data: [
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
year: 199,
month: 12,
generalId: general.id,
text: '이전 열전',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
year: 199,
month: 12,
generalId: general.id,
text: '이전 전투 결과',
},
],
});
const state: TurnWorldState = {
id: deathArchiveWorldId,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: general.turnTime,
meta: worldMeta,
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig,
scenarioMeta,
map: {
id: 'death-archive',
name: '사망 기록 archive',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
generals: [general],
nations: [],
cities: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: {
execute: ({ general: currentGeneral, world: currentWorld }) => ({
deleted: { general: true },
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: 'deleted',
before: currentGeneral,
year: currentWorld.currentYear,
month: currentWorld.currentMonth,
},
logs: [
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: currentGeneral.id,
text: '마지막 열전 첫째',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
generalId: currentGeneral.id,
text: '마지막 전투 결과 첫째',
},
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: currentGeneral.id,
text: '마지막 열전 둘째',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
generalId: currentGeneral.id,
text: '마지막 전투 결과 둘째',
},
],
}),
},
});
world.executeGeneralTurn(world.getGeneralById(general.id)!);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
const archived = await db.oldGeneral.findUniqueOrThrow({
where: { by_no: { serverId, generalNo: general.id } },
});
const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['마지막 열전 둘째', '마지막 열전 첫째', '이전 열전']);
expect(asRecord(archivedData.records).battleResult).toEqual([
'마지막 전투 결과 둘째',
'마지막 전투 결과 첫째',
'이전 전투 결과',
]);
await expect(
db.logEntry.count({
where: {
generalId: general.id,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
})
).resolves.toBe(6);
});
it('settles a pre-month retirement before same-flush unification without losing Hall or repaying stored points', async () => {
const turnTime = new Date('0200-01-01T00:05:00.000Z');
const monthBoundary = new Date('0200-01-01T00:10:00.000Z');
const general = makeGeneral(generalIds[6]!, userIds[6]!, {
nationId: sameFlushNationId,
cityId: sameFlushCityId,
age: 80,
officerLevel: 1,
turnTime,
meta: {
killturn: 24,
owner_name: '월경계 은퇴 사용자',
rank_warnum: 11,
firenum: 2,
inherit_lived_month: 10,
inherit_active_action: 4,
dex1: 200,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
event100_allstar: { granted: { dex1: 80 } },
},
inheritancePoints: {
previous: 100,
lived_month: 10,
active_action: 4,
tournament: 11,
},
});
const scenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
retirementYear: 80,
minPushHallAge: 30,
incDefSettingChange: 3,
maxDefSettingChange: 9,
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta = {
title: '월경계 은퇴 후 통일 integration',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
};
const worldMeta = {
serverId: sameFlushServerId,
serverName: '월경계 서버',
season: 9,
scenarioId: 77,
gameIdx: 12,
isUnited: 0,
isunited: 0,
killturn: 24,
scenarioMeta,
};
const nation = {
id: sameFlushNationId,
name: '월경계국',
color: '#224466',
capitalCityId: sameFlushCityId,
chiefGeneralId: general.id,
gold: 10_000,
rice: 10_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
meta: { gennum: 1, tech: 0 },
};
const city = {
id: sameFlushCityId,
name: '월경계성',
nationId: sameFlushNationId,
level: 5,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: { trust: 50, trade: 100, region: 1 },
};
const map = {
id: 'retire-before-unification',
name: '월경계 은퇴 후 통일',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
await db.worldState.create({
data: {
id: sameFlushWorldId,
scenarioCode: 'retire-before-unification-integration',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: scenarioConfig as GamePrisma.InputJsonValue,
meta: worldMeta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({
data: {
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
tech: 0,
level: nation.level,
typeCode: nation.typeCode,
meta: nation.meta,
},
});
await db.city.create({
data: {
id: city.id,
name: city.name,
nationId: city.nationId,
level: city.level,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
supplyState: city.supplyState,
frontState: city.frontState,
region: 1,
meta: city.meta,
},
});
await db.general.create({
data: {
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
age: general.age,
bornYear: general.bornYear,
deadYear: general.deadYear,
meta: general.meta as GamePrisma.InputJsonValue,
},
});
await db.rankData.createMany({
data: RANK_DATA_TYPES.map((type) => ({
generalId: general.id,
nationId: general.nationId,
type,
value: type === 'warnum' ? 11 : type === 'firenum' ? 2 : 0,
})),
});
await db.inheritancePoint.createMany({
data: [
{ userId: general.userId!, key: 'previous', value: 100 },
{ userId: general.userId!, key: 'lived_month', value: 10 },
{ userId: general.userId!, key: 'active_action', value: 4 },
{ userId: general.userId!, key: 'tournament', value: 11 },
],
});
await db.gameHistory.create({
data: {
serverId: sameFlushServerId,
date: new Date('0200-01-01T00:00:00.000Z'),
season: 9,
scenario: 77,
scenarioName: scenarioMeta.title,
status: 'OPEN',
},
});
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
await reservedTurns.loadAll();
reservedTurns.setGeneralTurn(general.id, 0, { action: '휴식', args: {} });
const state: TurnWorldState = {
id: sameFlushWorldId,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: worldMeta,
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig,
scenarioMeta,
map,
generals: [general],
nations: [nation],
cities: [city],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
let world: InMemoryTurnWorld | null = null;
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig,
scenarioMeta,
map,
getWorld: () => world,
});
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
calendarHandler: {
onMonthChanged: (context) => {
if (!world) throw new Error('world is unavailable');
const reborn = world.getGeneralById(general.id);
if (!reborn?.userId) throw new Error('reborn general is unavailable');
world.queueInheritancePointAdjustment(reborn.userId, 'unifier', 250, 'after_lifecycle');
world.updateGeneral(reborn.id, {
inheritancePoints: {
...reborn.inheritancePoints,
unifier: (reborn.inheritancePoints?.unifier ?? 0) + 250,
},
});
world.updateWorldMeta({ isUnited: 2, isunited: 2 });
world.queueUnificationFinalization({
generationKey: `unification:${sameFlushServerId}`,
serverId: sameFlushServerId,
profileName: 'che',
winnerNationId: sameFlushNationId,
year: context.currentYear,
month: context.currentMonth,
completedAt: new Date(context.turnTime.getTime()),
auctionCancellations: [],
});
},
},
});
world.advanceGameClockTo(monthBoundary, monthBoundary);
const processor = new InMemoryTurnProcessor(world);
const result = await processor.run(monthBoundary, {
budgetMs: 10_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(result).toMatchObject({ processedGenerals: 1, processedTurns: 1, partial: false });
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2 });
expect(world.peekDirtyState().lifecycleEvents).toContainEqual(
expect.objectContaining({
generalId: general.id,
outcome: 'retired',
isUnitedAtEvent: 0,
})
);
expect(world.getGeneralById(general.id)).toMatchObject({
age: 20,
inheritancePoints: { tournament: 11, lived_month: 11, active_action: 4 },
meta: { rank_warnum: 0, inherit_lived_month: 0, inherit_active_action: 0, dex1: 100 },
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns, profileName: 'che' });
try {
await hooks.hooks.flushChanges?.(result);
} finally {
await hooks.close();
}
await expect(
db.hallOfFame.findUniqueOrThrow({
where: {
serverId_type_generalNo: {
serverId: sameFlushServerId,
type: 'warnum',
generalNo: general.id,
},
},
})
).resolves.toMatchObject({ value: 11 });
const dexHall = await db.hallOfFame.findUniqueOrThrow({
where: {
serverId_type_generalNo: {
serverId: sameFlushServerId,
type: 'dex1',
generalNo: general.id,
},
},
});
expect(dexHall).toMatchObject({ value: 120 });
expect(asRecord(dexHall.aux)).toMatchObject({ unitedTime: monthBoundary.toISOString() });
const results = await db.inheritanceResult.findMany({
where: { serverId: sameFlushServerId, owner: general.userId! },
orderBy: { id: 'asc' },
select: { value: true },
});
expect(results).toHaveLength(2);
const rebirth = asRecord(results[0]!.value);
const unification = asRecord(results[1]!.value);
expect(rebirth).toMatchObject({ rebirth: true, tournament: 11 });
expect(asRecord(rebirth.retained)).toMatchObject({ unifier: 0 });
expect(unification).toMatchObject({
generationKey: `unification:${sameFlushServerId}`,
previous: rebirth.total,
lived_month: 0,
active_action: 0,
tournament: 0,
unifier: 250,
unifierBeforeAward: 250,
unifierAward: 0,
});
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: general.userId!, key: 'previous' } },
})
).resolves.toMatchObject({ value: Math.floor(Number(unification.total)) });
});
it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
const general = makeGeneral(generalIds[2]!, userIds[2]!, {
npcState: 1,
@@ -340,4 +925,429 @@ integration('general turn lifecycle persistence', () => {
})
).toBe(0);
});
it('executes explicit retirement, flushes pre-reset settlement values, and reloads only the reborn state', async () => {
const general = makeGeneral(generalIds[3]!, userIds[3]!, {
nationId,
cityId,
age: 65,
experience: 1_001,
dedication: 801,
turnTime: new Date('0200-01-01T00:10:00.000Z'),
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
},
meta: {
killturn: 24,
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 10,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 200,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
event100_allstar: { granted: { dex1: 80 } },
},
inheritancePoints: { previous: 50, lived_month: 10, active_action: 4 },
});
const automaticGeneral = makeGeneral(generalIds[4]!, userIds[4]!, {
nationId,
cityId,
age: 80,
crew: 100,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
},
meta: {
killturn: 24,
rank_warnum: 6,
inherit_lived_month: 10,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 40,
},
inheritancePoints: { previous: 70, lived_month: 10, active_action: 4 },
});
const scenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
retirementYear: 80,
incDefSettingChange: 3,
maxDefSettingChange: 9,
inheritItemRandomPoint: 3_000,
allItems: { weapon: { che_무기_12_칠성검: 1 } },
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta = {
title: '명시적 은퇴 integration',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
};
const worldMeta = {
serverId,
season: 4,
scenarioId: 22,
gameIdx: 7,
isUnited: 0,
killturn: 24,
scenarioMeta,
};
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'explicit-retirement-integration',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: scenarioConfig as GamePrisma.InputJsonValue,
meta: worldMeta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({
data: { id: nationId, name: '은퇴국', color: '#330000', level: 1, capitalCityId: cityId },
});
await db.city.create({
data: {
id: cityId,
name: '은퇴성',
level: 5,
nationId,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
region: 1,
},
});
await db.general.create({
data: {
id: general.id,
userId: general.userId,
name: general.name,
nationId,
cityId,
npcState: 0,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
age: general.age,
bornYear: general.bornYear,
deadYear: general.deadYear,
meta: general.meta as GamePrisma.InputJsonValue,
},
});
await db.general.create({
data: {
id: automaticGeneral.id,
userId: automaticGeneral.userId,
name: automaticGeneral.name,
nationId,
cityId,
npcState: 0,
leadership: automaticGeneral.stats.leadership,
strength: automaticGeneral.stats.strength,
intel: automaticGeneral.stats.intelligence,
experience: automaticGeneral.experience,
dedication: automaticGeneral.dedication,
officerLevel: automaticGeneral.officerLevel,
injury: automaticGeneral.injury,
gold: automaticGeneral.gold,
rice: automaticGeneral.rice,
crew: automaticGeneral.crew,
crewTypeId: automaticGeneral.crewTypeId,
train: automaticGeneral.train,
atmos: automaticGeneral.atmos,
turnTime: automaticGeneral.turnTime,
age: automaticGeneral.age,
bornYear: automaticGeneral.bornYear,
deadYear: automaticGeneral.deadYear,
meta: automaticGeneral.meta as GamePrisma.InputJsonValue,
},
});
await db.rankData.createMany({
data: [
...RANK_DATA_TYPES.map((type) => ({
generalId: general.id,
nationId,
type,
value: type === 'warnum' ? 10 : type === 'firenum' ? 8 : type === 'inherit_earned' ? 123 : 0,
})),
...RANK_DATA_TYPES.map((type) => ({
generalId: automaticGeneral.id,
nationId,
type,
value: type === 'warnum' ? 5 : type === 'inherit_spent_dyn' ? 3_000 : 0,
})),
],
});
await db.inheritancePoint.createMany({
data: [
{ userId: general.userId!, key: 'previous', value: 50 },
{ userId: general.userId!, key: 'lived_month', value: 10 },
{ userId: general.userId!, key: 'active_action', value: 4 },
{ userId: automaticGeneral.userId!, key: 'previous', value: 70 },
{ userId: automaticGeneral.userId!, key: 'lived_month', value: 10 },
{ userId: automaticGeneral.userId!, key: 'active_action', value: 4 },
],
});
await db.gameHistory.createMany({
data: [
{
serverId,
date: new Date('2026-08-24T00:00:00.000Z'),
season: 4,
scenario: 22,
scenarioName: scenarioMeta.title,
status: 'OPEN',
},
{
serverId: `${serverId}-completed`,
date: new Date('2026-08-23T00:00:00.000Z'),
season: 3,
scenario: 22,
scenarioName: '완료',
status: 'COMPLETED',
},
{
serverId: `${serverId}-abandoned`,
date: new Date('2026-08-22T00:00:00.000Z'),
season: 3,
scenario: 22,
scenarioName: '취소',
status: 'ABANDONED',
},
],
});
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
await reservedTurns.loadAll();
reservedTurns.setGeneralTurn(general.id, 0, { action: 'che_은퇴', args: {} });
reservedTurns.setGeneralTurn(general.id, 1, { action: 'che_은퇴', args: {} });
reservedTurns.setGeneralTurn(automaticGeneral.id, 0, { action: 'che_훈련', args: {} });
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: worldMeta,
};
const nation = {
id: nationId,
name: '은퇴국',
color: '#330000',
capitalCityId: cityId,
chiefGeneralId: general.id,
gold: 10_000,
rice: 10_000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: { gennum: 2, tech: 0 },
};
const city = {
id: cityId,
name: '은퇴성',
nationId,
level: 5,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: { trust: 50, trade: 100, region: 1 },
};
const map = {
id: 'explicit-retirement',
name: '명시적 은퇴',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
let world: InMemoryTurnWorld | null = null;
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig,
scenarioMeta,
map,
getWorld: () => world,
});
const snapshot: TurnWorldSnapshot = {
scenarioConfig,
scenarioMeta,
map,
generals: [general, automaticGeneral],
nations: [nation],
cities: [city],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
world.executeGeneralTurn(world.getGeneralById(automaticGeneral.id)!);
world.executeGeneralTurn(world.getGeneralById(general.id)!);
world.executeGeneralTurn(world.getGeneralById(general.id)!);
expect(world.peekDirtyState().lifecycleEvents.some((entry) => entry.outcome === 'retired')).toBe(true);
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: general.userId,
key: 'previous',
amount: 3_000,
phase: 'after_lifecycle',
});
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: automaticGeneral.userId,
key: 'previous',
amount: 3_000,
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 2,
processedTurns: 3,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)).toMatchObject({
age: 20,
experience: 501,
dedication: 401,
meta: {
rank_warnum: 0,
firenum: 0,
inherit_earned: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: -3_000,
dex1: 100,
},
});
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)?.meta).not.toHaveProperty(
'inheritRandomUnique'
);
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)).toMatchObject({
age: 20,
meta: {
rank_warnum: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: 0,
},
});
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)?.meta).not.toHaveProperty(
'inheritRandomUnique'
);
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'warnum', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 11, aux: expect.objectContaining({ serverIdx: 7 }) });
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'firenum', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 9 });
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'inherit_earned', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 4_321 });
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'dex1', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 120 });
const result = await db.inheritanceResult.findFirstOrThrow({
where: { serverId, owner: general.userId! },
orderBy: { id: 'desc' },
});
expect(result.value).toMatchObject({ combat: 55, sabotage: 180, dex: 0.06, rebirth: true });
const inheritanceLogs = await db.inheritanceLog.findMany({
where: { userId: general.userId! },
orderBy: { id: 'asc' },
select: { text: true },
});
const settlementLogIndex = inheritanceLogs.findIndex(({ text }) => text.startsWith('포인트 '));
const refundLogIndex = inheritanceLogs.findIndex(
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
);
expect(settlementLogIndex).toBeGreaterThanOrEqual(0);
expect(refundLogIndex).toBeGreaterThan(settlementLogIndex);
const persistedPrevious = await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: general.userId!, key: 'previous' } },
});
expect(persistedPrevious.value).toBeGreaterThan(3_000);
const automaticInheritanceLogs = await db.inheritanceLog.findMany({
where: { userId: automaticGeneral.userId! },
orderBy: { id: 'asc' },
select: { text: true },
});
const automaticRefundLogIndex = automaticInheritanceLogs.findIndex(
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
);
const automaticSettlementLogIndex = automaticInheritanceLogs.findIndex(({ text }) =>
text.startsWith('포인트 ')
);
expect(automaticRefundLogIndex).toBeGreaterThanOrEqual(0);
expect(automaticSettlementLogIndex).toBeGreaterThan(automaticRefundLogIndex);
await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0);
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0);
});
});
@@ -97,7 +97,14 @@ describe('general lifecycle archive history', () => {
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 },
meta: expect.objectContaining({
killturn: 0,
dex1: 1_000,
rank_warnum: 2,
rank_killnum: 1,
inheritRandomUnique: true,
inheritSpecificSpecialWar: true,
}),
}),
}),
})
@@ -106,32 +113,60 @@ describe('general lifecycle archive history', () => {
it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => {
const general = archivedGeneral();
const hallCreateMany = vi.fn(async () => ({ count: 1 }));
const hallCreate = vi.fn(async () => undefined);
general.userId = 'hall-owner';
general.meta = {
...general.meta,
rank_warnum: 11,
inherit_earned: 4_321,
dex1: 200,
event100_allstar: { granted: { dex1: 80 } },
};
const postRetirement = {
...general,
meta: {
...general.meta,
rank_warnum: 0,
inherit_earned: 0,
},
};
const rankUpsert = vi.fn(async () => undefined);
const prisma = {
generalAccessLog: {
updateMany: vi.fn(async () => ({ count: 1 })),
},
rankData: {
findMany: vi.fn(async () => [{ type: 'inherit_earned', value: 4_321 }]),
updateMany: vi.fn(async () => ({ count: 1 })),
findMany: vi.fn(async () => [
{ type: 'warnum', value: 10 },
{ type: 'inherit_earned', value: 123 },
]),
upsert: rankUpsert,
},
nation: {
findUnique: vi.fn(async () => null),
},
gameHistory: {
count: vi.fn(async () => 2),
count: vi.fn(async () => 99),
},
hallOfFame: {
findUnique: vi.fn(async () => null),
createMany: hallCreateMany,
findMany: vi.fn(async () => []),
create: hallCreate,
update: vi.fn(async () => undefined),
},
inheritancePoint: {
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
upsert: vi.fn(async () => undefined),
deleteMany: vi.fn(async () => ({ count: 0 })),
},
inheritanceResult: { create: vi.fn(async () => undefined) },
inheritanceLog: { create: vi.fn(async () => undefined) },
} as unknown as GamePrisma.TransactionClient;
const event: GeneralLifecycleEvent = {
generalId: general.id,
outcome: 'retired',
before: general,
after: general,
after: postRetirement,
isUnitedAtEvent: 0,
year: 200,
month: 1,
};
@@ -139,26 +174,39 @@ describe('general lifecycle archive history', () => {
await persistGeneralLifecycleEvents(
prisma,
[event],
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 0 },
{}
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 2, gameIdx: 7 },
{},
new Date('0200-02-01T00:00:00.000Z')
);
expect(hallCreateMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
serverId: 'hall-fixture',
season: 4,
scenario: 22,
generalNo: general.id,
type: 'inherit_earned',
value: 4_321,
}),
],
skipDuplicates: true,
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
serverId: 'hall-fixture',
season: 4,
scenario: 22,
generalNo: general.id,
type: 'inherit_earned',
value: 4_321,
}),
});
expect(prisma.rankData.updateMany).toHaveBeenCalledWith({
where: { generalId: general.id },
data: { value: 0 },
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ type: 'warnum', value: 11 }),
});
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'dex1',
value: 120,
aux: expect.objectContaining({ serverIdx: 7, unitedTime: '0200-02-01T00:00:00.000Z' }),
}),
});
expect(prisma.gameHistory.count).not.toHaveBeenCalled();
expect(prisma.inheritanceLog.create).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ text: expect.stringContaining('반환') }) })
);
expect(rankUpsert).toHaveBeenCalledWith({
where: { generalId_type: { generalId: general.id, type: 'warnum' } },
update: { nationId: general.nationId, value: 0 },
create: { generalId: general.id, nationId: general.nationId, type: 'warnum', value: 0 },
});
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from '../src/turn/hallOfFamePersistence.js';
const candidate = {
serverId: 'hall-server',
season: 3,
scenario: 22,
generalNo: 20,
type: 'experience' as const,
value: 2_000,
owner: 'same-owner',
aux: { name: '새장수' },
};
describe('Hall of Fame persistence policy', () => {
it('preserves an owner record belonging to another general for both higher and lower new values', async () => {
const update = vi.fn(async () => undefined);
const prisma = {
hallOfFame: {
findMany: vi.fn(async () => [
{
id: 1,
serverId: candidate.serverId,
season: 3,
scenario: 22,
generalNo: 10,
type: candidate.type,
value: 1_000,
owner: candidate.owner,
aux: { name: '기존장수' },
},
]),
create: vi.fn(async () => undefined),
update,
},
} as unknown as GamePrisma.TransactionClient;
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('PRESERVED');
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 500 })).resolves.toBe('PRESERVED');
expect(update).not.toHaveBeenCalled();
expect(prisma.hallOfFame.create).not.toHaveBeenCalled();
});
it('updates only value and aux for a higher same-general record, and preserves a lower value', async () => {
const existing = {
id: 2,
serverId: candidate.serverId,
season: 3,
scenario: 22,
generalNo: candidate.generalNo,
type: candidate.type,
value: 1_500,
owner: 'old-owner',
aux: { name: '기존장수' },
};
const update = vi.fn(async () => undefined);
const prisma = {
hallOfFame: {
findMany: vi.fn(async () => [existing]),
create: vi.fn(async () => undefined),
update,
},
} as unknown as GamePrisma.TransactionClient;
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('UPDATED');
expect(update).toHaveBeenCalledWith({
where: { id: existing.id },
data: { value: candidate.value, aux: candidate.aux },
});
update.mockClear();
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 1_000 })).resolves.toBe('PRESERVED');
expect(update).not.toHaveBeenCalled();
});
it('uses persisted gameIdx and reconstructs fallback from COMPLETED games only', async () => {
const count = vi.fn(async () => 4);
const prisma = { gameHistory: { count } } as unknown as GamePrisma.TransactionClient;
await expect(resolveOfficialGameIndex(prisma, { gameIdx: 0 })).resolves.toBe(0);
expect(count).not.toHaveBeenCalled();
await expect(resolveOfficialGameIndex(prisma, { firstGameIdx: 0 })).resolves.toBe(4);
expect(count).toHaveBeenCalledWith({ where: { status: 'COMPLETED' } });
});
});
@@ -0,0 +1,399 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 992_310;
const actorGeneralId = 7_310;
const targetGeneralId = 7_311;
const nationId = 7_312;
const actorUserId = 'inheritance-atomic-actor';
const targetUserId = 'inheritance-atomic-target';
const requestPrefix = 'integration:inheritance-atomic';
const pointConstraint = 'inheritance_atomic_point_failure';
const rankConstraint = 'inheritance_atomic_rank_failure';
const logConstraint = 'inheritance_atomic_log_failure';
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: {
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
availableSpecialWar: ['che_의술'],
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta: ScenarioMeta = {
title: '유산 원자성 통합',
startYear: 200,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = { id: 'inheritance-atomic', name: scenarioMeta.title, cities: [] };
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-24T00:00:00.000Z'),
meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta },
};
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
id: actorGeneralId,
userId: actorUserId,
name: '확인장수',
nationId,
cityId: 0,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
turnTime: new Date('2026-08-24T00: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: 17 },
inheritancePoints: { previous: 10_000 },
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 actorGeneral = buildGeneral({});
const targetGeneral = buildGeneral({
id: targetGeneralId,
userId: targetUserId,
name: '피확인장수',
meta: { killturn: 24, owner_name: '레거시 소유자' },
inheritancePoints: { previous: 0 },
});
const generals = [actorGeneral, targetGeneral];
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('immediate_action_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
officerLevel: general.officerLevel,
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
recentWarTime: general.recentWarTime,
age: general.age,
meta: general.meta as GamePrisma.InputJsonValue,
penalty: general.penalty as GamePrisma.InputJsonValue,
});
const buildCommand = (
suffix: string,
input: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>['input']
): Extract<TurnDaemonCommand, { type: 'inheritanceAction' }> => ({
type: 'inheritanceAction',
requestId: `${requestPrefix}:${suffix}`,
userId: actorUserId,
input,
});
integration('inheritance action PostgreSQL atomic persistence', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let hooks: DatabaseTurnHooks | undefined;
const dropFailureConstraints = async (): Promise<void> => {
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT IF EXISTS ${pointConstraint}`);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT IF EXISTS ${rankConstraint}`);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT IF EXISTS ${logConstraint}`);
};
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await dropFailureConstraints();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'inheritance-atomic',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({ data: { id: nationId, name: '통합국', color: '#123456', level: 1 } });
await db.general.createMany({ data: generals.map(toGeneralCreate) });
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 10_000 } });
await db.rankData.create({
data: { generalId: actorGeneralId, nationId, type: 'inherit_spent_dyn', value: 17 },
});
});
afterAll(async () => {
await hooks?.close();
if (db) {
await dropFailureConstraints();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { id: worldId } });
}
await disconnect?.();
});
it('rolls patch/point/rank/log/messages back at each injected failure and reloads one committed mutation', async () => {
const snapshot: TurnWorldSnapshot = {
generals,
cities: [],
nations: [
{
id: nationId,
name: '통합국',
color: '#123456',
capitalCityId: null,
chiefGeneralId: actorGeneralId,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta,
map,
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (captured) => world.restoreState(captured),
});
const execute = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<TurnDaemonCommandResult> => {
if (!hooks?.hooks.executeCommand || !command.requestId) {
throw new Error('Database command execution hook is unavailable.');
}
return stateManager.transaction(() =>
hooks!.hooks.executeCommand!(command.requestId!, async (context) => {
const result = await handler.handle(command, context);
if (!result) throw new Error('inheritanceAction command was not handled.');
return result;
})
);
};
const createInputEvent = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<void> => {
await db.inputEvent.create({
data: {
requestId: command.requestId!,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
status: 'PROCESSING',
lockedBy: 'inheritance-atomic-worker',
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
attempts: 1,
payload: command as GamePrisma.InputJsonValue,
},
});
};
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: point });
await expect(
db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: actorGeneralId, type: 'inherit_spent_dyn' } },
})
).resolves.toMatchObject({ value: spent });
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(logCount);
await expect(
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
).resolves.toBe(messageCount);
};
const pointCommand = buildCommand('point', {
action: 'buyHiddenBuff',
buffType: 'warAvoidRatio',
level: 1,
});
await createInputEvent(pointCommand);
await db.$executeRawUnsafe(`
ALTER TABLE inheritance_point
ADD CONSTRAINT ${pointConstraint}
CHECK (user_id <> '${actorUserId}' OR key <> 'previous' OR value = 10000)
`);
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
await assertStored(10_000, 17, 0, 0);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
).resolves.toMatchObject({
status: 'PROCESSING',
result: null,
});
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
await assertStored(9_800, 217, 1, 0);
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
await createInputEvent(rankCommand);
await db.$executeRawUnsafe(`
ALTER TABLE rank_data
ADD CONSTRAINT ${rankConstraint}
CHECK (general_id <> ${actorGeneralId} OR type <> 'inherit_spent_dyn' OR value = 217)
`);
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
expect(world.peekDirtyState().messages).toEqual([]);
await assertStored(9_800, 217, 1, 0);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
await expect(execute(rankCommand)).resolves.toMatchObject({
ok: true,
remainPoint: 8_800,
ownerName: '레거시 소유자',
});
await assertStored(8_800, 1_217, 2, 2);
const currentLog = await db.inheritanceLog.findFirstOrThrow({
where: { userId: actorUserId },
orderBy: { id: 'desc' },
select: { id: true },
});
const logCommand = buildCommand('log', { action: 'buyRandomUnique' });
await createInputEvent(logCommand);
await db.$executeRawUnsafe(`
ALTER TABLE inheritance_log
ADD CONSTRAINT ${logConstraint}
CHECK (user_id <> '${actorUserId}' OR id <= ${currentLog.id})
`);
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
await assertStored(8_800, 1_217, 2, 2);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 3, 2);
const freeStatCommand = buildCommand('free-stat', {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
await createInputEvent(freeStatCommand);
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 5, 2);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
orderBy: { id: 'asc' },
select: { mailbox: true, message: true },
});
expect(messages.map((entry) => [entry.mailbox, (entry.message as { text: string }).text])).toEqual([
[actorGeneralId, '피확인장수의 소유자는 레거시 소유자 입니다.'],
[targetGeneralId, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: rankCommand.requestId! } })
).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
result: expect.objectContaining({ type: 'inheritanceAction', ok: true, action: 'checkOwner' }),
lockedBy: null,
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((general) => general.id === actorGeneralId)).toMatchObject({
stats: { leadership: 71, strength: 47, intelligence: 86 },
meta: {
inherit_spent_dyn: 4_217,
inheritRandomUnique: 1,
inheritBuff: JSON.stringify({ warAvoidRatio: 1 }),
},
inheritancePoints: { previous: 5_800 },
});
}, 30_000);
});
@@ -0,0 +1,357 @@
import { describe, expect, it, vi } from 'vitest';
import { LiteHashDRBG, RandUtil, type TurnDaemonCommand } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
buildResetStatRandomBonus,
executeInheritanceAction,
resolveOwnerDisplayName,
} from '../src/turn/inheritanceActionService.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
type InheritanceCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 1,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 17 },
inheritancePoints: { previous: 10_000 },
turnTime: new Date('0200-04-01T00:00:00.000Z'),
...overrides,
});
const buildWorld = (options: {
general?: TurnGeneral;
target?: TurnGeneral;
worldMeta?: Record<string, unknown>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 4,
tickSeconds: 3_600,
lastTurnTime: new Date('0200-04-01T00:00:00.000Z'),
meta: { hiddenSeed: 'test-seed', season: 7, isunited: 0, ...(options.worldMeta ?? {}) },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: options.configMap ?? {},
const: {
availableSpecialWar: ['che_의술'],
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
...(options.configConst ?? {}),
},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
generals: [options.general ?? buildGeneral(), ...(options.target ? [options.target] : [])],
cities: [],
nations: [
{
id: 1,
name: '촉',
color: '#ff0000',
capitalCityId: null,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
const buildDatabase = (options: { point?: number; resetSeasons?: number[] } = {}) => {
const createLog = vi.fn(async () => ({}));
const findUserState = vi.fn(async () =>
options.resetSeasons ? { meta: { last_stat_reset: options.resetSeasons } } : null
);
const upsertUserState = vi.fn(async () => ({}));
const queryRaw = vi.fn(async () => [{ value: options.point ?? 10_000 }]);
return {
db: {
$queryRaw: queryRaw,
inheritanceLog: { create: createLog },
inheritanceUserState: { findUnique: findUserState, upsert: upsertUserState },
} as unknown as GamePrisma.TransactionClient,
createLog,
findUserState,
upsertUserState,
queryRaw,
};
};
const execute = async (
world: InMemoryTurnWorld,
db: GamePrisma.TransactionClient,
input: InheritanceCommand['input']
) =>
executeInheritanceAction({
db,
world,
command: { type: 'inheritanceAction', userId: 'user-1', input },
gameNow: new Date('0200-04-01T00:00:00.000Z'),
});
describe('inheritance action service', () => {
it.each([
{
name: 'hidden buff',
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
cost: 200,
general: buildGeneral(),
},
{
name: 'specific special',
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
cost: 4_000,
general: buildGeneral(),
},
{
name: 'special reset',
input: { action: 'resetSpecialWar' } as const,
cost: 1_000,
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_선봉' } }),
},
{
name: 'turn-time reset',
input: { action: 'resetTurnTime' } as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'paid stat reset',
input: {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1] as [number, number, number],
} as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'random unique reservation',
input: { action: 'buyRandomUnique' } as const,
cost: 3_000,
general: buildGeneral(),
},
{
name: 'owner lookup',
input: { action: 'checkOwner', targetGeneralId: 2 } as const,
cost: 1_000,
general: buildGeneral(),
},
])('charges $name in runtime rank, points, and the same dirty flush', async ({ input, cost, general }) => {
const target =
input.action === 'checkOwner'
? buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
})
: undefined;
const world = buildWorld({ general, target });
const { db, createLog } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: true, remainPoint: 10_000 - cost });
expect(world.getGeneralById(1)).toMatchObject({
meta: { inherit_spent_dyn: 17 + cost },
inheritancePoints: { previous: 10_000 - cost },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual(
cost === 0 ? [] : [{ userId: 'user-1', key: 'previous', amount: -cost }]
);
expect(createLog).toHaveBeenCalled();
});
it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => {
const world = buildWorld({});
const { db } = buildDatabase({ point: 0 });
const result = await execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
expect(result).toMatchObject({ ok: true, remainPoint: 0 });
expect(world.getGeneralById(1)?.meta.inherit_spent_dyn).toBe(17);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]);
expect(result.ok && result.stats).toEqual({ leadership: 73, strength: 45, intel: 87 });
});
it('matches the Ref ResetStat DRBG seed, inclusive 3..5 count, and weighted choices', () => {
const bonus = buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize('test-seed', 'ResetStat', 'user-1'))),
[70, 45, 85]
);
expect(bonus).toEqual([3, 0, 2]);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(3);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(5);
});
it.each([1, 2])('rejects npcState=%s ResetStat exactly like Ref npc != 0', async (npcState) => {
const world = buildWorld({ general: buildGeneral({ npcState }) });
const { db, queryRaw } = buildDatabase();
await expect(
execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: 'NPC는 능력치 초기화를 할 수 없습니다.' });
expect(queryRaw).not.toHaveBeenCalled();
});
it.each([
{
name: 'purchased buff before unification',
general: buildGeneral({ meta: { killturn: 24, inheritBuff: JSON.stringify({ warAvoidRatio: 1 }) } }),
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
reason: '이미 구입했습니다.',
},
{
name: 'owned special before unification',
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_의술' } }),
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
reason: '이미 그 특기를 보유하고 있습니다.',
},
{
name: 'blank special before unification',
general: buildGeneral(),
input: { action: 'resetSpecialWar' } as const,
reason: '이미 전투 특기가 공란입니다.',
},
{
name: 'random reservation before unification',
general: buildGeneral({ meta: { killturn: 24, inheritRandomUnique: true } }),
input: { action: 'buyRandomUnique' } as const,
reason: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
},
])('preserves Ref combined-invalid precedence: $name', async ({ general, input, reason }) => {
const world = buildWorld({ general, worldMeta: { isunited: 1 } });
const { db, queryRaw } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: false, reason });
expect(queryRaw).not.toHaveBeenCalled();
});
it('checks ResetStat shape, npc/S100, unification, season duplicate, then points', async () => {
const npcWorld = buildWorld({ general: buildGeneral({ npcState: 1 }), worldMeta: { isunited: 1 } });
const npcDb = buildDatabase({ point: 0 });
await expect(
execute(npcWorld, npcDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 84,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '능력치 총합이 200이 아닙니다. 다시 입력해주세요!' });
const seasonWorld = buildWorld({});
const seasonDb = buildDatabase({ point: 0, resetSeasons: [7] });
await expect(
execute(seasonWorld, seasonDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '이번 시즌에 이미 능력치를 초기화하셨습니다.' });
expect(seasonDb.queryRaw).not.toHaveBeenCalled();
});
it('uses both unification keys and preserves owner display-name compatibility order', async () => {
const world = buildWorld({ worldMeta: { isUnited: 1, isunited: 0 } });
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'resetTurnTime' })).resolves.toMatchObject({
ok: false,
reason: '이미 천하가 통일되었습니다.',
});
expect(resolveOwnerDisplayName({ ownerDisplayName: '현재', owner_name: '레거시', ownerName: '호환' })).toBe(
'현재'
);
expect(resolveOwnerDisplayName({ owner_name: '레거시', ownerName: '호환' })).toBe('레거시');
expect(resolveOwnerDisplayName({ ownerName: '호환' })).toBe('호환');
expect(resolveOwnerDisplayName({})).toBe('알수없음');
});
it('queues CheckOwner messages in Ref requester-then-target order', async () => {
const world = buildWorld({
target: buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
}),
});
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'checkOwner', targetGeneralId: 2 })).resolves.toMatchObject({
ok: true,
ownerName: '위유저',
});
expect(world.peekDirtyState().messages.map((message) => [message.dest.generalId, message.text])).toEqual([
[1, '조조의 소유자는 위유저 입니다.'],
[2, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { buildInheritanceSettlementLogTexts } from '../src/turn/inheritanceSettlementLogs.js';
describe('legacy inheritance settlement logs', () => {
it('logs calculated keys plus only direct stored keys that are present, in Ref order', () => {
expect(
buildInheritanceSettlementLogTexts({
previous: 100,
points: {
lived_month: 12,
max_belong: 90,
max_domestic_critical: 80,
active_action: 3,
combat: 15,
sabotage: 40,
unifier: 250,
dex: 1.25,
tournament: 50,
betting: 5,
},
storedKeys: new Set(['previous', 'lived_month', 'unifier']),
total: 521,
isRebirth: false,
})
).toEqual([
'기존 보유 포인트 100 증가',
'생존 포인트 12 증가',
'최대 임관년 수 포인트 90 증가',
'전투 횟수 포인트 15 증가',
'계략 성공 횟수 포인트 40 증가',
'천통 기여 포인트 250 증가',
'숙련도 포인트 1.25 증가',
'베팅 당첨 포인트 5 증가',
'포인트 100 => 521',
]);
});
it('skips delayed rebirth keys and logs coefficient-adjusted values', () => {
expect(
buildInheritanceSettlementLogTexts({
previous: 50,
points: {
lived_month: 12,
max_belong: 0,
max_domestic_critical: 0,
active_action: 3,
combat: 15,
sabotage: 40,
unifier: 0,
dex: 0.5,
tournament: 7,
betting: 5,
},
storedKeys: new Set([
'previous',
'lived_month',
'max_domestic_critical',
'active_action',
'unifier',
'tournament',
]),
total: 132,
isRebirth: true,
})
).toEqual([
'기존 보유 포인트 50 증가',
'생존 포인트 12 증가',
'능동 행동 수 포인트 3 증가',
'전투 횟수 포인트 15 증가',
'계략 성공 횟수 포인트 40 증가',
'숙련도 포인트 0.5 증가',
'토너먼트 포인트 7 증가',
'베팅 당첨 포인트 5 증가',
'포인트 50 => 132',
]);
});
});
@@ -214,7 +214,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.getGeneralById(1)?.role.items.horse).toBe(uniqueHorse.key);
expect(world.getGeneralById(2)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 500 },
{ userId: 'user-1', key: 'unifier', amount: 500, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
expect(world.peekDirtyState().logs).toEqual(
@@ -291,7 +291,7 @@ describe('UpdateNationLevel monthly action', () => {
meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 },
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
});
@@ -306,7 +306,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.getGeneralById(1)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 },
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
});
@@ -84,6 +84,7 @@ describe('durable read-model change journal mapping', () => {
lifecycleEvents: [],
pendingNeutralAuctions: [],
inheritancePointAdjustments: [],
pendingInheritanceLogs: [],
pendingNationBettingOpens: [],
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
@@ -35,6 +35,7 @@ const buildWorld = (): InMemoryTurnWorld => {
rank_warnum: 4,
firenum: 2,
dex1: 100,
event100_allstar: { granted: { dex1: 40 } },
},
officerLevel: 12,
experience: 10,
@@ -141,18 +142,12 @@ const input = {
describe('persistUnificationFinalization', () => {
it.each([
{ label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 },
])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => {
expect(
resolveStoredInheritancePoint(
new Map<string, number>(rows),
{ inheritancePoints: { unifier: memoryValue } },
'unifier',
2_000
)
).toBe(expected);
{ label: 'missing unifier row', rows: [], key: 'unifier' as const, expected: 0 },
{ label: 'missing resettable row', rows: [], key: 'tournament' as const, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], key: 'unifier' as const, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], key: 'unifier' as const, expected: 7 },
])('uses only transaction-visible inheritance storage for $label', ({ rows, key, expected }) => {
expect(resolveStoredInheritancePoint(new Map<string, number>(rows), key)).toBe(expected);
});
it('does not write when the transaction-scoped generation was already applied', async () => {
@@ -222,7 +217,7 @@ describe('persistUnificationFinalization', () => {
},
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
hallOfFame: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: hallCreate,
update: vi.fn().mockResolvedValue({}),
},
@@ -274,6 +269,11 @@ describe('persistUnificationFinalization', () => {
data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }),
})
);
expect(hallCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'dex1', value: 60 }),
})
);
expect(gameHistoryUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
);
@@ -186,9 +186,13 @@ describe('unique lottery on general commands', () => {
expect(dedicationIndex).toBeLessThan(uniqueIndex);
});
it('does not award a unique item reserved by an active auction', async () => {
it('refunds a pending inheritance purchase when active auctions exhaust the supply', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const lotteryGeneral = buildGeneral(1);
lotteryGeneral.userId = 'inherit-user';
lotteryGeneral.meta = { killturn: 24, inheritRandomUnique: true, inherit_spent_dyn: 3_000 };
lotteryGeneral.inheritancePoints = { previous: 200 };
const generals = [lotteryGeneral];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
@@ -267,6 +271,7 @@ describe('unique lottery on general commands', () => {
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
inheritItemRandomPoint: 3_000,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
@@ -317,6 +322,22 @@ describe('unique lottery on general commands', () => {
});
expect(result.general?.role.items.weapon).toBeNull();
expect(result.general?.meta).toMatchObject({ inherit_spent_dyn: 0 });
expect(result.general?.meta).not.toHaveProperty('inheritRandomUnique');
expect(result.general?.inheritancePoints?.previous).toBe(3_200);
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'inherit-user',
key: 'previous',
amount: 3_000,
});
expect(world.peekDirtyState().pendingInheritanceLogs).toEqual([
{
userId: 'inherit-user',
year: 180,
month: 1,
text: '얻을 유니크가 없어 3000 포인트 반환',
},
]);
});
});