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 { z } from 'zod';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; 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 { import {
ItemLoader, ItemLoader,
isItemKey, isItemKey,
loadWarTraitModules, loadWarTraitModules,
sendMessage,
WarTraitLoader, WarTraitLoader,
WAR_TRAIT_KEYS, WAR_TRAIT_KEYS,
isWarTraitKey, isWarTraitKey,
isCentennialStatResetAllowed, isCentennialStatResetAllowed,
} from '@sammo-ts/logic'; } 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 { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { import {
appendInheritanceLog,
buildResetCost, buildResetCost,
computeInheritanceItems, computeInheritanceItems,
readInheritancePoint,
readUserStateMeta,
resolveInheritConstants, resolveInheritConstants,
setInheritancePoint,
sumInheritanceItems, sumInheritanceItems,
writeUserStateMeta,
} from '../../services/inheritance.js'; } from '../../services/inheritance.js';
import type { GameApiContext, WorldStateRow } from '../../context.js'; import type { GameApiContext, WorldStateRow } from '../../context.js';
import { openAuctionWithDaemon } from '../../auction/open.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[] = [ const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio', 'warAvoidRatio',
@@ -46,17 +37,6 @@ const BUFF_KEYS: InheritBuffType[] = [
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; 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 POSTGRES_INTEGER_MAX = 2_147_483_647;
const parseBuffRecord = (raw: unknown): Record<string, number> => { const parseBuffRecord = (raw: unknown): Record<string, number> => {
@@ -74,13 +54,6 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
return result; 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 readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; 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))); 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 ( const requestInheritanceAction = async (
ctx: Pick<GameApiContext, 'turnDaemon'>, ctx: Pick<GameApiContext, 'turnDaemon' | 'requestId'>,
generalId: number, userId: string,
patch: { input: TurnDaemonInheritanceAction
meta?: Record<string, unknown>; ) => {
turnTime?: string;
stats?: {
leadership?: number;
strength?: number;
intelligence?: number;
};
specialWar?: string | null;
}
): Promise<void> => {
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'patchGeneral', type: 'inheritanceAction',
generalId, userId,
patch, 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' }); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
} }
if (!result.ok) { 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[] => { const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
@@ -189,54 +155,6 @@ export const resolveResetTurnTimeBase = (options: {
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) }; 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({ export const inheritRouter = router({
getStatus: authedProcedure.query(async ({ ctx }) => { getStatus: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.auth?.user.id; const userId = ctx.auth?.user.id;
@@ -380,7 +298,7 @@ export const inheritRouter = router({
}); });
return logs; return logs;
}), }),
buyHiddenBuff: authedProcedure buyHiddenBuff: engineAuthedProcedure
.input( .input(
z.object({ z.object({
type: z.enum(BUFF_KEYS), type: z.enum(BUFF_KEYS),
@@ -393,56 +311,14 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); const result = await requestInheritanceAction(ctx, userId, {
const worldMeta = asRecord(worldState.meta); action: 'buyHiddenBuff',
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { buffType: input.type,
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); level: input.level,
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, meta: true },
}); });
if (!general) { return { ok: true, remainPoint: result.remainPoint };
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 };
}), }),
setNextSpecialWar: authedProcedure setNextSpecialWar: engineAuthedProcedure
.input( .input(
z.object({ z.object({
specialKey: z.string(), specialKey: z.string(),
@@ -454,197 +330,32 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); await requestInheritanceAction(ctx, userId, { action: 'setNextSpecialWar', specialKey: input.specialKey });
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} 지정`
);
return { ok: true }; return { ok: true };
}), }),
resetSpecialWar: authedProcedure.mutation(async ({ ctx }) => { resetSpecialWar: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id; const userId = ctx.auth?.user.id;
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); await requestInheritanceAction(ctx, userId, { action: 'resetSpecialWar' });
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} 포인트로 전투 특기 초기화`
);
return { ok: true }; return { ok: true };
}), }),
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => { resetTurnTime: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id; const userId = ctx.auth?.user.id;
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); const result = await requestInheritanceAction(ctx, userId, { action: 'resetTurnTime' });
const worldMeta = asRecord(worldState.meta); return {
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { ok: true,
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); nextTurnTimeBase: result.nextTurnTimeBase!,
} nextTurnTimeLabel: result.nextTurnTimeLabel!,
};
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 };
}), }),
resetStat: authedProcedure resetStat: engineAuthedProcedure
.input( .input(
z.object({ z.object({
leadership: z.number().int(), leadership: z.number().int(),
@@ -658,191 +369,21 @@ export const inheritRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); const result = await requestInheritanceAction(ctx, userId, {
const worldMeta = asRecord(worldState.meta); action: 'resetStat',
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { leadership: input.leadership,
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); strength: input.strength,
} intel: input.intel,
const config = asRecord(worldState.config); ...(input.inheritBonusStat ? { inheritBonusStat: input.inheritBonusStat } : {}),
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}이 아닙니다. 다시 입력해주세요!`,
}); });
} return { ok: true, stats: result.stats! };
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 },
});
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 };
}), }),
buyRandomUnique: authedProcedure.mutation(async ({ ctx }) => { buyRandomUnique: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id; const userId = ctx.auth?.user.id;
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); await requestInheritanceAction(ctx, userId, { action: 'buyRandomUnique' });
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} 포인트로 랜덤 유니크 구입`
);
return { ok: true }; return { ok: true };
}), }),
openUniqueAuction: engineAuthedProcedure openUniqueAuction: engineAuthedProcedure
@@ -885,7 +426,7 @@ export const inheritRouter = router({
); );
return { ok: true, ...result }; return { ok: true, ...result };
}), }),
checkOwner: authedProcedure checkOwner: engineAuthedProcedure
.input( .input(
z.object({ z.object({
targetGeneralId: z.number().int().positive(), targetGeneralId: z.number().int().positive(),
@@ -896,79 +437,10 @@ export const inheritRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const worldState = await resolveWorld(ctx); const result = await requestInheritanceAction(ctx, userId, {
const worldMeta = asRecord(worldState.meta); action: 'checkOwner',
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { targetGeneralId: input.targetGeneralId,
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); });
} return { ok: true, ownerName: result.ownerName!, targetName: result.targetName! };
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 };
}), }),
}); });
+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 type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.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 { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
import { import {
@@ -182,11 +186,17 @@ export const rankingRouter = router({
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
}, },
], ],
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)], ...(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map(
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)], (key, index) =>
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], [
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], [' 병 숙 련 도', '궁 병 숙 련 도', '기 병 숙 련 도', '귀 병 숙 련 도', '차 병 숙 련 도'][
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], 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', 'percent',
@@ -415,7 +425,7 @@ export const rankingRouter = router({
return Array.from(optionMap.values()); return Array.from(optionMap.values());
} }
const rows = await ctx.db.gameHistory.findMany({ const rows = await ctx.db.gameHistory.findMany({
where: { status: 'COMPLETED' }, where: { status: { in: ['OPEN', 'COMPLETED'] } },
select: { season: true, scenario: true, scenarioName: true }, select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }], 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 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 { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '../src/context.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 { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { appRouter } from '../src/router.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 integration = describe.skipIf(!databaseUrl);
const actorGeneralId = 8_701; const worldId = 992_320;
const checkedGeneralId = 8_702; const actorId = 7_320;
const actorNationId = 871; const targetId = 7_321;
const checkedNationId = 872; const actorNationId = 7_322;
const targetNationId = 7_323;
const actorUserId = 'inherit-owner-message-actor'; 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 = { const auth: GameSessionTokenPayload = {
version: 1, version: 1,
profile: 'che:inherit-owner-message', profile: 'che:inherit-owner-message',
issuedAt: '2026-08-19T00:00:00.000Z', issuedAt: '2026-08-19T00:00:00.000Z',
expiresAt: '2026-08-20T00:00:00.000Z', expiresAt: '2026-08-20T00:00:00.000Z',
sessionId: 'inherit-owner-message-session', sessionId: 'inherit-owner-message-session',
user: { user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] },
id: actorUserId,
username: actorUserId,
displayName: '확인자 계정',
roles: ['user'],
},
sanctions: {}, sanctions: {},
}; };
const hasMailboxChange = (payload: unknown): boolean => { const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false; id: entry.id,
const changes = (payload as { changes?: unknown }).changes; userId: entry.userId,
if (!Array.isArray(changes)) return false; name: entry.name,
const mailboxes = new Set([actorGeneralId, checkedGeneralId]); nationId: entry.nationId,
return changes.some( cityId: entry.cityId,
(change) => npcState: entry.npcState,
Array.isArray(change) && leadership: entry.stats.leadership,
change[0] === 'messages.mailbox' && strength: entry.stats.strength,
typeof change[1] === 'number' && intel: entry.stats.intelligence,
mailboxes.has(change[1]) turnTime: entry.turnTime,
); meta: entry.meta as GamePrisma.InputJsonValue,
}; penalty: entry.penalty as GamePrisma.InputJsonValue,
});
integration('inherit owner lookup private messages', () => { integration('inherit owner lookup private messages', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined; let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
const buildContext = (requestId: string): GameApiContext => { beforeAll(async () => {
const redisClient = { const schema = new URL(databaseUrl!).searchParams.get('schema');
get: async () => null, if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`);
set: async () => null, 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, requestId,
db, db,
redis: redisClient as unknown as RedisConnector['client'], redis: redisClient as unknown as RedisConnector['client'],
turnDaemon: new InMemoryTurnDaemonTransport(), turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000),
battleSim: new InMemoryBattleSimTransport(), battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' }, profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
uploadDir: 'uploads', uploadDir: 'uploads',
@@ -72,157 +262,46 @@ integration('inherit owner lookup private messages', () => {
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'), accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
flushStore: new InMemoryFlushStore(), flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
readModelOutbox: { wake: vi.fn() },
};
}; };
beforeAll(async () => { let loop: Promise<void> | undefined;
const connector = createGamePostgresConnector({ url: databaseUrl! }); try {
await connector.connect(); loop = lifecycle.start();
db = connector.prisma; const caller = appRouter.createCaller(context);
closeDb = () => connector.disconnect(); const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' };
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
await db.inputEvent.deleteMany({ where: { actorUserId } }); await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } }); } finally {
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); await lifecycle.stop('inherit owner integration finished');
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } }); await loop;
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } }); await hooks.close();
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 } } });
} }
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( await expect(
db.inheritancePoint.findUniqueOrThrow({ db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } })
where: { userId_key: { userId: actorUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 500 }); ).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( await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } }) db.rankData.findUniqueOrThrow({
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId }); where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } },
await expect(
db.readModelRevision.findMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
orderBy: { entityId: 'asc' },
}) })
).resolves.toEqual([ ).resolves.toMatchObject({ value: 1_000 });
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }), await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1);
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }), 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);
}); });
+153 -128
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; 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 { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra'; import type { RedisConnector } from '@sammo-ts/infra';
import type { MessagePayload } from '@sammo-ts/logic'; 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 }>; inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>; configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>; configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult;
}) => { }) => {
const auth = options.auth === undefined ? buildAuth() : options.auth; const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general; const general = options.general === undefined ? buildGeneral() : options.general;
@@ -118,11 +119,19 @@ const buildContext = (options: {
options.target === undefined options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } }) ? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target; : options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({ const requestCommand = vi.fn(async (command: TurnDaemonCommand): Promise<TurnDaemonCommandResult> => {
type: command.type, if (options.daemonResult) return options.daemonResult;
if (command.type === 'inheritanceAction') {
return {
type: 'inheritanceAction',
ok: true, ok: true,
generalId: command.generalId, 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 pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({})); const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); 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({ const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' }, configMap: { targetGeneralPool: 'SPoolUnderU100' },
inheritancePoint: 0, inheritancePoint: 0,
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'resetStat',
code: 'BAD_REQUEST',
reason: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
},
}); });
const caller = appRouter.createCaller(fixture.context); const caller = appRouter.createCaller(fixture.context);
@@ -291,7 +307,17 @@ describe('inherit router actor and permission boundaries', () => {
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.', 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.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled();
}); });
@@ -429,10 +455,17 @@ describe('inherit router actor and permission boundaries', () => {
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled(); 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({ const fixture = buildContext({
auth: buildAuth('user-2'), auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }), general: buildGeneral({ userId: 'user-1' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'buyHiddenBuff',
code: 'PRECONDITION_FAILED',
reason: '장수가 존재하지 않습니다.',
},
}); });
await expect( await expect(
@@ -444,12 +477,25 @@ describe('inherit router actor and permission boundaries', () => {
code: 'PRECONDITION_FAILED', code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.', 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(); expect(fixture.pointUpsert).not.toHaveBeenCalled();
}); });
it('mutates only the authenticated user general and inheritance balance', async () => { 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( await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
@@ -458,29 +504,26 @@ describe('inherit router actor and permission boundaries', () => {
}) })
).resolves.toEqual({ ok: true, remainPoint: 800 }); ).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith( expect(fixture.requestCommand).toHaveBeenCalledWith({
expect.objectContaining({ type: 'inheritanceAction',
type: 'patchGeneral', userId: 'user-1',
generalId: 7, input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
patch: expect.objectContaining({ });
meta: expect.objectContaining({ expect(fixture.pointUpsert).not.toHaveBeenCalled();
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }), expect(fixture.logCreate).not.toHaveBeenCalled();
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
}); });
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => { it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
const fixture = buildContext({ const fixture = buildContext({
inheritancePoint: 5_000, inheritancePoint: 5_000,
configConst: { availableSpecialWar: ['che_의술'] }, configConst: { availableSpecialWar: ['che_의술'] },
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'setNextSpecialWar',
generalId: 7,
remainPoint: 1_000,
},
}); });
await expect( await expect(
@@ -488,19 +531,12 @@ describe('inherit router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true }); ).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({ expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral', type: 'inheritanceAction',
generalId: 7,
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1', userId: 'user-1',
year: 200, input: { action: 'setNextSpecialWar', specialKey: 'che_의술' },
month: 4,
text: '4000 포인트로 다음 전투 특기로 의술 지정',
},
}); });
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 () => { 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, inheritancePoint: 5_000,
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }), general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
configConst: { availableSpecialWar: ['che_의술'] }, configConst: { availableSpecialWar: ['che_의술'] },
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'setNextSpecialWar',
code: 'BAD_REQUEST',
reason: '이미 예약한 특기가 있습니다.',
},
}); });
await expect( await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' }) appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' }); ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
expect(fixture.requestCommand).not.toHaveBeenCalled(); expect(fixture.requestCommand).toHaveBeenCalledOnce();
expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled();
}); });
@@ -522,41 +565,44 @@ describe('inherit router actor and permission boundaries', () => {
const fixture = buildContext({ const fixture = buildContext({
inheritancePoint: 2_000, inheritancePoint: 2_000,
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }), 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 }); await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({ expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral', type: 'inheritanceAction',
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', userId: 'user-1',
year: 200, input: { action: 'resetSpecialWar' },
month: 4,
text: '1000 포인트로 전투 특기 초기화',
},
}); });
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 () => { 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({ await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: '이미 전투 특기가 공란입니다.', message: '이미 전투 특기가 공란입니다.',
}); });
expect(fixture.requestCommand).not.toHaveBeenCalled(); expect(fixture.requestCommand).toHaveBeenCalledOnce();
expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled();
}); });
@@ -572,34 +618,41 @@ describe('inherit router actor and permission boundaries', () => {
previousTurnTimeBase: 123_456, previousTurnTimeBase: 123_456,
tickSeconds: worldState.tickSeconds, 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({ await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
ok: true, ok: true,
...expected, ...expected,
}); });
expect(fixture.requestCommand).toHaveBeenCalledWith({ expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral', type: 'inheritanceAction',
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', userId: 'user-1',
year: 200, input: { action: 'resetTurnTime' },
month: 4,
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
},
}); });
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 () => { 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( await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
@@ -608,64 +661,27 @@ describe('inherit router actor and permission boundaries', () => {
ownerName: '위유저', ownerName: '위유저',
targetName: '조조', targetName: '조조',
}); });
expect(fixture.pointUpsert).toHaveBeenCalledWith( expect(fixture.requestCommand).toHaveBeenCalledWith({
expect.objectContaining({ type: 'inheritanceAction',
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1', userId: 'user-1',
year: 200, input: { action: 'checkOwner', targetGeneralId: 8 },
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
}); });
expect(fixture.messageRows).toHaveLength(2); expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.messageRows).toEqual([ expect(fixture.logCreate).not.toHaveBeenCalled();
expect.objectContaining({ expect(fixture.messageRows).toHaveLength(0);
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();
}); });
it('does not charge or send messages when the owner lookup target is the actor', async () => { it('does not charge or send messages when the owner lookup target is the actor', async () => {
const fixture = buildContext({ const fixture = buildContext({
inheritancePoint: 1_500, inheritancePoint: 1_500,
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }), target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'checkOwner',
code: 'BAD_REQUEST',
reason: '자신의 정보는 확인할 수 없습니다.',
},
}); });
await expect( 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 () => { 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( await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).rejects.toMatchObject({ ).rejects.toMatchObject({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: '유산 포인트가 부족합니다.', message: '충분한 유산 포인트를 가지고 있지 않습니다.',
}); });
expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled();
+43 -3
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 type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { RANK_DATA_TYPES } from '@sammo-ts/common'; import { RANK_DATA_TYPES } from '@sammo-ts/common';
@@ -108,6 +108,7 @@ const buildContext = (options?: {
profileId?: string; profileId?: string;
generals?: RankingGeneralRow[]; generals?: RankingGeneralRow[];
rankRows?: Array<{ generalId: number; type: string; value: number }>; rankRows?: Array<{ generalId: number; type: string; value: number }>;
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
}): GameApiContext => { }): GameApiContext => {
const selectedGeneralRows = options?.generals ?? generalRows; const selectedGeneralRows = options?.generals ?? generalRows;
const selectedProfile = options?.profileId const selectedProfile = options?.profileId
@@ -198,10 +199,12 @@ const buildContext = (options?: {
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
}, },
gameHistory: { gameHistory: {
findMany: async () => [ findMany:
options?.gameHistoryFindMany ??
(async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' }, { season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' }, { season: 3, scenario: 22, scenarioName: '가상모드22' },
], ]),
}, },
hallOfFame: { hallOfFame: {
findMany: async (args: { where: { type: string } }) => 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 () => { it('returns positions one through ten for every populated ranking section', async () => {
const generals = Array.from({ length: 12 }, (_, index) => { const generals = Array.from({ length: 12 }, (_, index) => {
const id = index + 1; 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 () => { it('scopes previous-server options and rankings to the request profile', async () => {
const cheCaller = appRouter.createCaller(buildContext({ authenticated: false })); const cheCaller = appRouter.createCaller(buildContext({ authenticated: false }));
await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([ 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 const zAdjustGeneralIcon = z
.object({ .object({
type: z.literal('adjustGeneralIcon'), type: z.literal('adjustGeneralIcon'),
@@ -643,6 +679,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId }; 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 normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
const command = parseWith(zAdjustGeneralIcon, envelope.command); const command = parseWith(zAdjustGeneralIcon, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) { if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
@@ -764,6 +808,7 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta, adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult, tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral, patchGeneral: normalizePatchGeneral,
inheritanceAction: normalizeInheritanceAction,
adjustGeneralIcon: normalizeAdjustGeneralIcon, adjustGeneralIcon: normalizeAdjustGeneralIcon,
joinCreateGeneral: normalizeJoinCreateGeneral, joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral, npcPossessGeneral: normalizeNpcPossessGeneral,
+58 -17
View File
@@ -44,7 +44,7 @@ import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js'; import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; 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 type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
@@ -1094,6 +1094,7 @@ export const createDatabaseTurnHooks = async (
lifecycleEvents, lifecycleEvents,
pendingNeutralAuctions, pendingNeutralAuctions,
inheritancePointAdjustments, inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens, pendingNationBettingOpens,
pendingNationBettingFinishes, pendingNationBettingFinishes,
pendingYearbookSnapshots, pendingYearbookSnapshots,
@@ -1137,6 +1138,20 @@ export const createDatabaseTurnHooks = async (
select: { id: true }, select: { id: true },
}) })
)?.id ?? 0; )?.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 // Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can // world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch. // never commit after another owner has advanced the epoch.
@@ -1257,15 +1272,24 @@ export const createDatabaseTurnHooks = async (
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
const serverId = const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; 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 }>(); 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 groupKey = `${entry.userId}\u0000${entry.key}`;
const current = grouped.get(groupKey); const current = grouped.get(groupKey);
if (current) { if (current) {
current.amount += entry.amount; current.amount += entry.amount;
} else { } else {
grouped.set(groupKey, { ...entry }); grouped.set(groupKey, {
userId: entry.userId,
key: entry.key,
amount: entry.amount,
});
} }
} }
for (const entry of grouped.values()) { for (const entry of grouped.values()) {
@@ -1275,14 +1299,41 @@ export const createDatabaseTurnHooks = async (
create: { userId: entry.userId, key: entry.key, value: entry.amount }, 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( await persistGeneralLifecycleEvents(
prisma, prisma,
lifecycleEvents, lifecycleEvents,
meta, meta,
asRecord(world.getScenarioConfig().const), 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) { if (accessScoreResetGeneralIds.length > 0) {
await prisma.generalAccessLog.updateMany({ await prisma.generalAccessLog.updateMany({
@@ -1611,21 +1662,11 @@ export const createDatabaseTurnHooks = async (
await upsertRankRows(prisma, rankRows); await upsertRankRows(prisma, rankRows);
} }
if (logs.length > 0) { if (pendingLogRows.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({ await prisma.logEntry.createMany({
data: payload, data: pendingLogRows,
}); });
} }
}
for (const snapshot of pendingYearbookSnapshots) { for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot); 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 type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogScope } from '@sammo-ts/logic';
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js'; 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 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; 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); 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 ( const settleInheritance = async (
prisma: GamePrisma.TransactionClient, prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent, event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>, worldMeta: Record<string, unknown>,
isRebirth: boolean, isRebirth: boolean,
configConst: Record<string, unknown> configConst: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>
): Promise<void> => { ): Promise<void> => {
const userId = event.before.userId; const userId = event.before.userId;
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) { if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
@@ -53,29 +114,23 @@ const settleInheritance = async (
} }
} }
const [rows, rankRows] = await Promise.all([ const rows = await prisma.inheritancePoint.findMany({
prisma.inheritancePoint.findMany({
where: { userId }, where: { userId },
select: { key: true, value: true }, select: { key: true, value: true },
}), });
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const points = new Map(rows.map((row) => [row.key, row.value])); const points = new Map(rows.map((row) => [row.key, row.value]));
const previous = points.get('previous') ?? 0; const previous = points.get('previous') ?? 0;
const randomUniqueRefund = meta.inheritRandomUnique const randomUniqueRefund =
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) !isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0;
: 0; const specificSpecialRefund =
const specificSpecialRefund = meta.inheritSpecificSpecialWar !isRebirth && meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0; : 0;
const refund = randomUniqueRefund + specificSpecialRefund; const refund = randomUniqueRefund + specificSpecialRefund;
const calculationMeta = { const calculationMeta = {
...Object.fromEntries(rankValues),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
...meta, ...meta,
...Object.fromEntries(rankRows.map((row) => [row.type, row.value])),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
}; };
const settlement = computeInheritanceSettlementBreakdown( const settlement = computeInheritanceSettlementBreakdown(
{ {
@@ -143,14 +198,22 @@ const settleInheritance = async (
}, },
}); });
} }
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({ await prisma.inheritanceLog.create({
data: { data: {
userId, userId,
year: event.year, year: event.year,
month: event.month, month: event.month,
text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`, text,
}, },
}); });
}
}; };
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0); const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
@@ -159,26 +222,23 @@ const settleHall = async (
prisma: GamePrisma.TransactionClient, prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent, event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>, worldMeta: Record<string, unknown>,
gameNow: Date gameNow: Date,
rank: ReadonlyMap<string, number>
): Promise<void> => { ): 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) { if (isUnited !== 0) {
return; return;
} }
const [ranks, nation, historyCount] = await Promise.all([ const [nation, serverIdx] = await Promise.all([
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
event.before.nationId > 0 event.before.nationId > 0
? prisma.nation.findUnique({ ? prisma.nation.findUnique({
where: { id: event.before.nationId }, where: { id: event.before.nationId },
select: { name: true, color: true }, select: { name: true, color: true },
}) })
: null, : 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 value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
const warnum = value('warnum'); const warnum = value('warnum');
const tt = value('ttw') + value('ttd') + value('ttl'); const tt = value('ttw') + value('ttd') + value('ttl');
@@ -221,12 +281,13 @@ const settleHall = async (
picture: event.before.picture ?? null, picture: event.before.picture ?? null,
imgsvr: event.before.imageServer ?? 0, imgsvr: event.before.imageServer ?? 0,
serverID: serverId, serverID: serverId,
serverIdx: historyCount, serverIdx,
scenarioName, scenarioName,
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '', serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
}; };
for (const type of HALL_OF_FAME_TYPES) { for (const type of HALL_OF_FAME_TYPES) {
const eventMeta = asRecord(event.before.meta);
let hallValue = let hallValue =
type === 'experience' type === 'experience'
? event.before.experience ? event.before.experience
@@ -234,6 +295,8 @@ const settleHall = async (
? event.before.dedication ? event.before.dedication
: type.endsWith('rate') : type.endsWith('rate')
? (calc[type] ?? 0) ? (calc[type] ?? 0)
: type.startsWith('dex')
? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey)
: value(type); : value(type);
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue; if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
if (type === 'ttrate' && tt < 50) continue; if (type === 'ttrate' && tt < 50) continue;
@@ -244,51 +307,29 @@ const settleHall = async (
if (!Number.isFinite(hallValue) || hallValue <= 0) continue; if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
hallValue = Number(hallValue); hallValue = Number(hallValue);
const existing = await prisma.hallOfFame.findUnique({ await persistHallOfFameCandidate(prisma, {
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, serverId,
season, season,
scenario, scenario,
generalNo: event.generalId, generalNo: event.generalId,
type, type: type as HallOfFameType,
value: hallValue, value: hallValue,
owner: event.before.userId ?? null, owner: event.before.userId ?? null,
aux: asJson(aux), aux,
},
],
skipDuplicates: true,
}); });
} }
}; };
const archiveDeletedGeneral = async ( const archiveGeneral = async (
prisma: GamePrisma.TransactionClient, prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent, event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown> worldMeta: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>,
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[]
): Promise<void> => { ): Promise<void> => {
const serverId = const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const [recordRows, rankRows] = await Promise.all([ const recordRows = await prisma.logEntry.findMany({
prisma.logEntry.findMany({
where: { where: {
generalId: event.generalId, generalId: event.generalId,
scope: LogScope.GENERAL, scope: LogScope.GENERAL,
@@ -296,20 +337,26 @@ const archiveDeletedGeneral = async (
}, },
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
select: { category: true, text: true }, select: { category: true, text: true },
}), });
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const archivedMeta = { const archivedMeta = {
...asRecord(event.before.meta), ...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; const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId);
delete archivedMeta.inheritSpecificSpecialWar; const history = [
const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text); ...pendingGeneralLogs
const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text); .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 = { const data = {
...event.before, ...event.before,
meta: archivedMeta, meta: archivedMeta,
@@ -345,7 +392,8 @@ export const persistGeneralLifecycleEvents = async (
events: GeneralLifecycleEvent[], events: GeneralLifecycleEvent[],
worldMeta: Record<string, unknown>, worldMeta: Record<string, unknown>,
configConst: Record<string, unknown>, configConst: Record<string, unknown>,
gameNow = new Date() gameNow = new Date(),
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = []
): Promise<void> => { ): Promise<void> => {
if (events.length === 0) { if (events.length === 0) {
return; return;
@@ -359,17 +407,18 @@ export const persistGeneralLifecycleEvents = async (
if (event.outcome === 'detached' || event.outcome === 'deleted') { if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); 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') { if (event.outcome === 'deleted') {
await archiveDeletedGeneral(prisma, event, worldMeta); await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues);
await settleInheritance(prisma, event, worldMeta, false, configConst); await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs);
} }
if (event.outcome === 'retired') { if (event.outcome === 'retired') {
await settleHall(prisma, event, worldMeta, gameNow); await settleHall(prisma, event, worldMeta, gameNow, rankValues);
await settleInheritance(prisma, event, worldMeta, true, configConst); await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues);
await prisma.rankData.updateMany({ await persistPostRetirementRankValues(prisma, event);
where: { generalId: event.generalId },
data: { value: 0 },
});
} }
} }
}; };
@@ -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'; outcome: 'active' | 'detached' | 'deleted' | 'retired';
before: TurnGeneral; before: TurnGeneral;
after?: TurnGeneral; after?: TurnGeneral;
/** World unification state observed when this lifecycle transition occurred. */
isUnitedAtEvent?: number;
year: number; year: number;
month: number; month: number;
} }
@@ -123,6 +125,23 @@ export interface InMemoryGameClockState {
lastTurnTick: number; 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 { export interface TurnWorldChanges {
realtimeBacklogShiftTicks: number; realtimeBacklogShiftTicks: number;
accessScoreResetGeneralIds: number[]; accessScoreResetGeneralIds: number[];
@@ -145,7 +164,8 @@ export interface TurnWorldChanges {
deletedEvents: number[]; deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[]; lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[]; pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[]; pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -184,7 +204,8 @@ export interface InMemoryTurnWorldStateSnapshot {
messages: MessageDraft[]; messages: MessageDraft[];
lifecycleEvents: GeneralLifecycleEvent[]; lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[]; pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[]; pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -487,7 +508,8 @@ export class InMemoryTurnWorld {
private readonly messages: MessageDraft[] = []; private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = []; private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = []; 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 pendingNationBettingOpens: PendingNationBettingOpen[] = [];
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
@@ -786,6 +808,7 @@ export class InMemoryTurnWorld {
lifecycleEvents: this.lifecycleEvents, lifecycleEvents: this.lifecycleEvents,
pendingNeutralAuctions: this.pendingNeutralAuctions, pendingNeutralAuctions: this.pendingNeutralAuctions,
inheritancePointAdjustments: this.inheritancePointAdjustments, inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingInheritanceLogs: this.pendingInheritanceLogs,
pendingNationBettingOpens: this.pendingNationBettingOpens, pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes, pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots, pendingYearbookSnapshots: this.pendingYearbookSnapshots,
@@ -831,6 +854,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents); this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions); this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments); this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingInheritanceLogs, restored.pendingInheritanceLogs ?? []);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens); this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes); this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); 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) { if (!userId || !Number.isFinite(amount) || amount === 0) {
return; 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 { queueNationBettingOpen(betting: PendingNationBettingOpen): void {
@@ -1271,6 +1307,9 @@ export class InMemoryTurnWorld {
generalId: id, generalId: id,
outcome: 'deleted', outcome: 'deleted',
before: structuredClone(general), before: structuredClone(general),
isUnitedAtEvent: Math.floor(
readMetaNumber(this.state.meta, 'isunited') ?? readMetaNumber(this.state.meta, 'isUnited') ?? 0
),
year, year,
month, month,
}); });
@@ -1811,6 +1850,7 @@ export class InMemoryTurnWorld {
closeAt: new Date(auction.closeAt.getTime()), closeAt: new Date(auction.closeAt.getTime()),
})); }));
const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry })); const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry }));
const pendingInheritanceLogs = this.pendingInheritanceLogs.map((entry) => ({ ...entry }));
const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({ const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({
...entry, ...entry,
candidates: entry.candidates.map((candidate) => ({ candidates: entry.candidates.map((candidate) => ({
@@ -1852,6 +1892,7 @@ export class InMemoryTurnWorld {
lifecycleEvents, lifecycleEvents,
pendingNeutralAuctions, pendingNeutralAuctions,
inheritancePointAdjustments, inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens, pendingNationBettingOpens,
pendingNationBettingFinishes, pendingNationBettingFinishes,
pendingYearbookSnapshots, pendingYearbookSnapshots,
@@ -1889,6 +1930,7 @@ export class InMemoryTurnWorld {
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length); this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length); this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length); this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
this.pendingInheritanceLogs.splice(0, changes.pendingInheritanceLogs.length);
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length); this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length); this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.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); const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
if (chief?.userId && chief.npcState < 2 && isUnited === 0) { if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
const amount = 250 * levelDiff; 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, { world.updateGeneral(chief.id, {
inheritancePoints: { inheritancePoints: {
...chief.inheritancePoints, ...chief.inheritancePoints,
@@ -32,7 +32,7 @@ import {
loadItemModules, loadItemModules,
resolveUniqueConfig, resolveUniqueConfig,
readScenarioGeneralPoolClaim, readScenarioGeneralPoolClaim,
rollUniqueLottery, rollUniqueLotteryDetailed,
getNextTurnAt, getNextTurnAt,
getBillByLevel, getBillByLevel,
LEGACY_DEFAULT_MAX_LEVEL, LEGACY_DEFAULT_MAX_LEVEL,
@@ -479,6 +479,8 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string; seedBase: string;
itemRegistry: Map<string, ItemModule>; itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>; uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
inheritItemRandomPoint: number;
inheritanceWorld?: InMemoryTurnWorld | null;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>; getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => { }): UniqueLotteryRunner => {
if (!options.worldView) { if (!options.worldView) {
@@ -523,7 +525,7 @@ const buildUniqueLotteryRunner = (options: {
const relMonthByInit = const relMonthByInit =
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth); joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit; const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
const itemKey = rollUniqueLottery({ const outcome = rollUniqueLotteryDetailed({
rng, rng,
config: options.uniqueConfig, config: options.uniqueConfig,
itemRegistry: options.itemRegistry, itemRegistry: options.itemRegistry,
@@ -539,13 +541,54 @@ const buildUniqueLotteryRunner = (options: {
acquireType, acquireType,
inheritRandomUnique, 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; return null;
} }
if (inheritRandomUnique && availableBuyUnique) { if (inheritRandomUnique && availableBuyUnique) {
delete asRecord(general.meta).inheritRandomUnique; 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 env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const)); const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.scenarioConfig.const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) { if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
} }
@@ -1133,6 +1181,8 @@ export const createReservedTurnHandler = async (options: {
seedBase, seedBase,
itemRegistry, itemRegistry,
uniqueConfig, uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: worldRef,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys, getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
}); });
let actionRng = sharedActionRng ?? buildRng(actionKey); let actionRng = sharedActionRng ?? buildRng(actionKey);
@@ -2086,6 +2136,10 @@ export const createReservedTurnHandler = async (options: {
} }
generalAiState = ai.getDebugState(); 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 generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
const generalResult = isBlocked const generalResult = isBlocked
? { ? {
@@ -2176,7 +2230,10 @@ export const createReservedTurnHandler = async (options: {
delete currentGeneral.meta.nextTurnTimeBase; 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; let deleteGeneral = false;
const deletedTroopIds = Array.from(commandDeletedTroopIds); const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
@@ -2353,8 +2410,18 @@ export const createReservedTurnHandler = async (options: {
lifecycleEvent: { lifecycleEvent: {
generalId: currentGeneral.id, generalId: currentGeneral.id,
outcome: lifecycleOutcome, outcome: lifecycleOutcome,
before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot, before:
lifecycleOutcome === 'active'
? lifecycleBefore
: explicitlyRetired
? explicitRetirementSnapshot
: lifecycleSnapshot,
...(deleteGeneral ? {} : { after: currentGeneral }), ...(deleteGeneral ? {} : { after: currentGeneral }),
isUnitedAtEvent: readMetaNumber(
asRecord(context.world.meta),
'isunited',
readMetaNumber(asRecord(context.world.meta), 'isUnited', 0)
),
year: context.world.currentYear, year: context.world.currentYear,
month: context.world.currentMonth, month: context.world.currentMonth,
}, },
@@ -2418,6 +2485,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const)); 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) { if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
} }
@@ -2488,6 +2560,8 @@ export const createImmediateGeneralActionExecutor = async (options: {
seedBase, seedBase,
itemRegistry, itemRegistry,
uniqueConfig, uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: options.world,
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys, getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
}); });
const startYear = resolveStartYear(state, options.scenarioMeta); 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 { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } 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 { 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 type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildOldNationArchiveData } from './oldNationArchive.js'; import { buildOldNationArchiveData } from './oldNationArchive.js';
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js'; import type { PendingUnificationAuctionCancellation } from './types.js';
const UNIFIER_POINT = 2000; const UNIFIER_POINT = 2000;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -49,14 +55,13 @@ const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
export const resolveStoredInheritancePoint = ( export const resolveStoredInheritancePoint = (
currentPoints: ReadonlyMap<string, number>, currentPoints: ReadonlyMap<string, number>,
general: Pick<TurnGeneral, 'inheritancePoints'>, key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number]
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number], ): number => {
unifierAward: number // All turn/month/auction mutations are persisted before finalization. A
): number => // missing row therefore means zero; the general snapshot can still contain
currentPoints.get(key) ?? // a rebirth-paid bucket that the lifecycle transaction deliberately deleted.
(key === 'unifier' return currentPoints.get(key) ?? 0;
? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward) };
: (general.inheritancePoints?.[key] ?? 0));
const formatHistogram = (value: unknown): string => const formatHistogram = (value: unknown): string =>
Object.entries(asRecord(value)) 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 unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
const mergedPoints = Object.fromEntries( const mergedPoints = Object.fromEntries(
ALL_MERGED_INHERITANCE_KEYS.map((key) => { 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; const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
return [key, computeActiveInheritancePoint(general, key, effectiveStored)]; return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
}) })
@@ -359,16 +364,24 @@ export const persistUnificationFinalization = async (
}, },
}, },
}); });
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({ await transaction.inheritanceLog.create({
data: { data: {
userId, userId,
serverId, serverId,
year: input.year, year: input.year,
month: input.month, month: input.month,
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`, text,
}, },
}); });
} }
}
const rankRows = generals.length const rankRows = generals.length
? await transaction.rankData.findMany({ ? await transaction.rankData.findMany({
@@ -388,7 +401,7 @@ export const persistUnificationFinalization = async (
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? ''); const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = input.completedAt.toISOString(); 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 minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => { const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
@@ -429,7 +442,7 @@ export const persistUnificationFinalization = async (
unitedTime, unitedTime,
ownerDisplayName: ownerDisplayName(generalMeta), ownerDisplayName: ownerDisplayName(generalMeta),
serverID: serverId, serverID: serverId,
serverIdx: serverCount, serverIdx,
serverName, serverName,
scenarioName, scenarioName,
generationKey: input.generationKey, generationKey: input.generationKey,
@@ -445,7 +458,7 @@ export const persistUnificationFinalization = async (
? general.experience ? general.experience
: type === 'dedication' : type === 'dedication'
? general.dedication ? general.dedication
: readNumber(generalMeta[type]); : readCentennialRecordableDexterity(generalMeta, type as CentennialDexKey);
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue; if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
if (type === 'ttrate' && totals.tt < 50) continue; if (type === 'ttrate' && totals.tt < 50) continue;
if (type === 'tlrate' && totals.tl < 50) continue; if (type === 'tlrate' && totals.tl < 50) continue;
@@ -454,17 +467,7 @@ export const persistUnificationFinalization = async (
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue; if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
if (value <= 0) continue; if (value <= 0) continue;
const existing = await transaction.hallOfFame.findFirst({ await persistHallOfFameCandidate(transaction, {
where: {
OR: [
{ serverId, type, generalNo: general.id },
{ serverId, type, owner: general.userId },
],
},
});
if (!existing) {
await transaction.hallOfFame.create({
data: {
serverId, serverId,
season, season,
scenario, scenario,
@@ -473,11 +476,7 @@ export const persistUnificationFinalization = async (
value, value,
owner: general.userId ?? null, owner: general.userId ?? null,
aux, 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({ await transaction.emperor.create({
data: { data: {
serverId, serverId,
phase: `${serverName}${serverCount}`, phase: `${serverName}${serverIdx}`,
nationCount, nationCount,
nationName: statisticNationNames || archivedNationNames.join(', '), nationName: statisticNationNames || archivedNationNames.join(', '),
nationHist: formatHistogram(statistics.maxNationHist), nationHist: formatHistogram(statistics.maxNationHist),
@@ -62,6 +62,7 @@ import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGener
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js'; import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js'; import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null; let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -162,7 +163,8 @@ const resolveCommandAcceptedAt = async (
| 'selectPoolReserve' | 'selectPoolReserve'
| 'selectPoolCreate' | 'selectPoolCreate'
| 'selectPoolReselect' | 'selectPoolReselect'
| 'adjustGeneralIcon'; | 'adjustGeneralIcon'
| 'inheritanceAction';
} }
> >
): Promise<Date> => { ): Promise<Date> => {
@@ -802,6 +804,20 @@ async function handlePatchGeneral(
return { type: 'patchGeneral', ok: true, generalId: command.generalId }; 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( async function handleAdjustGeneralIcon(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
@@ -2936,6 +2952,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>), handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) => patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>), handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
inheritanceAction: (command) =>
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
adjustGeneralIcon: (command) => adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>), handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
joinCreateGeneral: (command) => joinCreateGeneral: (command) =>
@@ -399,4 +399,153 @@ describe('legacy general turn lifecycle', () => {
} }
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired'); 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; import { asRecord, normalizeArchivedGeneral, RANK_DATA_TYPES, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.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 { 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 databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const generalIds = [990_001, 990_002, 990_003]; 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']; 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 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 => ({ const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
id, id,
@@ -69,12 +93,30 @@ integration('general turn lifecycle persistence', () => {
const cleanup = async () => { const cleanup = async () => {
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } }); await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalAccessLog.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.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); await db.unificationFinalization.deleteMany({ where: { serverId: sameFlushServerId } });
await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); await db.emperor.deleteMany({ where: { serverId: sameFlushServerId } });
await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } }); 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.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
await db.inheritancePoint.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 () => { beforeAll(async () => {
@@ -189,8 +231,7 @@ integration('general turn lifecycle persistence', () => {
}); });
const archivedData = asRecord(archived.data); const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']); expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique'); expect(asRecord(archivedData.meta)).toMatchObject({ inheritRandomUnique: true });
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot; const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot;
expect(snapshot).toMatchObject({ expect(snapshot).toMatchObject({
mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 }, mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 },
@@ -220,7 +261,19 @@ integration('general turn lifecycle persistence', () => {
select: { text: true }, select: { text: true },
}) })
).map(({ text }) => text) ).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 () => { 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 }, 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) => await db.$transaction((tx) =>
persistGeneralLifecycleEvents( persistGeneralLifecycleEvents(
tx, tx,
[event(general, 'retired')], [retirementEvent],
{ serverId, season: 1, scenarioId: 2, isUnited: 0 }, { 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 () => { it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
const general = makeGeneral(generalIds[2]!, userIds[2]!, { const general = makeGeneral(generalIds[2]!, userIds[2]!, {
npcState: 1, npcState: 1,
@@ -340,4 +925,429 @@ integration('general turn lifecycle persistence', () => {
}) })
).toBe(0); ).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>●</>첫 기록'], history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] }, records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true }, 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 () => { it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => {
const general = archivedGeneral(); 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 = { const prisma = {
generalAccessLog: { generalAccessLog: {
updateMany: vi.fn(async () => ({ count: 1 })), updateMany: vi.fn(async () => ({ count: 1 })),
}, },
rankData: { rankData: {
findMany: vi.fn(async () => [{ type: 'inherit_earned', value: 4_321 }]), findMany: vi.fn(async () => [
updateMany: vi.fn(async () => ({ count: 1 })), { type: 'warnum', value: 10 },
{ type: 'inherit_earned', value: 123 },
]),
upsert: rankUpsert,
}, },
nation: { nation: {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
}, },
gameHistory: { gameHistory: {
count: vi.fn(async () => 2), count: vi.fn(async () => 99),
}, },
hallOfFame: { hallOfFame: {
findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []),
createMany: hallCreateMany, create: hallCreate,
update: vi.fn(async () => undefined), 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; } as unknown as GamePrisma.TransactionClient;
const event: GeneralLifecycleEvent = { const event: GeneralLifecycleEvent = {
generalId: general.id, generalId: general.id,
outcome: 'retired', outcome: 'retired',
before: general, before: general,
after: general, after: postRetirement,
isUnitedAtEvent: 0,
year: 200, year: 200,
month: 1, month: 1,
}; };
@@ -139,13 +174,13 @@ describe('general lifecycle archive history', () => {
await persistGeneralLifecycleEvents( await persistGeneralLifecycleEvents(
prisma, prisma,
[event], [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({ expect(hallCreate).toHaveBeenCalledWith({
data: [ data: expect.objectContaining({
expect.objectContaining({
serverId: 'hall-fixture', serverId: 'hall-fixture',
season: 4, season: 4,
scenario: 22, scenario: 22,
@@ -153,12 +188,25 @@ describe('general lifecycle archive history', () => {
type: 'inherit_earned', type: 'inherit_earned',
value: 4_321, value: 4_321,
}), }),
],
skipDuplicates: true,
}); });
expect(prisma.rankData.updateMany).toHaveBeenCalledWith({ expect(hallCreate).toHaveBeenCalledWith({
where: { generalId: general.id }, data: expect.objectContaining({ type: 'warnum', value: 11 }),
data: { value: 0 }, });
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(1)?.role.items.horse).toBe(uniqueHorse.key);
expect(world.getGeneralById(2)?.role.items.horse).toBeNull(); expect(world.getGeneralById(2)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ 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.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
expect(world.peekDirtyState().logs).toEqual( expect(world.peekDirtyState().logs).toEqual(
@@ -291,7 +291,7 @@ describe('UpdateNationLevel monthly action', () => {
meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 }, meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 },
}); });
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ 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); 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.getGeneralById(1)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false); expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ 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); expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
}); });
@@ -84,6 +84,7 @@ describe('durable read-model change journal mapping', () => {
lifecycleEvents: [], lifecycleEvents: [],
pendingNeutralAuctions: [], pendingNeutralAuctions: [],
inheritancePointAdjustments: [], inheritancePointAdjustments: [],
pendingInheritanceLogs: [],
pendingNationBettingOpens: [], pendingNationBettingOpens: [],
pendingNationBettingFinishes: [], pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [], pendingYearbookSnapshots: [],
@@ -35,6 +35,7 @@ const buildWorld = (): InMemoryTurnWorld => {
rank_warnum: 4, rank_warnum: 4,
firenum: 2, firenum: 2,
dex1: 100, dex1: 100,
event100_allstar: { granted: { dex1: 40 } },
}, },
officerLevel: 12, officerLevel: 12,
experience: 10, experience: 10,
@@ -141,18 +142,12 @@ const input = {
describe('persistUnificationFinalization', () => { describe('persistUnificationFinalization', () => {
it.each([ it.each([
{ label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 }, { label: 'missing unifier row', rows: [], key: 'unifier' as const, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 }, { label: 'missing resettable row', rows: [], key: 'tournament' as const, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 }, { label: 'zero row', rows: [['unifier', 0] as const], key: 'unifier' as const, expected: 0 },
])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => { { label: 'positive row', rows: [['unifier', 7] as const], key: 'unifier' as const, expected: 7 },
expect( ])('uses only transaction-visible inheritance storage for $label', ({ rows, key, expected }) => {
resolveStoredInheritancePoint( expect(resolveStoredInheritancePoint(new Map<string, number>(rows), key)).toBe(expected);
new Map<string, number>(rows),
{ inheritancePoints: { unifier: memoryValue } },
'unifier',
2_000
)
).toBe(expected);
}); });
it('does not write when the transaction-scoped generation was already applied', async () => { 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 }, gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
hallOfFame: { hallOfFame: {
findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]),
create: hallCreate, create: hallCreate,
update: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}),
}, },
@@ -274,6 +269,11 @@ describe('persistUnificationFinalization', () => {
data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }), 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(gameHistoryUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) }) expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
); );
@@ -186,9 +186,13 @@ describe('unique lottery on general commands', () => {
expect(dedicationIndex).toBeLessThan(uniqueIndex); 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 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 = { const snapshot: TurnWorldSnapshot = {
generals: generals as any, generals: generals as any,
cities: [ cities: [
@@ -267,6 +271,7 @@ describe('unique lottery on general commands', () => {
uniqueTrialCoef: 10, uniqueTrialCoef: 10,
maxUniqueTrialProb: 10, maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0, minMonthToAllowInheritItem: 0,
inheritItemRandomPoint: 3_000,
}, },
environment: { mapName: 'test_map', unitSet: 'default' }, 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?.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((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 포인트 반환',
},
]);
}); });
}); });
+52
View File
@@ -79,6 +79,33 @@ export interface TurnDaemonSelectPoolReservation {
candidates: TurnDaemonSelectPoolCandidate[]; candidates: TurnDaemonSelectPoolCandidate[];
} }
export type TurnDaemonInheritanceAction =
| {
action: 'buyHiddenBuff';
buffType:
| 'warAvoidRatio'
| 'warCriticalRatio'
| 'warMagicTrialProb'
| 'domesticSuccessProb'
| 'domesticFailProb'
| 'warAvoidRatioOppose'
| 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose';
level: number;
}
| { action: 'setNextSpecialWar'; specialKey: string }
| { action: 'resetSpecialWar' }
| { action: 'resetTurnTime' }
| {
action: 'resetStat';
leadership: number;
strength: number;
intel: number;
inheritBonusStat?: [number, number, number];
}
| { action: 'buyRandomUnique' }
| { action: 'checkOwner'; targetGeneralId: number };
export type TurnDaemonCommand = export type TurnDaemonCommand =
| { | {
type: 'run'; type: 'run';
@@ -266,6 +293,12 @@ export type TurnDaemonCommand =
specialWar?: string | null; specialWar?: string | null;
}; };
} }
| {
type: 'inheritanceAction';
requestId?: string;
userId: string;
input: TurnDaemonInheritanceAction;
}
| { | {
type: 'adjustGeneralIcon'; type: 'adjustGeneralIcon';
requestId?: string; requestId?: string;
@@ -635,6 +668,25 @@ export type TurnDaemonCommandResult =
generalId: number; generalId: number;
reason: string; reason: string;
} }
| {
type: 'inheritanceAction';
ok: true;
action: TurnDaemonInheritanceAction['action'];
generalId: number;
remainPoint: number;
nextTurnTimeBase?: number;
nextTurnTimeLabel?: string;
stats?: { leadership: number; strength: number; intel: number };
ownerName?: string;
targetName?: string;
}
| {
type: 'inheritanceAction';
ok: false;
action: TurnDaemonInheritanceAction['action'];
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'INTERNAL_SERVER_ERROR';
reason: string;
}
| { | {
type: 'adjustGeneralIcon'; type: 'adjustGeneralIcon';
ok: true; ok: true;
@@ -22,6 +22,9 @@ const ACTION_NAME = '은퇴';
const ACTION_KEY = 'che_은퇴'; const ACTION_KEY = 'che_은퇴';
const REQ_AGE = 60; const REQ_AGE = 60;
const hasPendingRandomUnique = (value: unknown): boolean =>
value === true || value === 1 || (typeof value === 'string' && (value === '1' || value.toLowerCase() === 'true'));
const reqGeneralValue = (): Constraint => ({ const reqGeneralValue = (): Constraint => ({
name: 'reqGeneralValue', name: 'reqGeneralValue',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -43,18 +46,6 @@ export class ActionResolver<
const general = context.general; const general = context.general;
const effects: GeneralActionEffect<TriggerState>[] = []; const effects: GeneralActionEffect<TriggerState>[] = [];
const nextMeta = { ...general.meta };
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
nextMeta[key] = Math.round(value * 0.5);
}
delete nextMeta.specAge;
delete nextMeta.specAge2;
nextMeta.specage = 0;
nextMeta.specage2 = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
nextMeta[rankDataMetaKey(type)] = 0;
}
const josaYi = JosaUtil.pick(general.name, '이'); const josaYi = JosaUtil.pick(general.name, '이');
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, { context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
@@ -75,7 +66,32 @@ export class ActionResolver<
format: LogFormat.MONTH, format: LogFormat.MONTH,
}); });
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); const hadPendingRandomUnique = hasPendingRandomUnique(general.meta.inheritRandomUnique);
const acquiredUnique = tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const refundedPendingRandomUnique =
hadPendingRandomUnique && !acquiredUnique && !hasPendingRandomUnique(general.meta.inheritRandomUnique);
const postLotterySpentDynamic = general.meta.inherit_spent_dyn;
// The lottery can consume a pending inheritance reservation and mutate meta.
// Build the reborn projection afterwards so the consumed flag is not restored
// by the action patch while still applying the retirement resets atomically.
const nextMeta = { ...general.meta };
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
nextMeta[key] = Math.round(value * 0.5);
}
delete nextMeta.specAge;
delete nextMeta.specAge2;
nextMeta.specage = 0;
nextMeta.specage2 = 0;
nextMeta.inherit_lived_month = 0;
nextMeta.inherit_active_action = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
nextMeta[rankDataMetaKey(type)] = 0;
}
if (refundedPendingRandomUnique && typeof postLotterySpentDynamic === 'number') {
nextMeta.inherit_spent_dyn = postLotterySpentDynamic;
}
effects.push( effects.push(
createGeneralPatchEffect( createGeneralPatchEffect(
@@ -1,3 +1,5 @@
import { readCentennialRecordableDexterity, type CentennialDexKey } from '../scenario/centennialAllStar.js';
export const LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975; export const LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975;
export const ALL_MERGED_INHERITANCE_KEYS = [ export const ALL_MERGED_INHERITANCE_KEYS = [
@@ -38,9 +40,6 @@ export const REBIRTH_INHERITANCE_COEFFICIENTS: Readonly<Record<MergedInheritance
betting: 1, betting: 1,
}; };
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const readNumber = (source: Record<string, unknown>, ...keys: string[]): number => { const readNumber = (source: Record<string, unknown>, ...keys: string[]): number => {
for (const key of keys) { for (const key of keys) {
const value = source[key]; const value = source[key];
@@ -59,17 +58,10 @@ const readStoredPoint = (
storedOverride?: number storedOverride?: number
): number => storedOverride ?? general.inheritancePoints?.[key] ?? 0; ): number => storedOverride ?? general.inheritancePoints?.[key] ?? 0;
const readRecordableDexterity = (general: InheritancePointGeneral, key: string): number => {
const value = readNumber(general.meta, key);
const allStar = asRecord(general.meta.event100_allstar);
const granted = readNumber(asRecord(allStar.granted), key);
return Math.max(0, value - Math.min(Math.max(0, value), Math.max(0, granted)));
};
export const computeDexInheritancePoint = (general: InheritancePointGeneral): number => { export const computeDexInheritancePoint = (general: InheritancePointGeneral): number => {
let totalDexterity = 0; let totalDexterity = 0;
for (let index = 1; index <= 5; index += 1) { for (let index = 1; index <= 5; index += 1) {
let dexterity = readRecordableDexterity(general, `dex${index}`); let dexterity = readCentennialRecordableDexterity(general.meta, `dex${index}` as CentennialDexKey);
if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) { if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) {
totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3; totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3;
dexterity = LEGACY_DEX_INHERITANCE_LIMIT; dexterity = LEGACY_DEX_INHERITANCE_LIMIT;
+18 -7
View File
@@ -44,6 +44,12 @@ export type UniqueLotteryInput = {
inheritRandomUnique?: boolean; inheritRandomUnique?: boolean;
}; };
export type UniqueLotteryOutcome =
| { status: 'NO_SLOT' }
| { status: 'ROLL_FAILED' }
| { status: 'NO_SUPPLY' }
| { status: 'ACQUIRED'; itemKey: string };
const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [ const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [
[-1, 1], [-1, 1],
[3, 2], [3, 2],
@@ -183,7 +189,7 @@ export const buildGenericUniqueSeed = (
export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string => export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string =>
serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId); serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId);
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { export const rollUniqueLotteryDetailed = (input: UniqueLotteryInput): UniqueLotteryOutcome => {
const { const {
rng, rng,
config, config,
@@ -203,13 +209,13 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
const resolvedAcquireType = acquireType ?? '아이템'; const resolvedAcquireType = acquireType ?? '아이템';
if (userCount <= 0) { if (userCount <= 0) {
return null; return { status: 'ROLL_FAILED' };
} }
const itemTypes = Object.keys(config.allItems); const itemTypes = Object.keys(config.allItems);
const itemTypeCnt = itemTypes.length; const itemTypeCnt = itemTypes.length;
if (itemTypeCnt <= 0) { if (itemTypeCnt <= 0) {
return null; return { status: 'NO_SLOT' };
} }
const relYear = currentYear - startYear; const relYear = currentYear - startYear;
@@ -246,7 +252,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
} }
if (trialCnt <= 0 || maxCnt <= 0) { if (trialCnt <= 0 || maxCnt <= 0) {
return null; return { status: 'NO_SLOT' };
} }
const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth); const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth);
@@ -289,7 +295,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
} }
if (!success) { if (!success) {
return null; return { status: 'ROLL_FAILED' };
} }
const availableUnique: Array<[string, number]> = []; const availableUnique: Array<[string, number]> = [];
@@ -315,10 +321,15 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
} }
if (availableUnique.length === 0) { if (availableUnique.length === 0) {
return null; return { status: 'NO_SUPPLY' };
} }
return rng.choiceUsingWeightPair(availableUnique); return { status: 'ACQUIRED', itemKey: rng.choiceUsingWeightPair(availableUnique) };
};
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
const outcome = rollUniqueLotteryDetailed(input);
return outcome.status === 'ACQUIRED' ? outcome.itemKey : null;
}; };
const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>( const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
@@ -20,7 +20,7 @@ const STAT_KEYS = ['leadership', 'strength', 'intel'] as const;
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const; const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
type CentennialStatKey = (typeof STAT_KEYS)[number]; type CentennialStatKey = (typeof STAT_KEYS)[number];
type CentennialDexKey = (typeof DEX_KEYS)[number]; export type CentennialDexKey = (typeof DEX_KEYS)[number];
export interface CentennialAllStarTarget { export interface CentennialAllStarTarget {
uniqueName: string; uniqueName: string;
@@ -670,3 +670,14 @@ export const reconcileCentennialDexConversion = (
export const centennialRecordableValue = (current: number, granted: number): number => export const centennialRecordableValue = (current: number, granted: number): number =>
Math.max(0, current - Math.max(0, granted)); Math.max(0, current - Math.max(0, granted));
/**
* Ref CentennialAllStarGrowthService::recordableRawValue. Event-provided
* mastery is useful during the season, but must not enter permanent ranking,
* Hall of Fame, or inheritance records.
*/
export const readCentennialRecordableDexterity = (meta: Record<string, unknown>, key: CentennialDexKey): number => {
const current = asNumber(meta[key], 0);
const granted = asNumber(asRecord(asRecord(meta[CENTENNIAL_ALL_STAR_AUX_KEY]).granted)[key], 0);
return centennialRecordableValue(current, granted);
};
+46
View File
@@ -10,6 +10,7 @@ import {
countOccupiedUniqueItems, countOccupiedUniqueItems,
resolveUniqueConfig, resolveUniqueConfig,
rollUniqueLottery, rollUniqueLottery,
rollUniqueLotteryDetailed,
} from '../src/rewards/uniqueLottery.js'; } from '../src/rewards/uniqueLottery.js';
const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({ const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({
@@ -118,6 +119,51 @@ describe('unique lottery', () => {
expect(result).toBe('itemB'); expect(result).toBe('itemB');
}); });
it('distinguishes no slot, a failed roll, and exhausted supply', () => {
const itemRegistry = buildRegistry();
const base = {
itemRegistry,
scenarioId: 200,
userCount: 1,
currentYear: 200,
currentMonth: 1,
startYear: 180,
initYear: 180,
initMonth: 1,
};
expect(
rollUniqueLotteryDetailed({
...base,
rng: new RandUtil(LiteHashDRBG.build('no-slot')),
config: buildConfig(),
generalItems: { horse: null, weapon: 'itemB', book: null, item: null },
occupiedUniqueCounts: new Map([['itemB', 1]]),
})
).toEqual({ status: 'NO_SLOT' });
expect(
rollUniqueLotteryDetailed({
...base,
rng: new RandUtil(LiteHashDRBG.build('roll-failed')),
config: buildConfig({ uniqueTrialCoef: 0, maxUniqueTrialProb: 0 }),
generalItems: { horse: null, weapon: null, book: null, item: null },
occupiedUniqueCounts: new Map(),
})
).toEqual({ status: 'ROLL_FAILED' });
expect(
rollUniqueLotteryDetailed({
...base,
rng: new RandUtil(LiteHashDRBG.build('no-supply')),
config: buildConfig(),
generalItems: { horse: null, weapon: null, book: null, item: null },
occupiedUniqueCounts: new Map([['itemB', 1]]),
acquireType: '건국',
})
).toEqual({ status: 'NO_SUPPLY' });
});
it('counts only non-buyable equipped items', () => { it('counts only non-buyable equipped items', () => {
const itemRegistry = new Map<string, ItemModule>([ const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)], ['uniqueItem', buildItem('uniqueItem', 'weapon', false)],