merge: 전투·명예·유산 교차기수 패리티를 main에 반영한다

This commit is contained in:
2026-08-24 04:10:32 +00:00
54 changed files with 5659 additions and 1485 deletions
+47 -575
View File
@@ -2,36 +2,27 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
import { asNumber, asRecord, parseJson, LiteHashDRBG, type TurnDaemonInheritanceAction } from '@sammo-ts/common';
import {
ItemLoader,
isItemKey,
loadWarTraitModules,
sendMessage,
WarTraitLoader,
WAR_TRAIT_KEYS,
isWarTraitKey,
isCentennialStatResetAllowed,
} from '@sammo-ts/logic';
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
import type { InheritBuffType, ItemSlot } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import {
appendInheritanceLog,
buildResetCost,
computeInheritanceItems,
readInheritancePoint,
readUserStateMeta,
resolveInheritConstants,
setInheritancePoint,
sumInheritanceItems,
writeUserStateMeta,
} from '../../services/inheritance.js';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { openAuctionWithDaemon } from '../../auction/open.js';
import { buildTargetFromGeneral } from '../../messages/targets.js';
import { insertMessage } from '../../messages/store.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio',
@@ -46,17 +37,6 @@ const BUFF_KEYS: InheritBuffType[] = [
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
};
const POSTGRES_INTEGER_MAX = 2_147_483_647;
const parseBuffRecord = (raw: unknown): Record<string, number> => {
@@ -74,13 +54,6 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
return result;
};
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
const readStringList = (raw: unknown): string[] => {
const parsed = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
};
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
@@ -131,31 +104,24 @@ const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<
};
};
const patchGeneral = async (
ctx: Pick<GameApiContext, 'turnDaemon'>,
generalId: number,
patch: {
meta?: Record<string, unknown>;
turnTime?: string;
stats?: {
leadership?: number;
strength?: number;
intelligence?: number;
};
specialWar?: string | null;
}
): Promise<void> => {
const requestInheritanceAction = async (
ctx: Pick<GameApiContext, 'turnDaemon' | 'requestId'>,
userId: string,
input: TurnDaemonInheritanceAction
) => {
const result = await ctx.turnDaemon.requestCommand({
type: 'patchGeneral',
generalId,
patch,
type: 'inheritanceAction',
userId,
input,
...(ctx.requestId ? { requestId: `${ctx.requestId}:inherit.${input.action}:engine:0:inheritanceAction` } : {}),
});
if (!result || result.type !== 'patchGeneral') {
if (!result || result.type !== 'inheritanceAction') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
throw new TRPCError({ code: result.code, message: result.reason });
}
return result;
};
const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
@@ -189,54 +155,6 @@ export const resolveResetTurnTimeBase = (options: {
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
};
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
const raw = meta.season;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.floor(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return null;
};
const readResetSeasons = (meta: Record<string, unknown>): number[] => {
if (!Array.isArray(meta.last_stat_reset)) {
return [];
}
return meta.last_stat_reset
.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null))
.filter((value): value is number => value !== null);
};
const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => {
const total = weights.reduce((acc, value) => acc + value, 0);
if (total <= 0) {
return 0;
}
let cursor = rng.nextFloat1() * total;
for (let i = 0; i < weights.length; i += 1) {
cursor -= weights[i] ?? 0;
if (cursor <= 0) {
return i;
}
}
return weights.length - 1;
};
const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => {
const bonusCount = rng.nextInt(2) + 3;
const bonus = [0, 0, 0] as [number, number, number];
for (let i = 0; i < bonusCount; i += 1) {
const index = pickWeightedIndex(rng, baseStats);
bonus[index] += 1;
}
return bonus;
};
export const inheritRouter = router({
getStatus: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
@@ -380,7 +298,7 @@ export const inheritRouter = router({
});
return logs;
}),
buyHiddenBuff: authedProcedure
buyHiddenBuff: engineAuthedProcedure
.input(
z.object({
type: z.enum(BUFF_KEYS),
@@ -393,56 +311,14 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, meta: true },
const result = await requestInheritanceAction(ctx, userId, {
action: 'buyHiddenBuff',
buffType: input.type,
level: input.level,
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
const prevLevel = readBuffLevel(buff, input.type);
if (input.level === prevLevel) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
}
if (input.level < prevLevel) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 더 높은 등급을 구입했습니다.' });
}
const cost = inheritConst.inheritBuffPoints[input.level] - inheritConst.inheritBuffPoints[prevLevel];
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const buffText = BUFF_LABELS[input.type];
const moreText = prevLevel > 0 ? '추가' : '';
buff[input.type] = input.level;
await patchGeneral(ctx, general.id, {
meta: {
...asRecord(general.meta),
inheritBuff: serializeBuffRecord(buff),
},
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${cost} 포인트로 ${buffText} ${input.level} 단계 ${moreText}구입`
);
return { ok: true, remainPoint: currentPoint - cost };
return { ok: true, remainPoint: result.remainPoint };
}),
setNextSpecialWar: authedProcedure
setNextSpecialWar: engineAuthedProcedure
.input(
z.object({
specialKey: z.string(),
@@ -454,197 +330,32 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
if (!isWarTraitKey(input.specialKey)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '잘못된 전투 특기입니다.' });
}
const config = asRecord(worldState.config);
const constValues = asRecord(config.const);
const allowedSpecialWar = Array.isArray(constValues.availableSpecialWar)
? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string')
: [];
if (allowedSpecialWar.length > 0 && !allowedSpecialWar.includes(input.specialKey)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '허용되지 않은 전투 특기입니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
if (currentPoint < inheritConst.inheritSpecificSpecialPoint) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, meta: true, special2Code: true },
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
if (general.special2Code === input.specialKey) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 보유하고 있습니다.' });
}
const meta = asRecord(general.meta);
const reservedSpecial =
typeof meta.inheritSpecificSpecialWar === 'string' ? meta.inheritSpecificSpecialWar : null;
if (reservedSpecial === input.specialKey) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 예약하였습니다.' });
}
if (reservedSpecial) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
}
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
const warName = warModule?.name ?? input.specialKey;
await patchGeneral(ctx, general.id, {
meta: {
...meta,
inheritSpecificSpecialWar: input.specialKey,
},
});
await setInheritancePoint(
ctx.db,
userId,
'previous',
currentPoint - inheritConst.inheritSpecificSpecialPoint
);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${inheritConst.inheritSpecificSpecialPoint} 포인트로 다음 전투 특기로 ${warName} 지정`
);
await requestInheritanceAction(ctx, userId, { action: 'setNextSpecialWar', specialKey: input.specialKey });
return { ok: true };
}),
resetSpecialWar: authedProcedure.mutation(async ({ ctx }) => {
resetSpecialWar: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, special2Code: true, meta: true },
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
if (!general.special2Code || general.special2Code === 'None') {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 전투 특기가 공란입니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const currentLevel = asNumber(asRecord(general.meta).inheritResetSpecialWar, -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel);
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const meta = asRecord(general.meta);
const prevList = readStringList(meta.prev_types_special2);
prevList.push(general.special2Code);
await patchGeneral(ctx, general.id, {
specialWar: null,
meta: {
...meta,
inheritResetSpecialWar: nextLevel,
prev_types_special2: prevList,
},
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${cost} 포인트로 전투 특기 초기화`
);
await requestInheritanceAction(ctx, userId, { action: 'resetSpecialWar' });
return { ok: true };
}),
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
resetTurnTime: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, meta: true, turnTick: true },
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const currentLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel);
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const generalMeta = asRecord(general.meta);
const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0;
const seedTurnTime =
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
? rawSeedTurnTime
: typeof rawSeedTurnTime === 'bigint'
? Number(rawSeedTurnTime)
: 0;
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: 'inherit';
const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({
hiddenSeed,
userId,
previousTurnTimeBase: seedTurnTime,
tickSeconds: worldState.tickSeconds,
});
await patchGeneral(ctx, general.id, {
meta: {
...generalMeta,
inheritResetTurnTime: nextLevel,
nextTurnTimeBase,
},
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용`
);
return { ok: true, nextTurnTimeBase, nextTurnTimeLabel };
const result = await requestInheritanceAction(ctx, userId, { action: 'resetTurnTime' });
return {
ok: true,
nextTurnTimeBase: result.nextTurnTimeBase!,
nextTurnTimeLabel: result.nextTurnTimeLabel!,
};
}),
resetStat: authedProcedure
resetStat: engineAuthedProcedure
.input(
z.object({
leadership: z.number().int(),
@@ -658,191 +369,21 @@ export const inheritRouter = router({
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
const config = asRecord(worldState.config);
const statConfig = asRecord(config.stat);
const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel);
const statMin = asNumber(statConfig.min, 1);
const statMax = asNumber(statConfig.max, 999);
const total = input.leadership + input.strength + input.intel;
if (total !== statTotal) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`,
});
}
if (
input.leadership < statMin ||
input.strength < statMin ||
input.intel < statMin ||
input.leadership > statMax ||
input.strength > statMax ||
input.intel > statMax
) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '능력치 범위를 벗어났습니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const bonus = input.inheritBonusStat ?? [0, 0, 0];
const bonusSum = bonus.reduce((acc, value) => acc + value, 0);
if (bonus.some((value) => value < 0)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '보너스 능력치가 음수입니다. 다시 입력해주세요!',
});
}
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!',
});
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, npcState: true },
const result = await requestInheritanceAction(ctx, userId, {
action: 'resetStat',
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
...(input.inheritBonusStat ? { inheritBonusStat: input.inheritBonusStat } : {}),
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
if (general.npcState >= 2) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' });
}
if (!isCentennialStatResetAllowed(config)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
});
}
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const seasonValue = resolveSeasonValue(worldMeta);
if (seasonValue !== null) {
const userState = await readUserStateMeta(ctx.db, userId);
const resetSeasons = readResetSeasons(userState);
if (resetSeasons.includes(seasonValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '이번 시즌에 이미 능력치를 초기화하셨습니다.',
});
}
}
const finalBonus =
bonusSum === 0
? buildRandomBonus(
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
[input.leadership, input.strength, input.intel]
)
: (bonus as [number, number, number]);
const nextStats = {
leadership: input.leadership + finalBonus[0],
strength: input.strength + finalBonus[1],
intel: input.intel + finalBonus[2],
};
await patchGeneral(ctx, general.id, {
stats: {
leadership: nextStats.leadership,
strength: nextStats.strength,
intelligence: nextStats.intel,
},
});
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정`
);
if (bonusSum > 0) {
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
);
} else {
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
);
}
if (cost > 0) {
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
}
if (seasonValue !== null) {
const userState = await readUserStateMeta(ctx.db, userId);
const resetSeasons = readResetSeasons(userState);
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
await writeUserStateMeta(ctx.db, userId, {
...userState,
last_stat_reset: nextSeasons,
});
}
return { ok: true, stats: nextStats };
return { ok: true, stats: result.stats! };
}),
buyRandomUnique: authedProcedure.mutation(async ({ ctx }) => {
buyRandomUnique: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
if (currentPoint < inheritConst.inheritItemRandomPoint) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, meta: true },
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
const meta = asRecord(general.meta);
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
});
}
await patchGeneral(ctx, general.id, {
meta: {
...meta,
inheritRandomUnique: 1,
},
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritItemRandomPoint);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${inheritConst.inheritItemRandomPoint} 포인트로 랜덤 유니크 구입`
);
await requestInheritanceAction(ctx, userId, { action: 'buyRandomUnique' });
return { ok: true };
}),
openUniqueAuction: engineAuthedProcedure
@@ -885,7 +426,7 @@ export const inheritRouter = router({
);
return { ok: true, ...result };
}),
checkOwner: authedProcedure
checkOwner: engineAuthedProcedure
.input(
z.object({
targetGeneralId: z.number().int().positive(),
@@ -896,79 +437,10 @@ export const inheritRouter = router({
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await resolveWorld(ctx);
const worldMeta = asRecord(worldState.meta);
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
}
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
if (currentPoint < inheritConst.inheritCheckOwnerPoint) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const [general, target] = await Promise.all([
ctx.db.general.findFirst({ where: { userId } }),
ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }),
]);
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
if (!target || !target.userId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 장수가 존재하지 않습니다.' });
}
if (target.id === general.id) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
}
const rawOwnerName = asRecord(target.meta).ownerName;
const ownerName =
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인`
);
const [generalTarget, checkedTarget, gameTime] = await Promise.all([
buildTargetFromGeneral(ctx.db, general),
buildTargetFromGeneral(ctx.db, target),
loadCurrentGameTime(ctx.db),
]);
const systemTarget: MessageDraft['src'] = {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
};
const validUntil = new Date('9999-12-31T00:00:00.000Z');
const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise<void> => {
await sendMessage(
{
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
},
{
msgType: 'private',
src: systemTarget,
dest,
text,
time: gameTime.now,
validUntil,
option: {},
},
{ sendDestOnly: true }
);
ctx.changeJournal?.mark('messages.mailbox', dest.generalId);
};
await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`);
await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.');
return { ok: true, ownerName, targetName: target.name };
const result = await requestInheritanceAction(ctx, userId, {
action: 'checkOwner',
targetGeneralId: input.targetGeneralId,
});
return { ok: true, ownerName: result.ownerName!, targetName: result.targetName! };
}),
});
+16 -6
View File
@@ -5,6 +5,10 @@ import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/in
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
import {
@@ -182,11 +186,17 @@ export const rankingRouter = router({
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
},
],
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
...(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map(
(key, index) =>
[
[' 병 숙 련 도', '궁 병 숙 련 도', '기 병 숙 련 도', '귀 병 숙 련 도', '차 병 숙 련 도'][
index
]!,
'int',
(general: (typeof generals)[number]) =>
readCentennialRecordableDexterity(asRecord(general.meta), key as CentennialDexKey),
] as [string, 'int', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
),
[
'전 력 전 승 률',
'percent',
@@ -415,7 +425,7 @@ export const rankingRouter = router({
return Array.from(optionMap.values());
}
const rows = await ctx.db.gameHistory.findMany({
where: { status: 'COMPLETED' },
where: { status: { in: ['OPEN', 'COMPLETED'] } },
select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
});
@@ -1,68 +1,258 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { SystemClock } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
import {
createDatabaseTurnHooks,
DatabaseTurnDaemonCommandQueue,
EngineStateManager,
InMemoryTurnStateStore,
InMemoryTurnWorld,
loadTurnWorldFromDatabase,
TurnDaemonLifecycle,
type TurnGeneral,
type TurnWorldSnapshot,
type TurnWorldState,
} from '@sammo-ts/game-engine';
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const actorGeneralId = 8_701;
const checkedGeneralId = 8_702;
const actorNationId = 871;
const checkedNationId = 872;
const worldId = 992_320;
const actorId = 7_320;
const targetId = 7_321;
const actorNationId = 7_322;
const targetNationId = 7_323;
const actorUserId = 'inherit-owner-message-actor';
const checkedUserId = 'inherit-owner-message-checked';
const targetUserId = 'inherit-owner-message-target';
const requestId = 'integration:inherit-owner-message:success';
const engineRequestId = `${requestId}:inherit.checkOwner:engine:0:inheritanceAction`;
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const scenarioConfig: ScenarioConfig = {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: { inheritCheckOwnerPoint: 1_000 },
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta: ScenarioMeta = {
title: '소유자 확인 통합',
startYear: 200,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = { id: 'inherit-owner-message', name: scenarioMeta.title, cities: [] };
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
};
const general = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
id: actorId,
userId: actorUserId,
name: '확인장수',
nationId: actorNationId,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
turnTime: new Date('2026-08-19T00:10:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 0 },
inheritancePoints: { previous: 1_500 },
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
...overrides,
});
const actor = general({});
const target = general({
id: targetId,
userId: targetUserId,
name: '피확인장수',
nationId: targetNationId,
meta: { killturn: 24, owner_name: '피확인 계정' },
inheritancePoints: { previous: 0 },
});
const nation = (id: number, name: string, chiefGeneralId: number) => ({
id,
name,
color: id === actorNationId ? '#123456' : '#654321',
capitalCityId: null,
chiefGeneralId,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
});
const actorNation = nation(actorNationId, '확인국', actorId);
const targetNation = nation(targetNationId, '피확인국', targetId);
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:inherit-owner-message',
issuedAt: '2026-08-19T00:00:00.000Z',
expiresAt: '2026-08-20T00:00:00.000Z',
sessionId: 'inherit-owner-message-session',
user: {
id: actorUserId,
username: actorUserId,
displayName: '확인자 계정',
roles: ['user'],
},
user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] },
sanctions: {},
};
const hasMailboxChange = (payload: unknown): boolean => {
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
const changes = (payload as { changes?: unknown }).changes;
if (!Array.isArray(changes)) return false;
const mailboxes = new Set([actorGeneralId, checkedGeneralId]);
return changes.some(
(change) =>
Array.isArray(change) &&
change[0] === 'messages.mailbox' &&
typeof change[1] === 'number' &&
mailboxes.has(change[1])
);
};
const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
id: entry.id,
userId: entry.userId,
name: entry.name,
nationId: entry.nationId,
cityId: entry.cityId,
npcState: entry.npcState,
leadership: entry.stats.leadership,
strength: entry.stats.strength,
intel: entry.stats.intelligence,
turnTime: entry.turnTime,
meta: entry.meta as GamePrisma.InputJsonValue,
penalty: entry.penalty as GamePrisma.InputJsonValue,
});
integration('inherit owner lookup private messages', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
const buildContext = (requestId: string): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema');
if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'inherit-owner-message',
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.createMany({
data: [
{ id: actorNationId, name: actorNation.name, color: actorNation.color, level: 1 },
{ id: targetNationId, name: targetNation.name, color: targetNation.color, level: 1 },
],
});
await db.general.createMany({ data: [actor, target].map(toCreate) });
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 1_500 } });
await db.rankData.create({
data: { generalId: actorId, nationId: actorNationId, type: 'inherit_spent_dyn', value: 0 },
});
});
afterAll(async () => {
if (db) {
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
await db.worldState.deleteMany({ where: { id: worldId } });
}
await closeDb?.();
});
it('commits once and returns the durable result without double charging on the same API retry', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [actor, target],
cities: [],
nations: [actorNation, targetNation],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta,
map,
};
return {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (value) => world.restoreState(value),
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new SystemClock(),
controlQueue: queue,
commandResponder: queue,
getNextTickTime: () => new Date(Date.now() + 3_600_000),
stateStore: new InMemoryTurnStateStore(world),
processor: {
run: async () => {
throw new Error('scheduled turn must not run in inheritance API integration');
},
},
commandHandler: createTurnDaemonCommandHandler({ world }),
hooks: hooks.hooks,
stateManager,
},
{ profile: 'inherit-owner-message', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } }
);
const redisClient = { get: async () => null, set: async () => null };
const context: GameApiContext = {
requestId,
db,
redis: redisClient as unknown as RedisConnector['client'],
turnDaemon: new InMemoryTurnDaemonTransport(),
turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000),
battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
uploadDir: 'uploads',
@@ -72,157 +262,46 @@ integration('inherit owner lookup private messages', () => {
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
readModelOutbox: { wake: vi.fn() },
};
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
});
await db.nation.createMany({
data: [
{ id: actorNationId, name: '확인국', color: '#123456', level: 2 },
{ id: checkedNationId, name: '피확인국', color: '#654321', level: 3 },
],
});
await db.general.createMany({
data: [
{
id: actorGeneralId,
userId: actorUserId,
name: '확인장수',
nationId: actorNationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { ownerName: '확인자 계정' },
},
{
id: checkedGeneralId,
userId: checkedUserId,
name: '피확인장수',
nationId: checkedNationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { ownerName: '피확인 계정' },
},
],
});
await db.inheritancePoint.create({
data: { userId: actorUserId, key: 'previous', value: 1_500 },
});
const world = await db.worldState.create({
data: {
scenarioCode: 'inherit-owner-message',
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
config: { const: { inheritCheckOwnerPoint: 1_000 } },
meta: { isUnited: 0 },
},
});
worldStateId = world.id;
});
afterAll(async () => {
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id);
if (outboxIds.length > 0) {
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
let loop: Promise<void> | undefined;
try {
loop = lifecycle.start();
const caller = appRouter.createCaller(context);
const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' };
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
} finally {
await lifecycle.stop('inherit owner integration finished');
await loop;
await hooks.close();
}
await db.inputEvent.deleteMany({ where: { actorUserId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
});
await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
});
it('commits the point charge, log, and both Ref-compatible private messages', async () => {
const requestId = 'integration:inherit-owner-message:success';
await expect(
appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId })
).resolves.toEqual({
ok: true,
ownerName: '피확인 계정',
targetName: '피확인장수',
});
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
})
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } })
).resolves.toMatchObject({ value: 500 });
await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([
expect.objectContaining({
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
}),
]);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } },
orderBy: { mailbox: 'asc' },
});
expect(messages).toHaveLength(2);
expect(
messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message }))
).toEqual([
{
mailbox: actorGeneralId,
type: 'private',
src: 0,
dest: actorGeneralId,
message: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }),
text: '피확인장수의 소유자는 피확인 계정 입니다.',
}),
},
{
mailbox: checkedGeneralId,
type: 'private',
src: 0,
dest: checkedGeneralId,
message: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }),
text: '소유자명이 누군가에 의해 확인되었습니다.',
}),
},
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } })
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId });
await expect(
db.readModelRevision.findMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
orderBy: { entityId: 'asc' },
db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } },
})
).resolves.toEqual([
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }),
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }),
).resolves.toMatchObject({ value: 1_000 });
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorId, targetId] } },
orderBy: { id: 'asc' },
select: { mailbox: true, message: true },
});
expect(messages.map(({ mailbox, message }) => [mailbox, (message as { text: string }).text])).toEqual([
[actorId, '피확인장수의 소유자는 피확인 계정 입니다.'],
[targetId, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
});
await expect(db.inputEvent.findMany({ where: { requestId: engineRequestId } })).resolves.toEqual([
expect.objectContaining({ status: 'SUCCEEDED', attempts: 1, actorUserId }),
]);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === actorId)).toMatchObject({
meta: { inherit_spent_dyn: 1_000 },
inheritancePoints: { previous: 500 },
});
}, 30_000);
});
+158 -133
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import { ChangeJournal, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import type { MessagePayload } from '@sammo-ts/logic';
@@ -111,6 +111,7 @@ const buildContext = (options: {
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -118,11 +119,19 @@ const buildContext = (options: {
options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
type: command.type,
ok: true,
generalId: command.generalId,
}));
const requestCommand = vi.fn(async (command: TurnDaemonCommand): Promise<TurnDaemonCommandResult> => {
if (options.daemonResult) return options.daemonResult;
if (command.type === 'inheritanceAction') {
return {
type: 'inheritanceAction',
ok: true,
action: command.input.action,
generalId: general?.id ?? 7,
remainPoint: options.inheritancePoint ?? 10_000,
};
}
return { type: 'patchGeneral', ok: true, generalId: 7 };
});
const pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
@@ -272,10 +281,17 @@ describe('inherit router actor and permission boundaries', () => {
});
});
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' },
inheritancePoint: 0,
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'resetStat',
code: 'BAD_REQUEST',
reason: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
},
});
const caller = appRouter.createCaller(fixture.context);
@@ -291,7 +307,17 @@ describe('inherit router actor and permission boundaries', () => {
code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-1',
input: {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
},
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
@@ -429,10 +455,17 @@ describe('inherit router actor and permission boundaries', () => {
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
});
it('does not dispatch or charge when the authenticated user owns no general', async () => {
it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'buyHiddenBuff',
code: 'PRECONDITION_FAILED',
reason: '장수가 존재하지 않습니다.',
},
});
await expect(
@@ -444,12 +477,25 @@ describe('inherit router actor and permission boundaries', () => {
code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-2',
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
});
it('mutates only the authenticated user general and inheritance balance', async () => {
const fixture = buildContext({ inheritancePoint: 1000 });
const fixture = buildContext({
inheritancePoint: 1000,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'buyHiddenBuff',
generalId: 7,
remainPoint: 800,
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
@@ -458,29 +504,26 @@ describe('inherit router actor and permission boundaries', () => {
})
).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'patchGeneral',
generalId: 7,
patch: expect.objectContaining({
meta: expect.objectContaining({
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
const fixture = buildContext({
inheritancePoint: 5_000,
configConst: { availableSpecialWar: ['che_의술'] },
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'setNextSpecialWar',
generalId: 7,
remainPoint: 1_000,
},
});
await expect(
@@ -488,19 +531,12 @@ describe('inherit router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '4000 포인트로 다음 전투 특기로 의술 지정',
},
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('does not dispatch or charge when a different war trait is already reserved', async () => {
@@ -508,12 +544,19 @@ describe('inherit router actor and permission boundaries', () => {
inheritancePoint: 5_000,
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
configConst: { availableSpecialWar: ['che_의술'] },
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'setNextSpecialWar',
code: 'BAD_REQUEST',
reason: '이미 예약한 특기가 있습니다.',
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledOnce();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
@@ -522,41 +565,44 @@ describe('inherit router actor and permission boundaries', () => {
const fixture = buildContext({
inheritancePoint: 2_000,
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'resetSpecialWar',
generalId: 7,
remainPoint: 1_000,
},
});
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: {
specialWar: null,
meta: {
prev_types_special2: ['che_돌격', 'che_선봉'],
marker: 3,
inheritResetSpecialWar: 0,
},
},
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 전투 특기 초기화',
},
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'resetSpecialWar' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('does not dispatch or charge when the current war trait is already blank', async () => {
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
const fixture = buildContext({
inheritancePoint: 2_000,
general: buildGeneral({ special2Code: 'None' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'resetSpecialWar',
code: 'BAD_REQUEST',
reason: '이미 전투 특기가 공란입니다.',
},
});
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '이미 전투 특기가 공란입니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledOnce();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
@@ -572,34 +618,41 @@ describe('inherit router actor and permission boundaries', () => {
previousTurnTimeBase: 123_456,
tickSeconds: worldState.tickSeconds,
});
fixture.requestCommand.mockResolvedValueOnce({
type: 'inheritanceAction',
ok: true,
action: 'resetTurnTime',
generalId: 7,
remainPoint: 1_000,
...expected,
});
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
ok: true,
...expected,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: {
meta: {
nextTurnTimeBase: expected.nextTurnTimeBase,
inheritResetTurnTime: 0,
},
},
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
},
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'resetTurnTime' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({ inheritancePoint: 1500 });
const fixture = buildContext({
inheritancePoint: 1500,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'checkOwner',
generalId: 7,
remainPoint: 500,
ownerName: '위유저',
targetName: '조조',
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
@@ -608,64 +661,27 @@ describe('inherit router actor and permission boundaries', () => {
ownerName: '위유저',
targetName: '조조',
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'checkOwner', targetGeneralId: 8 },
});
expect(fixture.messageRows).toHaveLength(2);
expect(fixture.messageRows).toEqual([
expect.objectContaining({
mailbox: 7,
type: 'private',
src: 0,
dest: 7,
payload: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: 7, generalName: '유비' }),
text: '조조의 소유자는 위유저 입니다.',
}),
}),
expect.objectContaining({
mailbox: 8,
type: 'private',
src: 0,
dest: 8,
payload: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: 8, generalName: '조조' }),
text: '소유자명이 누군가에 의해 확인되었습니다.',
}),
}),
]);
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
skipDuplicates: true,
});
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
skipDuplicates: true,
});
expect(fixture.changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 },
{ domain: 'messages.mailbox', entityId: 8 },
]);
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
expect(fixture.messageRows).toHaveLength(0);
});
it('does not charge or send messages when the owner lookup target is the actor', async () => {
const fixture = buildContext({
inheritancePoint: 1_500,
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'checkOwner',
code: 'BAD_REQUEST',
reason: '자신의 정보는 확인할 수 없습니다.',
},
});
await expect(
@@ -681,13 +697,22 @@ describe('inherit router actor and permission boundaries', () => {
});
it('does not charge or send messages when inheritance points are insufficient', async () => {
const fixture = buildContext({ inheritancePoint: 999 });
const fixture = buildContext({
inheritancePoint: 999,
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'checkOwner',
code: 'BAD_REQUEST',
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '유산 포인트가 부족합니다.',
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
+45 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { RANK_DATA_TYPES } from '@sammo-ts/common';
@@ -108,6 +108,7 @@ const buildContext = (options?: {
profileId?: string;
generals?: RankingGeneralRow[];
rankRows?: Array<{ generalId: number; type: string; value: number }>;
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
}): GameApiContext => {
const selectedGeneralRows = options?.generals ?? generalRows;
const selectedProfile = options?.profileId
@@ -198,10 +199,12 @@ const buildContext = (options?: {
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
},
gameHistory: {
findMany: async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
],
findMany:
options?.gameHistoryFindMany ??
(async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
]),
},
hallOfFame: {
findMany: async (args: { where: { type: string } }) =>
@@ -303,6 +306,28 @@ describe('ranking.getBestGeneral', () => {
});
});
it('excludes 100th-season event mastery from current Best General values', async () => {
const generals = [
{
...generalRows[0]!,
meta: {
...generalRows[0]!.meta,
dex1: 120,
event100_allstar: { granted: { dex1: 70 } },
} as unknown as RankingGeneralRow['meta'],
},
];
const result = await appRouter.createCaller(buildContext({ generals })).ranking.getBestGeneral({
view: 'user',
});
expect(result.sections.find((section) => section.title === '보 병 숙 련 도')?.entries[0]).toMatchObject({
id: 1,
value: 50,
printValue: '50',
});
});
it('returns positions one through ten for every populated ranking section', async () => {
const generals = Array.from({ length: 12 }, (_, index) => {
const id = index + 1;
@@ -386,6 +411,21 @@ describe('ranking hall of fame', () => {
]);
});
it('includes the active OPEN game while excluding ABANDONED options', async () => {
const findMany = vi.fn(async () => [
{ season: 4, scenario: 23, scenarioName: '현재 시나리오' },
{ season: 3, scenario: 22, scenarioName: '완료 시나리오' },
]);
const options = await appRouter
.createCaller(buildContext({ authenticated: false, gameHistoryFindMany: findMany }))
.ranking.getHallOfFameOptions();
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { status: { in: ['OPEN', 'COMPLETED'] } } })
);
expect(options.map((entry) => entry.season)).toEqual([4, 3]);
});
it('scopes previous-server options and rankings to the request profile', async () => {
const cheCaller = appRouter.createCaller(buildContext({ authenticated: false }));
await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
@@ -265,6 +265,42 @@ const zPatchGeneral = z.object({
}),
});
const zInheritanceAction = z
.object({
type: z.literal('inheritanceAction'),
requestId: z.string().min(1).optional(),
userId: z.string().min(1),
input: z.discriminatedUnion('action', [
z.object({
action: z.literal('buyHiddenBuff'),
buffType: z.enum([
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
]),
level: z.number().int().min(1).max(5),
}),
z.object({ action: z.literal('setNextSpecialWar'), specialKey: z.string().min(1) }),
z.object({ action: z.literal('resetSpecialWar') }),
z.object({ action: z.literal('resetTurnTime') }),
z.object({
action: z.literal('resetStat'),
leadership: z.number().int(),
strength: z.number().int(),
intel: z.number().int(),
inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(),
}),
z.object({ action: z.literal('buyRandomUnique') }),
z.object({ action: z.literal('checkOwner'), targetGeneralId: z.number().int().positive() }),
]),
})
.strict();
const zAdjustGeneralIcon = z
.object({
type: z.literal('adjustGeneralIcon'),
@@ -643,6 +679,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeInheritanceAction: CommandNormalizer<'inheritanceAction'> = (envelope) => {
const command = parseWith(zInheritanceAction, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
const command = parseWith(zAdjustGeneralIcon, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
@@ -764,6 +808,7 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral,
inheritanceAction: normalizeInheritanceAction,
adjustGeneralIcon: normalizeAdjustGeneralIcon,
joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral,
+61 -20
View File
@@ -44,7 +44,7 @@ import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
import { persistGeneralLifecycleEvents, type GeneralLifecycleArchiveLog } from './generalTurnLifecyclePersistence.js';
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
@@ -1094,6 +1094,7 @@ export const createDatabaseTurnHooks = async (
lifecycleEvents,
pendingNeutralAuctions,
inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
@@ -1137,6 +1138,20 @@ export const createDatabaseTurnHooks = async (
select: { id: true },
})
)?.id ?? 0;
const logContext = {
year: state.currentYear,
month: state.currentMonth,
at: state.lastTurnTime,
};
const pendingLogRows = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
const pendingLifecycleArchiveLogs: GeneralLifecycleArchiveLog[] = pendingLogRows.flatMap((entry) =>
entry.generalId !== null &&
(entry.category === LogCategory.HISTORY || entry.category === LogCategory.BATTLE_BRIEF)
? [{ generalId: entry.generalId, category: entry.category, text: entry.text }]
: []
);
// Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
@@ -1257,15 +1272,24 @@ export const createDatabaseTurnHooks = async (
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
if (inheritancePointAdjustments.length > 0) {
const persistInheritancePointAdjustments = async (
entries: typeof inheritancePointAdjustments
): Promise<void> => {
if (entries.length === 0) {
return;
}
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
for (const entry of inheritancePointAdjustments) {
for (const entry of entries) {
const groupKey = `${entry.userId}\u0000${entry.key}`;
const current = grouped.get(groupKey);
if (current) {
current.amount += entry.amount;
} else {
grouped.set(groupKey, { ...entry });
grouped.set(groupKey, {
userId: entry.userId,
key: entry.key,
amount: entry.amount,
});
}
}
for (const entry of grouped.values()) {
@@ -1275,14 +1299,41 @@ export const createDatabaseTurnHooks = async (
create: { userId: entry.userId, key: entry.key, value: entry.amount },
});
}
}
};
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
if (entries.length === 0) {
return;
}
await prisma.inheritanceLog.createMany({
data: entries.map((entry) => ({
userId: entry.userId,
year: entry.year,
month: entry.month,
text: entry.text,
})),
});
};
const beforeLifecycleAdjustments = inheritancePointAdjustments.filter(
(entry) => entry.phase !== 'after_lifecycle'
);
const afterLifecycleAdjustments = inheritancePointAdjustments.filter(
(entry) => entry.phase === 'after_lifecycle'
);
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents(
prisma,
lifecycleEvents,
meta,
asRecord(world.getScenarioConfig().const),
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
pendingLifecycleArchiveLogs
);
await persistInheritancePointAdjustments(afterLifecycleAdjustments);
await persistInheritanceLogs(afterLifecycleLogs);
if (accessScoreResetGeneralIds.length > 0) {
await prisma.generalAccessLog.updateMany({
@@ -1611,20 +1662,10 @@ export const createDatabaseTurnHooks = async (
await upsertRankRows(prisma, rankRows);
}
if (logs.length > 0) {
const logContext = {
year: state.currentYear,
month: state.currentMonth,
at: state.lastTurnTime,
};
const payload = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
if (payload.length > 0) {
await prisma.logEntry.createMany({
data: payload,
});
}
if (pendingLogRows.length > 0) {
await prisma.logEntry.createMany({
data: pendingLogRows,
});
}
for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot);
@@ -1,9 +1,23 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import {
asRecord,
HALL_OF_FAME_TYPES,
RANK_DATA_TYPES,
rankDataMetaKey,
resolveLegacyTextColor,
type HallOfFameType,
} from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildPersistedRankRows } from './rankData.js';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -24,12 +38,59 @@ const readWorldNumber = (record: Record<string, unknown>, key: string, fallback:
return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
};
type LifecycleRankValues = Map<string, number>;
export interface GeneralLifecycleArchiveLog {
generalId: number;
category: string;
text: string;
}
const loadLifecycleRankValues = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent
): Promise<LifecycleRankValues> => {
const persisted = await prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
});
const values = new Map(persisted.map((row) => [row.type, row.value]));
const snapshotMeta = asRecord(event.before.meta);
for (const row of buildPersistedRankRows(event.before)) {
if (
row.type === 'experience' ||
row.type === 'dedication' ||
Object.prototype.hasOwnProperty.call(snapshotMeta, rankDataMetaKey(row.type))
) {
values.set(row.type, row.value);
}
}
return values;
};
const persistPostRetirementRankValues = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent
): Promise<void> => {
if (!event.after) {
return;
}
for (const row of buildPersistedRankRows(event.after)) {
await prisma.rankData.upsert({
where: { generalId_type: { generalId: row.generalId, type: row.type } },
update: { nationId: row.nationId, value: row.value },
create: row,
});
}
};
const settleInheritance = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
isRebirth: boolean,
configConst: Record<string, unknown>
configConst: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>
): Promise<void> => {
const userId = event.before.userId;
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
@@ -53,29 +114,23 @@ const settleInheritance = async (
}
}
const [rows, rankRows] = await Promise.all([
prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const rows = await prisma.inheritancePoint.findMany({
where: { userId },
select: { key: true, value: true },
});
const points = new Map(rows.map((row) => [row.key, row.value]));
const previous = points.get('previous') ?? 0;
const randomUniqueRefund = meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
: 0;
const specificSpecialRefund = meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const randomUniqueRefund =
!isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0;
const specificSpecialRefund =
!isRebirth && meta.inheritSpecificSpecialWar
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0;
const refund = randomUniqueRefund + specificSpecialRefund;
const calculationMeta = {
...Object.fromEntries(rankValues),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
...meta,
...Object.fromEntries(rankRows.map((row) => [row.type, row.value])),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
};
const settlement = computeInheritanceSettlementBreakdown(
{
@@ -143,14 +198,22 @@ const settleInheritance = async (
},
});
}
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`,
},
});
for (const text of buildInheritanceSettlementLogTexts({
previous: previous + refund,
points: settlement.earned,
storedKeys: new Set([...points.keys(), ...(refund > 0 ? (['previous'] as const) : [])]),
total,
isRebirth,
})) {
await prisma.inheritanceLog.create({
data: {
userId,
year: event.year,
month: event.month,
text,
},
});
}
};
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
@@ -159,26 +222,23 @@ const settleHall = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>,
gameNow: Date
gameNow: Date,
rank: ReadonlyMap<string, number>
): Promise<void> => {
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
const isUnited =
event.isUnitedAtEvent ?? readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
if (isUnited !== 0) {
return;
}
const [ranks, nation, historyCount] = await Promise.all([
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
const [nation, serverIdx] = await Promise.all([
event.before.nationId > 0
? prisma.nation.findUnique({
where: { id: event.before.nationId },
select: { name: true, color: true },
})
: null,
prisma.gameHistory.count(),
resolveOfficialGameIndex(prisma, worldMeta),
]);
const rank = new Map(ranks.map((row) => [row.type, row.value]));
const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
const warnum = value('warnum');
const tt = value('ttw') + value('ttd') + value('ttl');
@@ -221,12 +281,13 @@ const settleHall = async (
picture: event.before.picture ?? null,
imgsvr: event.before.imageServer ?? 0,
serverID: serverId,
serverIdx: historyCount,
serverIdx,
scenarioName,
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
};
for (const type of HALL_OF_FAME_TYPES) {
const eventMeta = asRecord(event.before.meta);
let hallValue =
type === 'experience'
? event.before.experience
@@ -234,7 +295,9 @@ const settleHall = async (
? event.before.dedication
: type.endsWith('rate')
? (calc[type] ?? 0)
: value(type);
: type.startsWith('dex')
? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey)
: value(type);
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
if (type === 'ttrate' && tt < 50) continue;
if (type === 'tlrate' && tl < 50) continue;
@@ -244,72 +307,56 @@ const settleHall = async (
if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
hallValue = Number(hallValue);
const existing = await prisma.hallOfFame.findUnique({
where: {
serverId_type_generalNo: {
serverId,
type: type as HallOfFameType,
generalNo: event.generalId,
},
},
});
if (existing) {
if (hallValue > existing.value) {
await prisma.hallOfFame.update({
where: { id: existing.id },
data: { value: hallValue, aux: asJson(aux) },
});
}
continue;
}
await prisma.hallOfFame.createMany({
data: [
{
serverId,
season,
scenario,
generalNo: event.generalId,
type,
value: hallValue,
owner: event.before.userId ?? null,
aux: asJson(aux),
},
],
skipDuplicates: true,
await persistHallOfFameCandidate(prisma, {
serverId,
season,
scenario,
generalNo: event.generalId,
type: type as HallOfFameType,
value: hallValue,
owner: event.before.userId ?? null,
aux,
});
}
};
const archiveDeletedGeneral = async (
const archiveGeneral = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>
worldMeta: Record<string, unknown>,
rankValues: ReadonlyMap<string, number>,
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[]
): Promise<void> => {
const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const [recordRows, rankRows] = await Promise.all([
prisma.logEntry.findMany({
where: {
generalId: event.generalId,
scope: LogScope.GENERAL,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
orderBy: { id: 'desc' },
select: { category: true, text: true },
}),
prisma.rankData.findMany({
where: { generalId: event.generalId },
select: { type: true, value: true },
}),
]);
const recordRows = await prisma.logEntry.findMany({
where: {
generalId: event.generalId,
scope: LogScope.GENERAL,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
orderBy: { id: 'desc' },
select: { category: true, text: true },
});
const archivedMeta = {
...asRecord(event.before.meta),
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
};
delete archivedMeta.inheritRandomUnique;
delete archivedMeta.inheritSpecificSpecialWar;
const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text);
const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text);
const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId);
const history = [
...pendingGeneralLogs
.filter((row) => row.category === LogCategory.HISTORY)
.map((row) => row.text)
.reverse(),
...recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text),
];
const battleResults = [
...pendingGeneralLogs
.filter((row) => row.category === LogCategory.BATTLE_BRIEF)
.map((row) => row.text)
.reverse(),
...recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text),
];
const data = {
...event.before,
meta: archivedMeta,
@@ -345,7 +392,8 @@ export const persistGeneralLifecycleEvents = async (
events: GeneralLifecycleEvent[],
worldMeta: Record<string, unknown>,
configConst: Record<string, unknown>,
gameNow = new Date()
gameNow = new Date(),
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = []
): Promise<void> => {
if (events.length === 0) {
return;
@@ -359,17 +407,18 @@ export const persistGeneralLifecycleEvents = async (
if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
}
if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
continue;
}
const rankValues = await loadLifecycleRankValues(prisma, event);
if (event.outcome === 'deleted') {
await archiveDeletedGeneral(prisma, event, worldMeta);
await settleInheritance(prisma, event, worldMeta, false, configConst);
await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues);
await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs);
}
if (event.outcome === 'retired') {
await settleHall(prisma, event, worldMeta, gameNow);
await settleInheritance(prisma, event, worldMeta, true, configConst);
await prisma.rankData.updateMany({
where: { generalId: event.generalId },
data: { value: 0 },
});
await settleHall(prisma, event, worldMeta, gameNow, rankValues);
await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues);
await persistPostRetirementRankValues(prisma, event);
}
}
};
@@ -0,0 +1,83 @@
import type { HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const readInteger = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback;
};
/**
* `gameIdx` is fixed when RESET opens a game and deliberately excludes
* retained ABANDONED rows. Older fixtures may not carry it, so reconstruct the
* same sequence from completed games plus the configured first index.
*/
export const resolveOfficialGameIndex = async (
prisma: GamePrisma.TransactionClient,
worldMeta: Record<string, unknown>
): Promise<number> => {
if (worldMeta.gameIdx !== undefined) {
return readInteger(worldMeta.gameIdx, 0);
}
const completedGames = await prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
return completedGames + readInteger(worldMeta.firstGameIdx, 1);
};
export interface HallOfFameCandidate {
serverId: string;
season: number;
scenario: number;
generalNo: number;
type: HallOfFameType;
value: number;
owner: string | null;
aux: unknown;
}
/**
* Ref insertIgnore treats an owner record belonging to another general as a
* complete winner: it does not reassign that row even when the new value is
* higher. Only an existing row for the same general and scenario may replace
* value+aux, keeping every identity column unchanged. This avoids the former
* Core state where an old general number was combined with a new general's aux.
*/
export const persistHallOfFameCandidate = async (
prisma: GamePrisma.TransactionClient,
candidate: HallOfFameCandidate
): Promise<'CREATED' | 'UPDATED' | 'PRESERVED'> => {
const matches = await prisma.hallOfFame.findMany({
where: {
OR: [
{ serverId: candidate.serverId, type: candidate.type, generalNo: candidate.generalNo },
...(candidate.owner
? [{ serverId: candidate.serverId, type: candidate.type, owner: candidate.owner }]
: []),
],
},
});
const sameGeneral = matches.find((entry) => entry.generalNo === candidate.generalNo);
if (!sameGeneral && matches.length > 0) {
return 'PRESERVED';
}
if (!sameGeneral) {
await prisma.hallOfFame.create({
data: {
...candidate,
aux: asJson(candidate.aux),
},
});
return 'CREATED';
}
if (sameGeneral.scenario !== candidate.scenario || candidate.value <= sameGeneral.value) {
return 'PRESERVED';
}
await prisma.hallOfFame.update({
where: { id: sameGeneral.id },
data: {
value: candidate.value,
aux: asJson(candidate.aux),
},
});
return 'UPDATED';
};
+47 -5
View File
@@ -83,6 +83,8 @@ export interface GeneralLifecycleEvent {
outcome: 'active' | 'detached' | 'deleted' | 'retired';
before: TurnGeneral;
after?: TurnGeneral;
/** World unification state observed when this lifecycle transition occurred. */
isUnitedAtEvent?: number;
year: number;
month: number;
}
@@ -123,6 +125,23 @@ export interface InMemoryGameClockState {
lastTurnTick: number;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
export interface PendingInheritancePointAdjustment {
userId: string;
key: string;
amount: number;
phase?: InheritancePersistencePhase;
}
export interface PendingInheritanceLog {
userId: string;
year: number;
month: number;
text: string;
phase?: InheritancePersistencePhase;
}
export interface TurnWorldChanges {
realtimeBacklogShiftTicks: number;
accessScoreResetGeneralIds: number[];
@@ -145,7 +164,8 @@ export interface TurnWorldChanges {
deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -184,7 +204,8 @@ export interface InMemoryTurnWorldStateSnapshot {
messages: MessageDraft[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
pendingInheritanceLogs: PendingInheritanceLog[];
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
@@ -487,7 +508,8 @@ export class InMemoryTurnWorld {
private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = [];
private readonly inheritancePointAdjustments: PendingInheritancePointAdjustment[] = [];
private readonly pendingInheritanceLogs: PendingInheritanceLog[] = [];
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
@@ -786,6 +808,7 @@ export class InMemoryTurnWorld {
lifecycleEvents: this.lifecycleEvents,
pendingNeutralAuctions: this.pendingNeutralAuctions,
inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingInheritanceLogs: this.pendingInheritanceLogs,
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
@@ -831,6 +854,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingInheritanceLogs, restored.pendingInheritanceLogs ?? []);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
@@ -990,11 +1014,23 @@ export class InMemoryTurnWorld {
});
}
queueInheritancePointAdjustment(userId: string, key: string, amount: number): void {
queueInheritancePointAdjustment(
userId: string,
key: string,
amount: number,
phase?: InheritancePersistencePhase
): void {
if (!userId || !Number.isFinite(amount) || amount === 0) {
return;
}
this.inheritancePointAdjustments.push({ userId, key, amount });
this.inheritancePointAdjustments.push({ userId, key, amount, ...(phase ? { phase } : {}) });
}
queueInheritanceLog(log: PendingInheritanceLog): void {
if (!log.userId || !log.text) {
return;
}
this.pendingInheritanceLogs.push({ ...log });
}
queueNationBettingOpen(betting: PendingNationBettingOpen): void {
@@ -1271,6 +1307,9 @@ export class InMemoryTurnWorld {
generalId: id,
outcome: 'deleted',
before: structuredClone(general),
isUnitedAtEvent: Math.floor(
readMetaNumber(this.state.meta, 'isunited') ?? readMetaNumber(this.state.meta, 'isUnited') ?? 0
),
year,
month,
});
@@ -1811,6 +1850,7 @@ export class InMemoryTurnWorld {
closeAt: new Date(auction.closeAt.getTime()),
}));
const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry }));
const pendingInheritanceLogs = this.pendingInheritanceLogs.map((entry) => ({ ...entry }));
const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({
...entry,
candidates: entry.candidates.map((candidate) => ({
@@ -1852,6 +1892,7 @@ export class InMemoryTurnWorld {
lifecycleEvents,
pendingNeutralAuctions,
inheritancePointAdjustments,
pendingInheritanceLogs,
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
@@ -1889,6 +1930,7 @@ export class InMemoryTurnWorld {
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
this.pendingInheritanceLogs.splice(0, changes.pendingInheritanceLogs.length);
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
@@ -0,0 +1,670 @@
import {
asNumber,
asRecord,
LiteHashDRBG,
parseJson,
RandUtil,
rankDataMetaKey,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
type TurnDaemonInheritanceAction,
} from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
isCentennialStatResetAllowed,
isWarTraitKey,
loadWarTraitModules,
resolveMessageTargetIcon,
WarTraitLoader,
type InheritBuffType,
type MessageDraft,
type MessageTarget,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
type InheritanceActionCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
type InheritanceActionResult = Extract<TurnDaemonCommandResult, { type: 'inheritanceAction' }>;
interface InheritConstants {
inheritBornStatPoint: number;
inheritItemRandomPoint: number;
inheritBuffPoints: number[];
inheritSpecificSpecialPoint: number;
inheritResetAttrPointBase: number[];
inheritCheckOwnerPoint: number;
}
const DEFAULT_INHERIT_CONST: InheritConstants = {
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
};
const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
};
const SYSTEM_TARGET: MessageTarget = {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const resolveNumberArray = (value: unknown, fallback: number[]): number[] => {
if (!Array.isArray(value)) return [...fallback];
const result = value
.map((entry) => (typeof entry === 'number' && Number.isFinite(entry) ? entry : null))
.filter((entry): entry is number => entry !== null);
return result.length > 0 ? result : [...fallback];
};
const resolveInheritConstants = (world: InMemoryTurnWorld): InheritConstants => {
const configConst = asRecord(world.getScenarioConfig().const);
return {
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
inheritItemRandomPoint: asNumber(
configConst.inheritItemRandomPoint,
DEFAULT_INHERIT_CONST.inheritItemRandomPoint
),
inheritBuffPoints: resolveNumberArray(configConst.inheritBuffPoints, DEFAULT_INHERIT_CONST.inheritBuffPoints),
inheritSpecificSpecialPoint: asNumber(
configConst.inheritSpecificSpecialPoint,
DEFAULT_INHERIT_CONST.inheritSpecificSpecialPoint
),
inheritResetAttrPointBase: resolveNumberArray(
configConst.inheritResetAttrPointBase,
DEFAULT_INHERIT_CONST.inheritResetAttrPointBase
),
inheritCheckOwnerPoint: asNumber(
configConst.inheritCheckOwnerPoint,
DEFAULT_INHERIT_CONST.inheritCheckOwnerPoint
),
};
};
const buildResetCost = (baseCosts: number[], level: number): number => {
const costs = [...baseCosts];
while (costs.length <= level) {
const size = costs.length;
costs.push((costs[size - 1] ?? 0) + (costs[size - 2] ?? 0));
}
return costs[level] ?? 0;
};
const readBuffRecord = (raw: unknown): Record<string, number> => {
const source = typeof raw === 'string' ? (parseJson<Record<string, unknown>>(raw) ?? {}) : asRecord(raw);
return Object.fromEntries(
Object.entries(source).filter((entry): entry is [string, number] => {
const value = entry[1];
return typeof value === 'number' && Number.isFinite(value);
})
);
};
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
};
const readStringList = (raw: unknown): string[] => {
const value = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
};
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.floor(parsed);
}
return fallback;
};
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
const value = meta.season;
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.floor(parsed);
}
return null;
};
const readResetSeasons = (meta: Record<string, unknown>): number[] =>
Array.isArray(meta.last_stat_reset)
? meta.last_stat_reset
.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null))
.filter((value): value is number => value !== null)
: [];
export const buildResetStatRandomBonus = (
rng: RandUtil,
baseStats: [number, number, number]
): [number, number, number] => {
const bonusCount = rng.nextRangeInt(3, 5);
const bonus = [0, 0, 0] as [number, number, number];
for (let index = 0; index < bonusCount; index += 1) {
const selected = Number(
rng.choiceUsingWeight({
0: baseStats[0],
1: baseStats[1],
2: baseStats[2],
})
) as 0 | 1 | 2;
bonus[selected] += 1;
}
return bonus;
};
const formatTurnTimeBaseLabel = (value: number): string => {
const wholeSeconds = Math.trunc(value);
const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0');
const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0');
return `${hours}:${minutes}`;
};
const resolveResetTurnTimeBase = (options: {
hiddenSeed: string | number;
userId: string;
previousTurnTimeBase: string | number;
tickSeconds: number;
}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => {
const rng = new LiteHashDRBG(
simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase)
);
const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds);
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
};
const reject = (
action: TurnDaemonInheritanceAction['action'],
code: Extract<InheritanceActionResult, { ok: false }>['code'],
reason: string
): InheritanceActionResult => ({ type: 'inheritanceAction', ok: false, action, code, reason });
const lockPreviousPoint = async (db: GamePrisma.TransactionClient, userId: string): Promise<number> => {
const rows = await db.$queryRaw<Array<{ value: number }>>(GamePrisma.sql`
SELECT value
FROM inheritance_point
WHERE user_id = ${userId} AND key = 'previous'
FOR UPDATE
`);
return rows[0]?.value ?? 0;
};
const appendInheritanceLog = async (
db: GamePrisma.TransactionClient,
userId: string,
year: number,
month: number,
text: string
): Promise<void> => {
await db.inheritanceLog.create({ data: { userId, year, month, text } });
};
const buildMessageTarget = (world: InMemoryTurnWorld, general: TurnGeneral): MessageTarget => {
const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null;
return {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000',
icon: resolveMessageTargetIcon(general),
};
};
const queueOwnerLookupMessages = (
world: InMemoryTurnWorld,
actor: TurnGeneral,
target: TurnGeneral,
ownerName: string,
gameNow: Date
): void => {
const validUntil = new Date('9999-12-31T00:00:00.000Z');
const messages: MessageDraft[] = [
{
msgType: 'private',
src: SYSTEM_TARGET,
dest: buildMessageTarget(world, actor),
text: `${target.name}의 소유자는 ${ownerName} 입니다.`,
time: gameNow,
validUntil,
option: {},
sendDestOnly: true,
},
{
msgType: 'private',
src: SYSTEM_TARGET,
dest: buildMessageTarget(world, target),
text: '소유자명이 누군가에 의해 확인되었습니다.',
time: gameNow,
validUntil,
option: {},
sendDestOnly: true,
},
];
for (const message of messages) world.queueMessage(message);
};
const applyCharge = (options: {
world: InMemoryTurnWorld;
general: TurnGeneral;
userId: string;
previousPoint: number;
cost: number;
patch: Partial<TurnGeneral>;
}): TurnGeneral => {
const { world, general, userId, previousPoint, cost, patch } = options;
const patchMeta = patch.meta ? asRecord(patch.meta) : general.meta;
const spentKey = rankDataMetaKey('inherit_spent_dyn');
const nextMeta = {
...patchMeta,
[spentKey]: readMetaNumber(general.meta, spentKey, 0) + cost,
} as TurnGeneral['meta'];
const next = world.updateGeneral(general.id, {
...patch,
meta: nextMeta,
inheritancePoints: {
...general.inheritancePoints,
previous: previousPoint - cost,
},
});
if (!next) throw new Error(`Inheritance action general ${general.id} disappeared during mutation.`);
world.queueInheritancePointAdjustment(userId, 'previous', -cost);
return next;
};
const isUnited = (world: InMemoryTurnWorld): boolean => {
const meta = asRecord(world.getState().meta);
return asNumber(meta.isunited, 0) !== 0 || asNumber(meta.isUnited, 0) !== 0;
};
export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
const meta = asRecord(rawMeta);
for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) {
const value = meta[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return '알수없음';
};
export const executeInheritanceAction = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
gameNow: Date;
}): Promise<InheritanceActionResult> => {
const { db, world, command, gameNow } = options;
const { input, userId } = command;
const action = input.action;
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
if (!general) return reject(action, 'PRECONDITION_FAILED', '장수가 존재하지 않습니다.');
const state = world.getState();
const worldMeta = asRecord(state.meta);
const config = world.getScenarioConfig();
const configRecord = asRecord(config);
const constants = resolveInheritConstants(world);
if (action === 'checkOwner') {
if (input.targetGeneralId === general.id) {
return reject(action, 'BAD_REQUEST', '자신의 정보는 확인할 수 없습니다.');
}
const target = world.getGeneralById(input.targetGeneralId);
if (!target) return reject(action, 'BAD_REQUEST', '대상 장수가 존재하지 않습니다.');
if (!target.userId) return reject(action, 'BAD_REQUEST', '대상 장수는 NPC입니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const previousPoint = await lockPreviousPoint(db, userId);
const cost = constants.inheritCheckOwnerPoint;
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const ownerName = resolveOwnerDisplayName(target.meta);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 장수 소유자 확인`
);
queueOwnerLookupMessages(world, general, target, ownerName, gameNow);
applyCharge({ world, general, userId, previousPoint, cost, patch: {} });
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
ownerName,
targetName: target.name,
};
}
if (action === 'buyHiddenBuff') {
const buff = readBuffRecord(general.meta.inheritBuff);
const previousLevel = readBuffLevel(buff, input.buffType);
if (input.level === previousLevel) return reject(action, 'BAD_REQUEST', '이미 구입했습니다.');
if (input.level < previousLevel) return reject(action, 'BAD_REQUEST', '이미 더 높은 등급을 구입했습니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost =
(constants.inheritBuffPoints[input.level] ?? 0) - (constants.inheritBuffPoints[previousLevel] ?? 0);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const moreText = previousLevel > 0 ? '추가' : '';
buff[input.buffType] = input.level;
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 ${BUFF_LABELS[input.buffType]} ${input.level} 단계 ${moreText}구입`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritBuff: JSON.stringify(buff) } },
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'setNextSpecialWar') {
if (!isWarTraitKey(input.specialKey)) return reject(action, 'BAD_REQUEST', '잘못된 전투 특기입니다.');
const configConst = asRecord(config.const);
const allowed = Array.isArray(configConst.availableSpecialWar)
? configConst.availableSpecialWar.filter((key): key is string => typeof key === 'string')
: [];
if (allowed.length > 0 && !allowed.includes(input.specialKey)) {
return reject(action, 'BAD_REQUEST', '허용되지 않은 전투 특기입니다.');
}
if (general.role.specialWar === input.specialKey) {
return reject(action, 'BAD_REQUEST', '이미 그 특기를 보유하고 있습니다.');
}
const reserved =
typeof general.meta.inheritSpecificSpecialWar === 'string' ? general.meta.inheritSpecificSpecialWar : null;
if (reserved === input.specialKey) return reject(action, 'BAD_REQUEST', '이미 그 특기를 예약하였습니다.');
if (reserved) return reject(action, 'BAD_REQUEST', '이미 예약한 특기가 있습니다.');
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost = constants.inheritSpecificSpecialPoint;
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
const warName = warModule?.name ?? input.specialKey;
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 다음 전투 특기로 ${warName} 지정`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritSpecificSpecialWar: input.specialKey } },
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'resetSpecialWar') {
const currentSpecial = general.role.specialWar;
if (!currentSpecial || currentSpecial === 'None') {
return reject(action, 'BAD_REQUEST', '이미 전투 특기가 공란입니다.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const currentLevel = readMetaNumber(general.meta, 'inheritResetSpecialWar', -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const previousTypes = readStringList(general.meta.prev_types_special2);
previousTypes.push(currentSpecial);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 전투 특기 초기화`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
role: { ...general.role, specialWar: null },
meta: {
...general.meta,
inheritResetSpecialWar: nextLevel,
prev_types_special2: previousTypes,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
};
}
if (action === 'resetTurnTime') {
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const currentLevel = readMetaNumber(general.meta, 'inheritResetTurnTime', -1);
const nextLevel = currentLevel + 1;
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const rawSeedTurnTime = general.meta.nextTurnTimeBase ?? general.turnTick ?? 0;
const seedTurnTime =
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
? rawSeedTurnTime
: typeof rawSeedTurnTime === 'bigint'
? Number(rawSeedTurnTime)
: 0;
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: 'inherit';
const timing = resolveResetTurnTimeBase({
hiddenSeed,
userId,
previousTurnTimeBase: seedTurnTime,
tickSeconds: state.tickSeconds,
});
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${timing.nextTurnTimeLabel} 적용`
);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
meta: {
...general.meta,
inheritResetTurnTime: nextLevel,
nextTurnTimeBase: timing.nextTurnTimeBase,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
...timing,
};
}
if (action === 'resetStat') {
const statConfig = asRecord(configRecord.stat);
const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel);
const statMin = asNumber(statConfig.min, 1);
const statMax = asNumber(statConfig.max, 999);
if (input.leadership + input.strength + input.intel !== statTotal) {
return reject(action, 'BAD_REQUEST', `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`);
}
if (
input.leadership < statMin ||
input.strength < statMin ||
input.intel < statMin ||
input.leadership > statMax ||
input.strength > statMax ||
input.intel > statMax
) {
return reject(action, 'BAD_REQUEST', '능력치 범위를 벗어났습니다.');
}
const bonus = input.inheritBonusStat ?? [0, 0, 0];
const bonusSum = bonus.reduce((sum, value) => sum + value, 0);
if (bonus.some((value) => value < 0)) {
return reject(action, 'BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 입력해주세요!');
}
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
return reject(action, 'BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!');
}
if (general.npcState !== 0) return reject(action, 'BAD_REQUEST', 'NPC는 능력치 초기화를 할 수 없습니다.');
if (!isCentennialStatResetAllowed(config)) {
return reject(action, 'BAD_REQUEST', '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const cost = bonusSum > 0 ? constants.inheritBornStatPoint : 0;
const season = resolveSeasonValue(worldMeta);
const userStateRow =
season === null
? null
: await db.inheritanceUserState.findUnique({ where: { userId }, select: { meta: true } });
const userState = asRecord(userStateRow?.meta);
const resetSeasons = readResetSeasons(userState);
if (season !== null && resetSeasons.includes(season)) {
return reject(action, 'BAD_REQUEST', '이번 시즌에 이미 능력치를 초기화하셨습니다.');
}
const previousPoint = await lockPreviousPoint(db, userId);
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
const statHiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: 'inherit';
const baseStats = [input.leadership, input.strength, input.intel] as [number, number, number];
const finalBonus =
bonusSum === 0
? buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize(statHiddenSeed, 'ResetStat', userId))),
baseStats
)
: (bonus as [number, number, number]);
const nextStats = {
leadership: input.leadership + finalBonus[0],
strength: input.strength + finalBonus[1],
intel: input.intel + finalBonus[2],
};
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
`통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정`
);
await appendInheritanceLog(
db,
userId,
state.currentYear,
state.currentMonth,
bonusSum > 0
? `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
: `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
);
if (season !== null) {
await db.inheritanceUserState.upsert({
where: { userId },
update: { meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
create: { userId, meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
});
}
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: {
stats: {
leadership: nextStats.leadership,
strength: nextStats.strength,
intelligence: nextStats.intel,
},
},
});
return {
type: 'inheritanceAction',
ok: true,
action,
generalId: general.id,
remainPoint: previousPoint - cost,
stats: nextStats,
};
}
if (general.meta.inheritRandomUnique !== undefined && general.meta.inheritRandomUnique !== null) {
return reject(action, 'BAD_REQUEST', '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.');
}
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
const previousPoint = await lockPreviousPoint(db, userId);
const cost = constants.inheritItemRandomPoint;
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
await appendInheritanceLog(db, userId, state.currentYear, state.currentMonth, `${cost} 포인트로 랜덤 유니크 구입`);
applyCharge({
world,
general,
userId,
previousPoint,
cost,
patch: { meta: { ...general.meta, inheritRandomUnique: 1 } },
});
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
};
@@ -0,0 +1,63 @@
import {
REBIRTH_INHERITANCE_COEFFICIENTS,
type MergedInheritanceKey,
} from '@sammo-ts/logic/inheritance/pointCalculation.js';
const LEGACY_KEY_ORDER = [
'lived_month',
'max_belong',
'max_domestic_critical',
'active_action',
'combat',
'sabotage',
'unifier',
'dex',
'tournament',
'betting',
] as const satisfies readonly MergedInheritanceKey[];
const LEGACY_CALCULATED_KEYS = new Set<MergedInheritanceKey>(['max_belong', 'combat', 'sabotage', 'dex', 'betting']);
const LEGACY_KEY_LABEL: Readonly<Record<'previous' | MergedInheritanceKey, string>> = {
previous: '기존 보유',
lived_month: '생존',
max_belong: '최대 임관년 수',
max_domestic_critical: '최대 연속 내정 성공',
active_action: '능동 행동 수',
combat: '전투 횟수',
sabotage: '계략 성공 횟수',
unifier: '천통 기여',
dex: '숙련도',
tournament: '토너먼트',
betting: '베팅 당첨',
};
const formatLegacyPoint = (value: number): string => {
if (!Number.isFinite(value)) {
return '0';
}
return String(Object.is(value, -0) ? 0 : value);
};
export const buildInheritanceSettlementLogTexts = (input: {
previous: number;
points: Readonly<Partial<Record<MergedInheritanceKey, number>>>;
storedKeys: ReadonlySet<string>;
total: number;
isRebirth: boolean;
}): string[] => {
const texts = input.storedKeys.has('previous')
? [`${LEGACY_KEY_LABEL.previous} 포인트 ${formatLegacyPoint(input.previous)} 증가`]
: [];
for (const key of LEGACY_KEY_ORDER) {
if (!LEGACY_CALCULATED_KEYS.has(key) && !input.storedKeys.has(key)) {
continue;
}
if (input.isRebirth && REBIRTH_INHERITANCE_COEFFICIENTS[key] === null) {
continue;
}
texts.push(`${LEGACY_KEY_LABEL[key]} 포인트 ${formatLegacyPoint(input.points[key] ?? 0)} 증가`);
}
texts.push(`포인트 ${formatLegacyPoint(input.previous)} => ${formatLegacyPoint(input.total)}`);
return texts;
};
@@ -377,7 +377,11 @@ export const createUpdateNationLevelHandler = (options: {
const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
const amount = 250 * levelDiff;
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount);
// General turns (including rebirth settlement) finish before
// monthly actions in the processor. Persist this award after
// lifecycle so the retirement result cannot claim a later
// promotion as pre-rebirth retained state.
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount, 'after_lifecycle');
world.updateGeneral(chief.id, {
inheritancePoints: {
...chief.inheritancePoints,
@@ -32,7 +32,7 @@ import {
loadItemModules,
resolveUniqueConfig,
readScenarioGeneralPoolClaim,
rollUniqueLottery,
rollUniqueLotteryDetailed,
getNextTurnAt,
getBillByLevel,
LEGACY_DEFAULT_MAX_LEVEL,
@@ -479,6 +479,8 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string;
itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
inheritItemRandomPoint: number;
inheritanceWorld?: InMemoryTurnWorld | null;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => {
if (!options.worldView) {
@@ -523,7 +525,7 @@ const buildUniqueLotteryRunner = (options: {
const relMonthByInit =
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
const itemKey = rollUniqueLottery({
const outcome = rollUniqueLotteryDetailed({
rng,
config: options.uniqueConfig,
itemRegistry: options.itemRegistry,
@@ -539,13 +541,54 @@ const buildUniqueLotteryRunner = (options: {
acquireType,
inheritRandomUnique,
});
if (!itemKey) {
if (outcome.status === 'NO_SLOT' || outcome.status === 'NO_SUPPLY') {
if (inheritRandomUnique) {
const turnGeneral = general as TurnGeneral;
const cost = options.inheritItemRandomPoint;
const nextMeta = {
...turnGeneral.meta,
// Explicit retirement resets every rank before this lottery in Ref,
// so a failed pending purchase leaves the post-rebirth delta at -cost.
inherit_spent_dyn:
reason === '은퇴'
? -cost
: readMetaNumber(asRecord(turnGeneral.meta), 'inherit_spent_dyn', 0) - cost,
} as TurnGeneral['meta'];
delete nextMeta.inheritRandomUnique;
turnGeneral.meta = nextMeta;
turnGeneral.inheritancePoints = {
...turnGeneral.inheritancePoints,
previous: readInheritanceNumber(turnGeneral.inheritancePoints?.previous) + cost,
};
if (turnGeneral.userId) {
const persistencePhase = reason === '은퇴' ? 'after_lifecycle' : undefined;
options.inheritanceWorld?.queueInheritancePointAdjustment(
turnGeneral.userId,
'previous',
cost,
persistencePhase
);
options.inheritanceWorld?.queueInheritanceLog({
userId: turnGeneral.userId,
year: world.currentYear,
month: world.currentMonth,
text:
outcome.status === 'NO_SLOT'
? `유니크를 얻을 공간이 없어 ${cost} 포인트 반환`
: `얻을 유니크가 없어 ${cost} 포인트 반환`,
...(persistencePhase ? { phase: persistencePhase } : {}),
});
}
}
return null;
}
if (outcome.status === 'ROLL_FAILED') {
return null;
}
if (inheritRandomUnique && availableBuyUnique) {
delete asRecord(general.meta).inheritRandomUnique;
}
return options.itemRegistry.get(itemKey) ?? null;
return options.itemRegistry.get(outcome.itemKey) ?? null;
};
};
@@ -885,6 +928,11 @@ export const createReservedTurnHandler = async (options: {
const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.scenarioConfig.const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
@@ -1133,6 +1181,8 @@ export const createReservedTurnHandler = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: worldRef,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let actionRng = sharedActionRng ?? buildRng(actionKey);
@@ -2086,6 +2136,10 @@ export const createReservedTurnHandler = async (options: {
}
generalAiState = ai.getDebugState();
}
// che_은퇴 performs the rebirth inside the action, as Ref does. Preserve
// the fully accumulated pre-command state so lifecycle persistence can
// settle Hall/inheritance before observing that reset.
const explicitRetirementSnapshot = cloneTurnGeneral(currentGeneral);
const generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
const generalResult = isBlocked
? {
@@ -2176,7 +2230,10 @@ export const createReservedTurnHandler = async (options: {
delete currentGeneral.meta.nextTurnTimeBase;
}
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active';
const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed;
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired
? 'retired'
: 'active';
let deleteGeneral = false;
const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
@@ -2353,8 +2410,18 @@ export const createReservedTurnHandler = async (options: {
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: lifecycleOutcome,
before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot,
before:
lifecycleOutcome === 'active'
? lifecycleBefore
: explicitlyRetired
? explicitRetirementSnapshot
: lifecycleSnapshot,
...(deleteGeneral ? {} : { after: currentGeneral }),
isUnitedAtEvent: readMetaNumber(
asRecord(context.world.meta),
'isunited',
readMetaNumber(asRecord(context.world.meta), 'isUnited', 0)
),
year: context.world.currentYear,
month: context.world.currentMonth,
},
@@ -2418,6 +2485,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const));
const inheritItemRandomPoint = readMetaNumber(
asRecord(options.world.getScenarioConfig().const),
'inheritItemRandomPoint',
3_000
);
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
@@ -2488,6 +2560,8 @@ export const createImmediateGeneralActionExecutor = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
inheritItemRandomPoint,
inheritanceWorld: options.world,
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
});
const startYear = resolveStartYear(state, options.scenarioMeta);
@@ -2,11 +2,17 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameTy
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import {
readCentennialRecordableDexterity,
type CentennialDexKey,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
import type { PendingUnificationAuctionCancellation } from './types.js';
const UNIFIER_POINT = 2000;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
@@ -49,14 +55,13 @@ const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
export const resolveStoredInheritancePoint = (
currentPoints: ReadonlyMap<string, number>,
general: Pick<TurnGeneral, 'inheritancePoints'>,
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number],
unifierAward: number
): number =>
currentPoints.get(key) ??
(key === 'unifier'
? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward)
: (general.inheritancePoints?.[key] ?? 0));
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number]
): number => {
// All turn/month/auction mutations are persisted before finalization. A
// missing row therefore means zero; the general snapshot can still contain
// a rebirth-paid bucket that the lifecycle transaction deliberately deleted.
return currentPoints.get(key) ?? 0;
};
const formatHistogram = (value: unknown): string =>
Object.entries(asRecord(value))
@@ -329,7 +334,7 @@ export const persistUnificationFinalization = async (
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
const mergedPoints = Object.fromEntries(
ALL_MERGED_INHERITANCE_KEYS.map((key) => {
const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward);
const stored = resolveStoredInheritancePoint(currentPoints, key);
const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
})
@@ -359,15 +364,23 @@ export const persistUnificationFinalization = async (
},
},
});
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`,
},
});
for (const text of buildInheritanceSettlementLogTexts({
previous,
points: mergedPoints,
storedKeys: new Set([...currentPoints.keys(), ...(unifierAward > 0 ? (['unifier'] as const) : [])]),
total,
isRebirth: false,
})) {
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text,
},
});
}
}
const rankRows = generals.length
@@ -388,7 +401,7 @@ export const persistUnificationFinalization = async (
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = input.completedAt.toISOString();
const serverCount = await transaction.gameHistory.count();
const serverIdx = await resolveOfficialGameIndex(transaction, meta);
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
@@ -429,7 +442,7 @@ export const persistUnificationFinalization = async (
unitedTime,
ownerDisplayName: ownerDisplayName(generalMeta),
serverID: serverId,
serverIdx: serverCount,
serverIdx,
serverName,
scenarioName,
generationKey: input.generationKey,
@@ -445,7 +458,7 @@ export const persistUnificationFinalization = async (
? general.experience
: type === 'dedication'
? general.dedication
: readNumber(generalMeta[type]);
: readCentennialRecordableDexterity(generalMeta, type as CentennialDexKey);
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
if (type === 'ttrate' && totals.tt < 50) continue;
if (type === 'tlrate' && totals.tl < 50) continue;
@@ -454,30 +467,16 @@ export const persistUnificationFinalization = async (
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
if (value <= 0) continue;
const existing = await transaction.hallOfFame.findFirst({
where: {
OR: [
{ serverId, type, generalNo: general.id },
{ serverId, type, owner: general.userId },
],
},
await persistHallOfFameCandidate(transaction, {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
});
if (!existing) {
await transaction.hallOfFame.create({
data: {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
},
});
} else if (value > existing.value) {
await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } });
}
}
}
@@ -631,7 +630,7 @@ export const persistUnificationFinalization = async (
await transaction.emperor.create({
data: {
serverId,
phase: `${serverName}${serverCount}`,
phase: `${serverName}${serverIdx}`,
nationCount,
nationName: statisticNationNames || archivedNationNames.join(', '),
nationHist: formatHistogram(statistics.maxNationHist),
@@ -62,6 +62,7 @@ import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGener
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -162,7 +163,8 @@ const resolveCommandAcceptedAt = async (
| 'selectPoolReserve'
| 'selectPoolCreate'
| 'selectPoolReselect'
| 'adjustGeneralIcon';
| 'adjustGeneralIcon'
| 'inheritanceAction';
}
>
): Promise<Date> => {
@@ -802,6 +804,20 @@ async function handlePatchGeneral(
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
}
async function handleInheritanceAction(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx) as unknown as GamePrisma.TransactionClient;
const acceptedAt = await resolveCommandAcceptedAt(db as unknown as DatabaseClient, command);
return executeInheritanceAction({
db,
world: ctx.world,
command,
gameNow: ctx.world.getGameNow(acceptedAt),
});
}
async function handleAdjustGeneralIcon(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
@@ -2936,6 +2952,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
inheritanceAction: (command) =>
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
joinCreateGeneral: (command) =>
@@ -399,4 +399,153 @@ describe('legacy general turn lifecycle', () => {
}
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
});
it('emits an explicit retirement lifecycle event with the pre-rebirth snapshot', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
age: 65,
experience: 1_001,
dedication: 801,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: 'che_명마_07_백마',
weapon: 'che_무기_07_동추',
book: 'che_서적_07_위료자',
item: 'che_의술_정력견혈산',
},
},
meta: {
killturn: 24,
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 10,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 101,
},
inheritancePoints: { previous: 50 },
}),
]),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_은퇴', args: {} };
harness.reservedTurnStore.getGeneralTurns(1)[1] = { action: 'che_은퇴', args: {} };
await harness.runOneTick();
await harness.runOneTick();
const current = harness.world.getGeneralById(1)!;
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
expect(lifecycle).toMatchObject({
outcome: 'retired',
isUnitedAtEvent: 0,
before: {
age: 65,
experience: 1_001,
dedication: 801,
meta: {
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 12,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 101,
},
},
after: {
age: 20,
meta: { inherit_lived_month: 0, inherit_active_action: 0 },
},
});
expect(current).toMatchObject({
age: 20,
experience: 501,
dedication: 401,
inheritancePoints: { previous: 3_050 },
meta: {
rank_warnum: 0,
firenum: 0,
inherit_earned: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: -3_000,
dex1: 51,
},
});
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'user-1',
key: 'previous',
amount: 3_000,
phase: 'after_lifecycle',
});
expect(harness.world.peekDirtyState().pendingInheritanceLogs).toContainEqual({
userId: 'user-1',
year: 200,
month: 2,
text: '유니크를 얻을 공간이 없어 3000 포인트 반환',
phase: 'after_lifecycle',
});
});
it('refunds a failed pending lottery before automatic retirement and resets the spent rank to zero', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
age: 80,
crew: 100,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: 'che_명마_07_백마',
weapon: 'che_무기_07_동추',
book: 'che_서적_07_위료자',
item: 'che_의술_정력견혈산',
},
},
meta: {
killturn: 24,
inheritRandomUnique: true,
inherit_spent_dyn: 3_000,
inherit_lived_month: 10,
},
inheritancePoints: { previous: 70 },
}),
]),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
await harness.runOneTick();
const current = harness.world.getGeneralById(1)!;
expect(current).toMatchObject({
age: 20,
inheritancePoints: { previous: 3_070 },
meta: { inherit_spent_dyn: 0, inherit_lived_month: 0 },
});
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'user-1',
key: 'previous',
amount: 3_000,
});
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
expect(lifecycle?.before.meta).toMatchObject({ inherit_spent_dyn: 0 });
expect(lifecycle?.before.meta).not.toHaveProperty('inheritRandomUnique');
});
});
@@ -1,17 +1,41 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { asRecord, normalizeArchivedGeneral, RANK_DATA_TYPES, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
import type { TurnGeneral } from '../src/turn/types.js';
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalIds = [990_001, 990_002, 990_003];
const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
const generalIds = [990_001, 990_002, 990_003, 990_004, 990_005, 990_006, 990_007];
const userIds = [
'integration-lifecycle-dead',
'integration-lifecycle-retired',
'integration-lifecycle-possessed',
'integration-lifecycle-explicit-retired',
'integration-lifecycle-automatic-retired',
'integration-lifecycle-death-archive',
'integration-lifecycle-retire-before-unification',
];
const serverId = 'lifecycle-int';
const sameFlushServerId = `${serverId}-retire-before-unification`;
const worldId = 990_004;
const deathArchiveWorldId = 990_006;
const sameFlushWorldId = 990_007;
const nationId = 990_004;
const cityId = 990_004;
const sameFlushNationId = 990_007;
const sameFlushCityId = 990_007;
const archiveServerIds = [serverId, sameFlushServerId];
const historyServerIds = [serverId, `${serverId}-completed`, `${serverId}-abandoned`, sameFlushServerId];
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
@@ -69,12 +93,30 @@ integration('general turn lifecycle persistence', () => {
const cleanup = async () => {
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } });
await db.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } });
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } });
await db.unificationFinalization.deleteMany({ where: { serverId: sameFlushServerId } });
await db.emperor.deleteMany({ where: { serverId: sameFlushServerId } });
await db.oldNation.deleteMany({ where: { serverId: sameFlushServerId } });
await db.oldGeneral.deleteMany({
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
});
await db.hallOfFame.deleteMany({
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
});
await db.inheritanceResult.deleteMany({
where: { serverId: { in: archiveServerIds }, owner: { in: userIds } },
});
await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } });
await db.general.deleteMany({ where: { id: { in: generalIds } } });
await db.city.deleteMany({ where: { id: { in: [cityId, sameFlushCityId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, sameFlushNationId] } } });
await db.worldState.deleteMany({
where: { id: { in: [worldId, deathArchiveWorldId, sameFlushWorldId] } },
});
await db.gameHistory.deleteMany({ where: { serverId: { in: historyServerIds } } });
};
beforeAll(async () => {
@@ -189,8 +231,7 @@ integration('general turn lifecycle persistence', () => {
});
const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
expect(asRecord(archivedData.meta)).toMatchObject({ inheritRandomUnique: true });
const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot;
expect(snapshot).toMatchObject({
mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 },
@@ -220,7 +261,19 @@ integration('general turn lifecycle persistence', () => {
select: { text: true },
})
).map(({ text }) => text)
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']);
).toEqual([
'사망으로 랜덤 유니크 구입 3000 포인트 반환',
'기존 보유 포인트 3100 증가',
'최대 임관년 수 포인트 90 증가',
'최대 연속 내정 성공 포인트 80 증가',
'전투 횟수 포인트 10 증가',
'계략 성공 횟수 포인트 20 증가',
'천통 기여 포인트 250 증가',
'숙련도 포인트 1.004 증가',
'토너먼트 포인트 50 증가',
'베팅 당첨 포인트 5 증가',
'포인트 3100 => 3622',
]);
});
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
@@ -255,10 +308,15 @@ integration('general turn lifecycle persistence', () => {
data: { generalId: general.id, nationId: 0, type: 'inherit_earned', value: 4_321 },
});
const retirementEvent = event(general, 'retired');
retirementEvent.after = {
...general,
meta: { ...general.meta, rank_warnum: 0, inherit_earned: 0 },
};
await db.$transaction((tx) =>
persistGeneralLifecycleEvents(
tx,
[event(general, 'retired')],
[retirementEvent],
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
{}
)
@@ -311,6 +369,533 @@ integration('general turn lifecycle persistence', () => {
]);
});
it('archives the death turn history and battle brief through execute and database flush', async () => {
const general = makeGeneral(generalIds[5]!, userIds[5]!, {
npcState: 2,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
});
const scenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: { killturn: 0 },
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta = {
title: '사망 기록 archive integration',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
};
const worldMeta = { serverId, killturn: 0, scenarioMeta };
await db.worldState.create({
data: {
id: deathArchiveWorldId,
scenarioCode: 'death-archive-integration',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: scenarioConfig as GamePrisma.InputJsonValue,
meta: worldMeta as GamePrisma.InputJsonValue,
},
});
await db.general.create({
data: {
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
age: general.age,
bornYear: general.bornYear,
deadYear: general.deadYear,
meta: general.meta as GamePrisma.InputJsonValue,
},
});
await db.logEntry.createMany({
data: [
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
year: 199,
month: 12,
generalId: general.id,
text: '이전 열전',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
year: 199,
month: 12,
generalId: general.id,
text: '이전 전투 결과',
},
],
});
const state: TurnWorldState = {
id: deathArchiveWorldId,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: general.turnTime,
meta: worldMeta,
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig,
scenarioMeta,
map: {
id: 'death-archive',
name: '사망 기록 archive',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
generals: [general],
nations: [],
cities: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: {
execute: ({ general: currentGeneral, world: currentWorld }) => ({
deleted: { general: true },
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: 'deleted',
before: currentGeneral,
year: currentWorld.currentYear,
month: currentWorld.currentMonth,
},
logs: [
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: currentGeneral.id,
text: '마지막 열전 첫째',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
generalId: currentGeneral.id,
text: '마지막 전투 결과 첫째',
},
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: currentGeneral.id,
text: '마지막 열전 둘째',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
generalId: currentGeneral.id,
text: '마지막 전투 결과 둘째',
},
],
}),
},
});
world.executeGeneralTurn(world.getGeneralById(general.id)!);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
const archived = await db.oldGeneral.findUniqueOrThrow({
where: { by_no: { serverId, generalNo: general.id } },
});
const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['마지막 열전 둘째', '마지막 열전 첫째', '이전 열전']);
expect(asRecord(archivedData.records).battleResult).toEqual([
'마지막 전투 결과 둘째',
'마지막 전투 결과 첫째',
'이전 전투 결과',
]);
await expect(
db.logEntry.count({
where: {
generalId: general.id,
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
},
})
).resolves.toBe(6);
});
it('settles a pre-month retirement before same-flush unification without losing Hall or repaying stored points', async () => {
const turnTime = new Date('0200-01-01T00:05:00.000Z');
const monthBoundary = new Date('0200-01-01T00:10:00.000Z');
const general = makeGeneral(generalIds[6]!, userIds[6]!, {
nationId: sameFlushNationId,
cityId: sameFlushCityId,
age: 80,
officerLevel: 1,
turnTime,
meta: {
killturn: 24,
owner_name: '월경계 은퇴 사용자',
rank_warnum: 11,
firenum: 2,
inherit_lived_month: 10,
inherit_active_action: 4,
dex1: 200,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
event100_allstar: { granted: { dex1: 80 } },
},
inheritancePoints: {
previous: 100,
lived_month: 10,
active_action: 4,
tournament: 11,
},
});
const scenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
retirementYear: 80,
minPushHallAge: 30,
incDefSettingChange: 3,
maxDefSettingChange: 9,
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta = {
title: '월경계 은퇴 후 통일 integration',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
};
const worldMeta = {
serverId: sameFlushServerId,
serverName: '월경계 서버',
season: 9,
scenarioId: 77,
gameIdx: 12,
isUnited: 0,
isunited: 0,
killturn: 24,
scenarioMeta,
};
const nation = {
id: sameFlushNationId,
name: '월경계국',
color: '#224466',
capitalCityId: sameFlushCityId,
chiefGeneralId: general.id,
gold: 10_000,
rice: 10_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
meta: { gennum: 1, tech: 0 },
};
const city = {
id: sameFlushCityId,
name: '월경계성',
nationId: sameFlushNationId,
level: 5,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: { trust: 50, trade: 100, region: 1 },
};
const map = {
id: 'retire-before-unification',
name: '월경계 은퇴 후 통일',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
await db.worldState.create({
data: {
id: sameFlushWorldId,
scenarioCode: 'retire-before-unification-integration',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: scenarioConfig as GamePrisma.InputJsonValue,
meta: worldMeta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({
data: {
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
tech: 0,
level: nation.level,
typeCode: nation.typeCode,
meta: nation.meta,
},
});
await db.city.create({
data: {
id: city.id,
name: city.name,
nationId: city.nationId,
level: city.level,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
supplyState: city.supplyState,
frontState: city.frontState,
region: 1,
meta: city.meta,
},
});
await db.general.create({
data: {
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
age: general.age,
bornYear: general.bornYear,
deadYear: general.deadYear,
meta: general.meta as GamePrisma.InputJsonValue,
},
});
await db.rankData.createMany({
data: RANK_DATA_TYPES.map((type) => ({
generalId: general.id,
nationId: general.nationId,
type,
value: type === 'warnum' ? 11 : type === 'firenum' ? 2 : 0,
})),
});
await db.inheritancePoint.createMany({
data: [
{ userId: general.userId!, key: 'previous', value: 100 },
{ userId: general.userId!, key: 'lived_month', value: 10 },
{ userId: general.userId!, key: 'active_action', value: 4 },
{ userId: general.userId!, key: 'tournament', value: 11 },
],
});
await db.gameHistory.create({
data: {
serverId: sameFlushServerId,
date: new Date('0200-01-01T00:00:00.000Z'),
season: 9,
scenario: 77,
scenarioName: scenarioMeta.title,
status: 'OPEN',
},
});
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
await reservedTurns.loadAll();
reservedTurns.setGeneralTurn(general.id, 0, { action: '휴식', args: {} });
const state: TurnWorldState = {
id: sameFlushWorldId,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: worldMeta,
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig,
scenarioMeta,
map,
generals: [general],
nations: [nation],
cities: [city],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
let world: InMemoryTurnWorld | null = null;
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig,
scenarioMeta,
map,
getWorld: () => world,
});
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
calendarHandler: {
onMonthChanged: (context) => {
if (!world) throw new Error('world is unavailable');
const reborn = world.getGeneralById(general.id);
if (!reborn?.userId) throw new Error('reborn general is unavailable');
world.queueInheritancePointAdjustment(reborn.userId, 'unifier', 250, 'after_lifecycle');
world.updateGeneral(reborn.id, {
inheritancePoints: {
...reborn.inheritancePoints,
unifier: (reborn.inheritancePoints?.unifier ?? 0) + 250,
},
});
world.updateWorldMeta({ isUnited: 2, isunited: 2 });
world.queueUnificationFinalization({
generationKey: `unification:${sameFlushServerId}`,
serverId: sameFlushServerId,
profileName: 'che',
winnerNationId: sameFlushNationId,
year: context.currentYear,
month: context.currentMonth,
completedAt: new Date(context.turnTime.getTime()),
auctionCancellations: [],
});
},
},
});
world.advanceGameClockTo(monthBoundary, monthBoundary);
const processor = new InMemoryTurnProcessor(world);
const result = await processor.run(monthBoundary, {
budgetMs: 10_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(result).toMatchObject({ processedGenerals: 1, processedTurns: 1, partial: false });
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2 });
expect(world.peekDirtyState().lifecycleEvents).toContainEqual(
expect.objectContaining({
generalId: general.id,
outcome: 'retired',
isUnitedAtEvent: 0,
})
);
expect(world.getGeneralById(general.id)).toMatchObject({
age: 20,
inheritancePoints: { tournament: 11, lived_month: 11, active_action: 4 },
meta: { rank_warnum: 0, inherit_lived_month: 0, inherit_active_action: 0, dex1: 100 },
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns, profileName: 'che' });
try {
await hooks.hooks.flushChanges?.(result);
} finally {
await hooks.close();
}
await expect(
db.hallOfFame.findUniqueOrThrow({
where: {
serverId_type_generalNo: {
serverId: sameFlushServerId,
type: 'warnum',
generalNo: general.id,
},
},
})
).resolves.toMatchObject({ value: 11 });
const dexHall = await db.hallOfFame.findUniqueOrThrow({
where: {
serverId_type_generalNo: {
serverId: sameFlushServerId,
type: 'dex1',
generalNo: general.id,
},
},
});
expect(dexHall).toMatchObject({ value: 120 });
expect(asRecord(dexHall.aux)).toMatchObject({ unitedTime: monthBoundary.toISOString() });
const results = await db.inheritanceResult.findMany({
where: { serverId: sameFlushServerId, owner: general.userId! },
orderBy: { id: 'asc' },
select: { value: true },
});
expect(results).toHaveLength(2);
const rebirth = asRecord(results[0]!.value);
const unification = asRecord(results[1]!.value);
expect(rebirth).toMatchObject({ rebirth: true, tournament: 11 });
expect(asRecord(rebirth.retained)).toMatchObject({ unifier: 0 });
expect(unification).toMatchObject({
generationKey: `unification:${sameFlushServerId}`,
previous: rebirth.total,
lived_month: 0,
active_action: 0,
tournament: 0,
unifier: 250,
unifierBeforeAward: 250,
unifierAward: 0,
});
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: general.userId!, key: 'previous' } },
})
).resolves.toMatchObject({ value: Math.floor(Number(unification.total)) });
});
it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
const general = makeGeneral(generalIds[2]!, userIds[2]!, {
npcState: 1,
@@ -340,4 +925,429 @@ integration('general turn lifecycle persistence', () => {
})
).toBe(0);
});
it('executes explicit retirement, flushes pre-reset settlement values, and reloads only the reborn state', async () => {
const general = makeGeneral(generalIds[3]!, userIds[3]!, {
nationId,
cityId,
age: 65,
experience: 1_001,
dedication: 801,
turnTime: new Date('0200-01-01T00:10:00.000Z'),
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
},
meta: {
killturn: 24,
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 10,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 200,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
event100_allstar: { granted: { dex1: 80 } },
},
inheritancePoints: { previous: 50, lived_month: 10, active_action: 4 },
});
const automaticGeneral = makeGeneral(generalIds[4]!, userIds[4]!, {
nationId,
cityId,
age: 80,
crew: 100,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
},
meta: {
killturn: 24,
rank_warnum: 6,
inherit_lived_month: 10,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 40,
},
inheritancePoints: { previous: 70, lived_month: 10, active_action: 4 },
});
const scenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
retirementYear: 80,
incDefSettingChange: 3,
maxDefSettingChange: 9,
inheritItemRandomPoint: 3_000,
allItems: { weapon: { che_무기_12_칠성검: 1 } },
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta = {
title: '명시적 은퇴 integration',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
};
const worldMeta = {
serverId,
season: 4,
scenarioId: 22,
gameIdx: 7,
isUnited: 0,
killturn: 24,
scenarioMeta,
};
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'explicit-retirement-integration',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: scenarioConfig as GamePrisma.InputJsonValue,
meta: worldMeta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({
data: { id: nationId, name: '은퇴국', color: '#330000', level: 1, capitalCityId: cityId },
});
await db.city.create({
data: {
id: cityId,
name: '은퇴성',
level: 5,
nationId,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
region: 1,
},
});
await db.general.create({
data: {
id: general.id,
userId: general.userId,
name: general.name,
nationId,
cityId,
npcState: 0,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
age: general.age,
bornYear: general.bornYear,
deadYear: general.deadYear,
meta: general.meta as GamePrisma.InputJsonValue,
},
});
await db.general.create({
data: {
id: automaticGeneral.id,
userId: automaticGeneral.userId,
name: automaticGeneral.name,
nationId,
cityId,
npcState: 0,
leadership: automaticGeneral.stats.leadership,
strength: automaticGeneral.stats.strength,
intel: automaticGeneral.stats.intelligence,
experience: automaticGeneral.experience,
dedication: automaticGeneral.dedication,
officerLevel: automaticGeneral.officerLevel,
injury: automaticGeneral.injury,
gold: automaticGeneral.gold,
rice: automaticGeneral.rice,
crew: automaticGeneral.crew,
crewTypeId: automaticGeneral.crewTypeId,
train: automaticGeneral.train,
atmos: automaticGeneral.atmos,
turnTime: automaticGeneral.turnTime,
age: automaticGeneral.age,
bornYear: automaticGeneral.bornYear,
deadYear: automaticGeneral.deadYear,
meta: automaticGeneral.meta as GamePrisma.InputJsonValue,
},
});
await db.rankData.createMany({
data: [
...RANK_DATA_TYPES.map((type) => ({
generalId: general.id,
nationId,
type,
value: type === 'warnum' ? 10 : type === 'firenum' ? 8 : type === 'inherit_earned' ? 123 : 0,
})),
...RANK_DATA_TYPES.map((type) => ({
generalId: automaticGeneral.id,
nationId,
type,
value: type === 'warnum' ? 5 : type === 'inherit_spent_dyn' ? 3_000 : 0,
})),
],
});
await db.inheritancePoint.createMany({
data: [
{ userId: general.userId!, key: 'previous', value: 50 },
{ userId: general.userId!, key: 'lived_month', value: 10 },
{ userId: general.userId!, key: 'active_action', value: 4 },
{ userId: automaticGeneral.userId!, key: 'previous', value: 70 },
{ userId: automaticGeneral.userId!, key: 'lived_month', value: 10 },
{ userId: automaticGeneral.userId!, key: 'active_action', value: 4 },
],
});
await db.gameHistory.createMany({
data: [
{
serverId,
date: new Date('2026-08-24T00:00:00.000Z'),
season: 4,
scenario: 22,
scenarioName: scenarioMeta.title,
status: 'OPEN',
},
{
serverId: `${serverId}-completed`,
date: new Date('2026-08-23T00:00:00.000Z'),
season: 3,
scenario: 22,
scenarioName: '완료',
status: 'COMPLETED',
},
{
serverId: `${serverId}-abandoned`,
date: new Date('2026-08-22T00:00:00.000Z'),
season: 3,
scenario: 22,
scenarioName: '취소',
status: 'ABANDONED',
},
],
});
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
await reservedTurns.loadAll();
reservedTurns.setGeneralTurn(general.id, 0, { action: 'che_은퇴', args: {} });
reservedTurns.setGeneralTurn(general.id, 1, { action: 'che_은퇴', args: {} });
reservedTurns.setGeneralTurn(automaticGeneral.id, 0, { action: 'che_훈련', args: {} });
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: worldMeta,
};
const nation = {
id: nationId,
name: '은퇴국',
color: '#330000',
capitalCityId: cityId,
chiefGeneralId: general.id,
gold: 10_000,
rice: 10_000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: { gennum: 2, tech: 0 },
};
const city = {
id: cityId,
name: '은퇴성',
nationId,
level: 5,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: { trust: 50, trade: 100, region: 1 },
};
const map = {
id: 'explicit-retirement',
name: '명시적 은퇴',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
let world: InMemoryTurnWorld | null = null;
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig,
scenarioMeta,
map,
getWorld: () => world,
});
const snapshot: TurnWorldSnapshot = {
scenarioConfig,
scenarioMeta,
map,
generals: [general, automaticGeneral],
nations: [nation],
cities: [city],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
world.executeGeneralTurn(world.getGeneralById(automaticGeneral.id)!);
world.executeGeneralTurn(world.getGeneralById(general.id)!);
world.executeGeneralTurn(world.getGeneralById(general.id)!);
expect(world.peekDirtyState().lifecycleEvents.some((entry) => entry.outcome === 'retired')).toBe(true);
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: general.userId,
key: 'previous',
amount: 3_000,
phase: 'after_lifecycle',
});
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: automaticGeneral.userId,
key: 'previous',
amount: 3_000,
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 2,
processedTurns: 3,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)).toMatchObject({
age: 20,
experience: 501,
dedication: 401,
meta: {
rank_warnum: 0,
firenum: 0,
inherit_earned: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: -3_000,
dex1: 100,
},
});
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)?.meta).not.toHaveProperty(
'inheritRandomUnique'
);
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)).toMatchObject({
age: 20,
meta: {
rank_warnum: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: 0,
},
});
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)?.meta).not.toHaveProperty(
'inheritRandomUnique'
);
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'warnum', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 11, aux: expect.objectContaining({ serverIdx: 7 }) });
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'firenum', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 9 });
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'inherit_earned', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 4_321 });
await expect(
db.hallOfFame.findUniqueOrThrow({
where: { serverId_type_generalNo: { serverId, type: 'dex1', generalNo: general.id } },
})
).resolves.toMatchObject({ value: 120 });
const result = await db.inheritanceResult.findFirstOrThrow({
where: { serverId, owner: general.userId! },
orderBy: { id: 'desc' },
});
expect(result.value).toMatchObject({ combat: 55, sabotage: 180, dex: 0.06, rebirth: true });
const inheritanceLogs = await db.inheritanceLog.findMany({
where: { userId: general.userId! },
orderBy: { id: 'asc' },
select: { text: true },
});
const settlementLogIndex = inheritanceLogs.findIndex(({ text }) => text.startsWith('포인트 '));
const refundLogIndex = inheritanceLogs.findIndex(
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
);
expect(settlementLogIndex).toBeGreaterThanOrEqual(0);
expect(refundLogIndex).toBeGreaterThan(settlementLogIndex);
const persistedPrevious = await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: general.userId!, key: 'previous' } },
});
expect(persistedPrevious.value).toBeGreaterThan(3_000);
const automaticInheritanceLogs = await db.inheritanceLog.findMany({
where: { userId: automaticGeneral.userId! },
orderBy: { id: 'asc' },
select: { text: true },
});
const automaticRefundLogIndex = automaticInheritanceLogs.findIndex(
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
);
const automaticSettlementLogIndex = automaticInheritanceLogs.findIndex(({ text }) =>
text.startsWith('포인트 ')
);
expect(automaticRefundLogIndex).toBeGreaterThanOrEqual(0);
expect(automaticSettlementLogIndex).toBeGreaterThan(automaticRefundLogIndex);
await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0);
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0);
});
});
@@ -97,7 +97,14 @@ describe('general lifecycle archive history', () => {
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 },
meta: expect.objectContaining({
killturn: 0,
dex1: 1_000,
rank_warnum: 2,
rank_killnum: 1,
inheritRandomUnique: true,
inheritSpecificSpecialWar: true,
}),
}),
}),
})
@@ -106,32 +113,60 @@ describe('general lifecycle archive history', () => {
it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => {
const general = archivedGeneral();
const hallCreateMany = vi.fn(async () => ({ count: 1 }));
const hallCreate = vi.fn(async () => undefined);
general.userId = 'hall-owner';
general.meta = {
...general.meta,
rank_warnum: 11,
inherit_earned: 4_321,
dex1: 200,
event100_allstar: { granted: { dex1: 80 } },
};
const postRetirement = {
...general,
meta: {
...general.meta,
rank_warnum: 0,
inherit_earned: 0,
},
};
const rankUpsert = vi.fn(async () => undefined);
const prisma = {
generalAccessLog: {
updateMany: vi.fn(async () => ({ count: 1 })),
},
rankData: {
findMany: vi.fn(async () => [{ type: 'inherit_earned', value: 4_321 }]),
updateMany: vi.fn(async () => ({ count: 1 })),
findMany: vi.fn(async () => [
{ type: 'warnum', value: 10 },
{ type: 'inherit_earned', value: 123 },
]),
upsert: rankUpsert,
},
nation: {
findUnique: vi.fn(async () => null),
},
gameHistory: {
count: vi.fn(async () => 2),
count: vi.fn(async () => 99),
},
hallOfFame: {
findUnique: vi.fn(async () => null),
createMany: hallCreateMany,
findMany: vi.fn(async () => []),
create: hallCreate,
update: vi.fn(async () => undefined),
},
inheritancePoint: {
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
upsert: vi.fn(async () => undefined),
deleteMany: vi.fn(async () => ({ count: 0 })),
},
inheritanceResult: { create: vi.fn(async () => undefined) },
inheritanceLog: { create: vi.fn(async () => undefined) },
} as unknown as GamePrisma.TransactionClient;
const event: GeneralLifecycleEvent = {
generalId: general.id,
outcome: 'retired',
before: general,
after: general,
after: postRetirement,
isUnitedAtEvent: 0,
year: 200,
month: 1,
};
@@ -139,26 +174,39 @@ describe('general lifecycle archive history', () => {
await persistGeneralLifecycleEvents(
prisma,
[event],
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 0 },
{}
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 2, gameIdx: 7 },
{},
new Date('0200-02-01T00:00:00.000Z')
);
expect(hallCreateMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
serverId: 'hall-fixture',
season: 4,
scenario: 22,
generalNo: general.id,
type: 'inherit_earned',
value: 4_321,
}),
],
skipDuplicates: true,
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
serverId: 'hall-fixture',
season: 4,
scenario: 22,
generalNo: general.id,
type: 'inherit_earned',
value: 4_321,
}),
});
expect(prisma.rankData.updateMany).toHaveBeenCalledWith({
where: { generalId: general.id },
data: { value: 0 },
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ type: 'warnum', value: 11 }),
});
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'dex1',
value: 120,
aux: expect.objectContaining({ serverIdx: 7, unitedTime: '0200-02-01T00:00:00.000Z' }),
}),
});
expect(prisma.gameHistory.count).not.toHaveBeenCalled();
expect(prisma.inheritanceLog.create).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ text: expect.stringContaining('반환') }) })
);
expect(rankUpsert).toHaveBeenCalledWith({
where: { generalId_type: { generalId: general.id, type: 'warnum' } },
update: { nationId: general.nationId, value: 0 },
create: { generalId: general.id, nationId: general.nationId, type: 'warnum', value: 0 },
});
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from '../src/turn/hallOfFamePersistence.js';
const candidate = {
serverId: 'hall-server',
season: 3,
scenario: 22,
generalNo: 20,
type: 'experience' as const,
value: 2_000,
owner: 'same-owner',
aux: { name: '새장수' },
};
describe('Hall of Fame persistence policy', () => {
it('preserves an owner record belonging to another general for both higher and lower new values', async () => {
const update = vi.fn(async () => undefined);
const prisma = {
hallOfFame: {
findMany: vi.fn(async () => [
{
id: 1,
serverId: candidate.serverId,
season: 3,
scenario: 22,
generalNo: 10,
type: candidate.type,
value: 1_000,
owner: candidate.owner,
aux: { name: '기존장수' },
},
]),
create: vi.fn(async () => undefined),
update,
},
} as unknown as GamePrisma.TransactionClient;
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('PRESERVED');
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 500 })).resolves.toBe('PRESERVED');
expect(update).not.toHaveBeenCalled();
expect(prisma.hallOfFame.create).not.toHaveBeenCalled();
});
it('updates only value and aux for a higher same-general record, and preserves a lower value', async () => {
const existing = {
id: 2,
serverId: candidate.serverId,
season: 3,
scenario: 22,
generalNo: candidate.generalNo,
type: candidate.type,
value: 1_500,
owner: 'old-owner',
aux: { name: '기존장수' },
};
const update = vi.fn(async () => undefined);
const prisma = {
hallOfFame: {
findMany: vi.fn(async () => [existing]),
create: vi.fn(async () => undefined),
update,
},
} as unknown as GamePrisma.TransactionClient;
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('UPDATED');
expect(update).toHaveBeenCalledWith({
where: { id: existing.id },
data: { value: candidate.value, aux: candidate.aux },
});
update.mockClear();
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 1_000 })).resolves.toBe('PRESERVED');
expect(update).not.toHaveBeenCalled();
});
it('uses persisted gameIdx and reconstructs fallback from COMPLETED games only', async () => {
const count = vi.fn(async () => 4);
const prisma = { gameHistory: { count } } as unknown as GamePrisma.TransactionClient;
await expect(resolveOfficialGameIndex(prisma, { gameIdx: 0 })).resolves.toBe(0);
expect(count).not.toHaveBeenCalled();
await expect(resolveOfficialGameIndex(prisma, { firstGameIdx: 0 })).resolves.toBe(4);
expect(count).toHaveBeenCalledWith({ where: { status: 'COMPLETED' } });
});
});
@@ -0,0 +1,399 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 992_310;
const actorGeneralId = 7_310;
const targetGeneralId = 7_311;
const nationId = 7_312;
const actorUserId = 'inheritance-atomic-actor';
const targetUserId = 'inheritance-atomic-target';
const requestPrefix = 'integration:inheritance-atomic';
const pointConstraint = 'inheritance_atomic_point_failure';
const rankConstraint = 'inheritance_atomic_rank_failure';
const logConstraint = 'inheritance_atomic_log_failure';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const scenarioConfig: ScenarioConfig = {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
availableSpecialWar: ['che_의술'],
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta: ScenarioMeta = {
title: '유산 원자성 통합',
startYear: 200,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = { id: 'inheritance-atomic', name: scenarioMeta.title, cities: [] };
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-24T00:00:00.000Z'),
meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta },
};
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
id: actorGeneralId,
userId: actorUserId,
name: '확인장수',
nationId,
cityId: 0,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
turnTime: new Date('2026-08-24T00:10:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 17 },
inheritancePoints: { previous: 10_000 },
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
...overrides,
});
const actorGeneral = buildGeneral({});
const targetGeneral = buildGeneral({
id: targetGeneralId,
userId: targetUserId,
name: '피확인장수',
meta: { killturn: 24, owner_name: '레거시 소유자' },
inheritancePoints: { previous: 0 },
});
const generals = [actorGeneral, targetGeneral];
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('immediate_action_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
officerLevel: general.officerLevel,
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
recentWarTime: general.recentWarTime,
age: general.age,
meta: general.meta as GamePrisma.InputJsonValue,
penalty: general.penalty as GamePrisma.InputJsonValue,
});
const buildCommand = (
suffix: string,
input: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>['input']
): Extract<TurnDaemonCommand, { type: 'inheritanceAction' }> => ({
type: 'inheritanceAction',
requestId: `${requestPrefix}:${suffix}`,
userId: actorUserId,
input,
});
integration('inheritance action PostgreSQL atomic persistence', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let hooks: DatabaseTurnHooks | undefined;
const dropFailureConstraints = async (): Promise<void> => {
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT IF EXISTS ${pointConstraint}`);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT IF EXISTS ${rankConstraint}`);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT IF EXISTS ${logConstraint}`);
};
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await dropFailureConstraints();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'inheritance-atomic',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({ data: { id: nationId, name: '통합국', color: '#123456', level: 1 } });
await db.general.createMany({ data: generals.map(toGeneralCreate) });
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 10_000 } });
await db.rankData.create({
data: { generalId: actorGeneralId, nationId, type: 'inherit_spent_dyn', value: 17 },
});
});
afterAll(async () => {
await hooks?.close();
if (db) {
await dropFailureConstraints();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { id: worldId } });
}
await disconnect?.();
});
it('rolls patch/point/rank/log/messages back at each injected failure and reloads one committed mutation', async () => {
const snapshot: TurnWorldSnapshot = {
generals,
cities: [],
nations: [
{
id: nationId,
name: '통합국',
color: '#123456',
capitalCityId: null,
chiefGeneralId: actorGeneralId,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta,
map,
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (captured) => world.restoreState(captured),
});
const execute = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<TurnDaemonCommandResult> => {
if (!hooks?.hooks.executeCommand || !command.requestId) {
throw new Error('Database command execution hook is unavailable.');
}
return stateManager.transaction(() =>
hooks!.hooks.executeCommand!(command.requestId!, async (context) => {
const result = await handler.handle(command, context);
if (!result) throw new Error('inheritanceAction command was not handled.');
return result;
})
);
};
const createInputEvent = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<void> => {
await db.inputEvent.create({
data: {
requestId: command.requestId!,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
status: 'PROCESSING',
lockedBy: 'inheritance-atomic-worker',
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
attempts: 1,
payload: command as GamePrisma.InputJsonValue,
},
});
};
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: point });
await expect(
db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: actorGeneralId, type: 'inherit_spent_dyn' } },
})
).resolves.toMatchObject({ value: spent });
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(logCount);
await expect(
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
).resolves.toBe(messageCount);
};
const pointCommand = buildCommand('point', {
action: 'buyHiddenBuff',
buffType: 'warAvoidRatio',
level: 1,
});
await createInputEvent(pointCommand);
await db.$executeRawUnsafe(`
ALTER TABLE inheritance_point
ADD CONSTRAINT ${pointConstraint}
CHECK (user_id <> '${actorUserId}' OR key <> 'previous' OR value = 10000)
`);
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
await assertStored(10_000, 17, 0, 0);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
).resolves.toMatchObject({
status: 'PROCESSING',
result: null,
});
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
await assertStored(9_800, 217, 1, 0);
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
await createInputEvent(rankCommand);
await db.$executeRawUnsafe(`
ALTER TABLE rank_data
ADD CONSTRAINT ${rankConstraint}
CHECK (general_id <> ${actorGeneralId} OR type <> 'inherit_spent_dyn' OR value = 217)
`);
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
expect(world.peekDirtyState().messages).toEqual([]);
await assertStored(9_800, 217, 1, 0);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
await expect(execute(rankCommand)).resolves.toMatchObject({
ok: true,
remainPoint: 8_800,
ownerName: '레거시 소유자',
});
await assertStored(8_800, 1_217, 2, 2);
const currentLog = await db.inheritanceLog.findFirstOrThrow({
where: { userId: actorUserId },
orderBy: { id: 'desc' },
select: { id: true },
});
const logCommand = buildCommand('log', { action: 'buyRandomUnique' });
await createInputEvent(logCommand);
await db.$executeRawUnsafe(`
ALTER TABLE inheritance_log
ADD CONSTRAINT ${logConstraint}
CHECK (user_id <> '${actorUserId}' OR id <= ${currentLog.id})
`);
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
await assertStored(8_800, 1_217, 2, 2);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 3, 2);
const freeStatCommand = buildCommand('free-stat', {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
await createInputEvent(freeStatCommand);
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 5, 2);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
orderBy: { id: 'asc' },
select: { mailbox: true, message: true },
});
expect(messages.map((entry) => [entry.mailbox, (entry.message as { text: string }).text])).toEqual([
[actorGeneralId, '피확인장수의 소유자는 레거시 소유자 입니다.'],
[targetGeneralId, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: rankCommand.requestId! } })
).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
result: expect.objectContaining({ type: 'inheritanceAction', ok: true, action: 'checkOwner' }),
lockedBy: null,
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((general) => general.id === actorGeneralId)).toMatchObject({
stats: { leadership: 71, strength: 47, intelligence: 86 },
meta: {
inherit_spent_dyn: 4_217,
inheritRandomUnique: 1,
inheritBuff: JSON.stringify({ warAvoidRatio: 1 }),
},
inheritancePoints: { previous: 5_800 },
});
}, 30_000);
});
@@ -0,0 +1,357 @@
import { describe, expect, it, vi } from 'vitest';
import { LiteHashDRBG, RandUtil, type TurnDaemonCommand } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
buildResetStatRandomBonus,
executeInheritanceAction,
resolveOwnerDisplayName,
} from '../src/turn/inheritanceActionService.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
type InheritanceCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 1,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 17 },
inheritancePoints: { previous: 10_000 },
turnTime: new Date('0200-04-01T00:00:00.000Z'),
...overrides,
});
const buildWorld = (options: {
general?: TurnGeneral;
target?: TurnGeneral;
worldMeta?: Record<string, unknown>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 4,
tickSeconds: 3_600,
lastTurnTime: new Date('0200-04-01T00:00:00.000Z'),
meta: { hiddenSeed: 'test-seed', season: 7, isunited: 0, ...(options.worldMeta ?? {}) },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: options.configMap ?? {},
const: {
availableSpecialWar: ['che_의술'],
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
...(options.configConst ?? {}),
},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
generals: [options.general ?? buildGeneral(), ...(options.target ? [options.target] : [])],
cities: [],
nations: [
{
id: 1,
name: '촉',
color: '#ff0000',
capitalCityId: null,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
const buildDatabase = (options: { point?: number; resetSeasons?: number[] } = {}) => {
const createLog = vi.fn(async () => ({}));
const findUserState = vi.fn(async () =>
options.resetSeasons ? { meta: { last_stat_reset: options.resetSeasons } } : null
);
const upsertUserState = vi.fn(async () => ({}));
const queryRaw = vi.fn(async () => [{ value: options.point ?? 10_000 }]);
return {
db: {
$queryRaw: queryRaw,
inheritanceLog: { create: createLog },
inheritanceUserState: { findUnique: findUserState, upsert: upsertUserState },
} as unknown as GamePrisma.TransactionClient,
createLog,
findUserState,
upsertUserState,
queryRaw,
};
};
const execute = async (
world: InMemoryTurnWorld,
db: GamePrisma.TransactionClient,
input: InheritanceCommand['input']
) =>
executeInheritanceAction({
db,
world,
command: { type: 'inheritanceAction', userId: 'user-1', input },
gameNow: new Date('0200-04-01T00:00:00.000Z'),
});
describe('inheritance action service', () => {
it.each([
{
name: 'hidden buff',
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
cost: 200,
general: buildGeneral(),
},
{
name: 'specific special',
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
cost: 4_000,
general: buildGeneral(),
},
{
name: 'special reset',
input: { action: 'resetSpecialWar' } as const,
cost: 1_000,
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_선봉' } }),
},
{
name: 'turn-time reset',
input: { action: 'resetTurnTime' } as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'paid stat reset',
input: {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1] as [number, number, number],
} as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'random unique reservation',
input: { action: 'buyRandomUnique' } as const,
cost: 3_000,
general: buildGeneral(),
},
{
name: 'owner lookup',
input: { action: 'checkOwner', targetGeneralId: 2 } as const,
cost: 1_000,
general: buildGeneral(),
},
])('charges $name in runtime rank, points, and the same dirty flush', async ({ input, cost, general }) => {
const target =
input.action === 'checkOwner'
? buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
})
: undefined;
const world = buildWorld({ general, target });
const { db, createLog } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: true, remainPoint: 10_000 - cost });
expect(world.getGeneralById(1)).toMatchObject({
meta: { inherit_spent_dyn: 17 + cost },
inheritancePoints: { previous: 10_000 - cost },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual(
cost === 0 ? [] : [{ userId: 'user-1', key: 'previous', amount: -cost }]
);
expect(createLog).toHaveBeenCalled();
});
it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => {
const world = buildWorld({});
const { db } = buildDatabase({ point: 0 });
const result = await execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
expect(result).toMatchObject({ ok: true, remainPoint: 0 });
expect(world.getGeneralById(1)?.meta.inherit_spent_dyn).toBe(17);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]);
expect(result.ok && result.stats).toEqual({ leadership: 73, strength: 45, intel: 87 });
});
it('matches the Ref ResetStat DRBG seed, inclusive 3..5 count, and weighted choices', () => {
const bonus = buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize('test-seed', 'ResetStat', 'user-1'))),
[70, 45, 85]
);
expect(bonus).toEqual([3, 0, 2]);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(3);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(5);
});
it.each([1, 2])('rejects npcState=%s ResetStat exactly like Ref npc != 0', async (npcState) => {
const world = buildWorld({ general: buildGeneral({ npcState }) });
const { db, queryRaw } = buildDatabase();
await expect(
execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: 'NPC는 능력치 초기화를 할 수 없습니다.' });
expect(queryRaw).not.toHaveBeenCalled();
});
it.each([
{
name: 'purchased buff before unification',
general: buildGeneral({ meta: { killturn: 24, inheritBuff: JSON.stringify({ warAvoidRatio: 1 }) } }),
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
reason: '이미 구입했습니다.',
},
{
name: 'owned special before unification',
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_의술' } }),
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
reason: '이미 그 특기를 보유하고 있습니다.',
},
{
name: 'blank special before unification',
general: buildGeneral(),
input: { action: 'resetSpecialWar' } as const,
reason: '이미 전투 특기가 공란입니다.',
},
{
name: 'random reservation before unification',
general: buildGeneral({ meta: { killturn: 24, inheritRandomUnique: true } }),
input: { action: 'buyRandomUnique' } as const,
reason: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
},
])('preserves Ref combined-invalid precedence: $name', async ({ general, input, reason }) => {
const world = buildWorld({ general, worldMeta: { isunited: 1 } });
const { db, queryRaw } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: false, reason });
expect(queryRaw).not.toHaveBeenCalled();
});
it('checks ResetStat shape, npc/S100, unification, season duplicate, then points', async () => {
const npcWorld = buildWorld({ general: buildGeneral({ npcState: 1 }), worldMeta: { isunited: 1 } });
const npcDb = buildDatabase({ point: 0 });
await expect(
execute(npcWorld, npcDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 84,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '능력치 총합이 200이 아닙니다. 다시 입력해주세요!' });
const seasonWorld = buildWorld({});
const seasonDb = buildDatabase({ point: 0, resetSeasons: [7] });
await expect(
execute(seasonWorld, seasonDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '이번 시즌에 이미 능력치를 초기화하셨습니다.' });
expect(seasonDb.queryRaw).not.toHaveBeenCalled();
});
it('uses both unification keys and preserves owner display-name compatibility order', async () => {
const world = buildWorld({ worldMeta: { isUnited: 1, isunited: 0 } });
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'resetTurnTime' })).resolves.toMatchObject({
ok: false,
reason: '이미 천하가 통일되었습니다.',
});
expect(resolveOwnerDisplayName({ ownerDisplayName: '현재', owner_name: '레거시', ownerName: '호환' })).toBe(
'현재'
);
expect(resolveOwnerDisplayName({ owner_name: '레거시', ownerName: '호환' })).toBe('레거시');
expect(resolveOwnerDisplayName({ ownerName: '호환' })).toBe('호환');
expect(resolveOwnerDisplayName({})).toBe('알수없음');
});
it('queues CheckOwner messages in Ref requester-then-target order', async () => {
const world = buildWorld({
target: buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
}),
});
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'checkOwner', targetGeneralId: 2 })).resolves.toMatchObject({
ok: true,
ownerName: '위유저',
});
expect(world.peekDirtyState().messages.map((message) => [message.dest.generalId, message.text])).toEqual([
[1, '조조의 소유자는 위유저 입니다.'],
[2, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { buildInheritanceSettlementLogTexts } from '../src/turn/inheritanceSettlementLogs.js';
describe('legacy inheritance settlement logs', () => {
it('logs calculated keys plus only direct stored keys that are present, in Ref order', () => {
expect(
buildInheritanceSettlementLogTexts({
previous: 100,
points: {
lived_month: 12,
max_belong: 90,
max_domestic_critical: 80,
active_action: 3,
combat: 15,
sabotage: 40,
unifier: 250,
dex: 1.25,
tournament: 50,
betting: 5,
},
storedKeys: new Set(['previous', 'lived_month', 'unifier']),
total: 521,
isRebirth: false,
})
).toEqual([
'기존 보유 포인트 100 증가',
'생존 포인트 12 증가',
'최대 임관년 수 포인트 90 증가',
'전투 횟수 포인트 15 증가',
'계략 성공 횟수 포인트 40 증가',
'천통 기여 포인트 250 증가',
'숙련도 포인트 1.25 증가',
'베팅 당첨 포인트 5 증가',
'포인트 100 => 521',
]);
});
it('skips delayed rebirth keys and logs coefficient-adjusted values', () => {
expect(
buildInheritanceSettlementLogTexts({
previous: 50,
points: {
lived_month: 12,
max_belong: 0,
max_domestic_critical: 0,
active_action: 3,
combat: 15,
sabotage: 40,
unifier: 0,
dex: 0.5,
tournament: 7,
betting: 5,
},
storedKeys: new Set([
'previous',
'lived_month',
'max_domestic_critical',
'active_action',
'unifier',
'tournament',
]),
total: 132,
isRebirth: true,
})
).toEqual([
'기존 보유 포인트 50 증가',
'생존 포인트 12 증가',
'능동 행동 수 포인트 3 증가',
'전투 횟수 포인트 15 증가',
'계략 성공 횟수 포인트 40 증가',
'숙련도 포인트 0.5 증가',
'토너먼트 포인트 7 증가',
'베팅 당첨 포인트 5 증가',
'포인트 50 => 132',
]);
});
});
@@ -214,7 +214,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.getGeneralById(1)?.role.items.horse).toBe(uniqueHorse.key);
expect(world.getGeneralById(2)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 500 },
{ userId: 'user-1', key: 'unifier', amount: 500, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
expect(world.peekDirtyState().logs).toEqual(
@@ -291,7 +291,7 @@ describe('UpdateNationLevel monthly action', () => {
meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 },
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
});
@@ -306,7 +306,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.getGeneralById(1)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 },
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
});
@@ -84,6 +84,7 @@ describe('durable read-model change journal mapping', () => {
lifecycleEvents: [],
pendingNeutralAuctions: [],
inheritancePointAdjustments: [],
pendingInheritanceLogs: [],
pendingNationBettingOpens: [],
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
@@ -35,6 +35,7 @@ const buildWorld = (): InMemoryTurnWorld => {
rank_warnum: 4,
firenum: 2,
dex1: 100,
event100_allstar: { granted: { dex1: 40 } },
},
officerLevel: 12,
experience: 10,
@@ -141,18 +142,12 @@ const input = {
describe('persistUnificationFinalization', () => {
it.each([
{ label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 },
])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => {
expect(
resolveStoredInheritancePoint(
new Map<string, number>(rows),
{ inheritancePoints: { unifier: memoryValue } },
'unifier',
2_000
)
).toBe(expected);
{ label: 'missing unifier row', rows: [], key: 'unifier' as const, expected: 0 },
{ label: 'missing resettable row', rows: [], key: 'tournament' as const, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], key: 'unifier' as const, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], key: 'unifier' as const, expected: 7 },
])('uses only transaction-visible inheritance storage for $label', ({ rows, key, expected }) => {
expect(resolveStoredInheritancePoint(new Map<string, number>(rows), key)).toBe(expected);
});
it('does not write when the transaction-scoped generation was already applied', async () => {
@@ -222,7 +217,7 @@ describe('persistUnificationFinalization', () => {
},
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
hallOfFame: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: hallCreate,
update: vi.fn().mockResolvedValue({}),
},
@@ -274,6 +269,11 @@ describe('persistUnificationFinalization', () => {
data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }),
})
);
expect(hallCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'dex1', value: 60 }),
})
);
expect(gameHistoryUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
);
@@ -186,9 +186,13 @@ describe('unique lottery on general commands', () => {
expect(dedicationIndex).toBeLessThan(uniqueIndex);
});
it('does not award a unique item reserved by an active auction', async () => {
it('refunds a pending inheritance purchase when active auctions exhaust the supply', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const lotteryGeneral = buildGeneral(1);
lotteryGeneral.userId = 'inherit-user';
lotteryGeneral.meta = { killturn: 24, inheritRandomUnique: true, inherit_spent_dyn: 3_000 };
lotteryGeneral.inheritancePoints = { previous: 200 };
const generals = [lotteryGeneral];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
@@ -267,6 +271,7 @@ describe('unique lottery on general commands', () => {
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
inheritItemRandomPoint: 3_000,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
@@ -317,6 +322,22 @@ describe('unique lottery on general commands', () => {
});
expect(result.general?.role.items.weapon).toBeNull();
expect(result.general?.meta).toMatchObject({ inherit_spent_dyn: 0 });
expect(result.general?.meta).not.toHaveProperty('inheritRandomUnique');
expect(result.general?.inheritancePoints?.previous).toBe(3_200);
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'inherit-user',
key: 'previous',
amount: 3_000,
});
expect(world.peekDirtyState().pendingInheritanceLogs).toEqual([
{
userId: 'inherit-user',
year: 180,
month: 1,
text: '얻을 유니크가 없어 3000 포인트 반환',
},
]);
});
});
+52
View File
@@ -79,6 +79,33 @@ export interface TurnDaemonSelectPoolReservation {
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 =
| {
type: 'run';
@@ -266,6 +293,12 @@ export type TurnDaemonCommand =
specialWar?: string | null;
};
}
| {
type: 'inheritanceAction';
requestId?: string;
userId: string;
input: TurnDaemonInheritanceAction;
}
| {
type: 'adjustGeneralIcon';
requestId?: string;
@@ -635,6 +668,25 @@ export type TurnDaemonCommandResult =
generalId: number;
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';
ok: true;
@@ -22,6 +22,9 @@ const ACTION_NAME = '은퇴';
const ACTION_KEY = 'che_은퇴';
const REQ_AGE = 60;
const hasPendingRandomUnique = (value: unknown): boolean =>
value === true || value === 1 || (typeof value === 'string' && (value === '1' || value.toLowerCase() === 'true'));
const reqGeneralValue = (): Constraint => ({
name: 'reqGeneralValue',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -43,18 +46,6 @@ export class ActionResolver<
const general = context.general;
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, '이');
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
@@ -75,7 +66,32 @@ export class ActionResolver<
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(
createGeneralPatchEffect(
@@ -21,7 +21,7 @@ import { compileCrewTypeCatalog } from '../crewType/index.js';
import type { City, General, Nation } from '../domain/entities.js';
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.js';
import { formatLogText, LogCategory, LogFormat, LogScope } from '../logging/index.js';
import { formatLogText, LogCategory, LogFormat, LogScope, type ActionLogger } from '../logging/index.js';
import {
createCrewTypeWarTriggerRegistry,
resolveDefenderOrder,
@@ -344,6 +344,10 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
export interface BattleSimProcessorOptions {
trace?: (event: WarBattleTraceEvent) => void;
rngFactory?: (seed: string) => RandUtil;
/** Comparison-only logger instrumentation; production callers omit it. */
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
/** Comparison-only observation of the resolved pure battle outcome. */
onBattleResolved?: (outcome: WarBattleOutcome) => void;
}
export const processBattleSimJob = (
@@ -416,9 +420,12 @@ export const processBattleSimJob = (
})),
defenderCity,
defenderNation,
...(options.loggerFactory ? { loggerFactory: options.loggerFactory } : {}),
...(options.trace ? { trace: options.trace } : {}),
});
options.onBattleResolved?.(outcome);
lastBattle = outcome;
const attackerReport = outcome.reports.find(
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
@@ -1,3 +1,5 @@
import { readCentennialRecordableDexterity, type CentennialDexKey } from '../scenario/centennialAllStar.js';
export const LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975;
export const ALL_MERGED_INHERITANCE_KEYS = [
@@ -38,9 +40,6 @@ export const REBIRTH_INHERITANCE_COEFFICIENTS: Readonly<Record<MergedInheritance
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 => {
for (const key of keys) {
const value = source[key];
@@ -59,17 +58,10 @@ const readStoredPoint = (
storedOverride?: number
): 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 => {
let totalDexterity = 0;
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) {
totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3;
dexterity = LEGACY_DEX_INHERITANCE_LIMIT;
@@ -1,11 +1,13 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_부적 } from '@sammo-ts/logic/war/triggers/che_부적.js';
import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
import type { ItemModule } from './types.js';
const ITEM_KEY = 'che_부적_태현청생부';
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 303;
export const itemModule: ItemModule = {
key: ITEM_KEY,
@@ -28,8 +30,12 @@ export const itemModule: ItemModule = {
}
return value;
} as NonNullable<ItemModule['onCalcStat']>,
getBattleInitTriggerList: (context) => {
if (!context.unit) return null;
return new WarTriggerCaller(new che_부상무효(context.unit, RAISE_TYPE), new che_부적(context.unit, RAISE_TYPE));
},
getBattlePhaseTriggerList: (context) => {
if (!context.unit) return null;
return new WarTriggerCaller(new che_부적(context.unit));
return new WarTriggerCaller(new che_부적(context.unit, RAISE_TYPE));
},
};
@@ -1,9 +1,10 @@
import { che_진압 } from '@sammo-ts/logic/war/triggers/che_진압.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { ItemModule } from './types.js';
const ITEM_KEY = 'che_진압_박혁론';
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_NONE;
export const itemModule: ItemModule = {
key: ITEM_KEY,
@@ -18,6 +19,6 @@ export const itemModule: ItemModule = {
unique: false,
getBattlePhaseTriggerList: (context) => {
if (!context.unit) return null;
return new WarTriggerCaller(new che_진압(context.unit));
return new WarTriggerCaller(new che_진압(context.unit, RAISE_TYPE));
},
};
+20 -3
View File
@@ -1,11 +1,19 @@
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
import { triggerModule as medicalWarTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
import type { ItemModule } from './types.js';
const INFO =
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)';
const ATTEMPT_DEDUP_TYPE: Record<string, number> = {
che_의술_상한잡병론: 301,
che_의술_정력견혈산: 302,
che_의술_청낭서: 302,
che_의술_태평청령: 303,
};
export const createMedicalItem = (key: string, rawName: string): ItemModule => ({
key,
rawName,
@@ -18,6 +26,15 @@ export const createMedicalItem = (key: string, rawName: string): ItemModule => (
reqSecu: 0,
unique: true,
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
getBattlePhaseTriggerList: (context) =>
context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null,
getBattlePhaseTriggerList: (context) => {
if (!context.unit) {
return null;
}
const attemptRaiseType =
BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * (ATTEMPT_DEDUP_TYPE[key] ?? 0);
return new WarTriggerCaller(
new che_의술시도(context.unit, attemptRaiseType),
new che_의술발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
);
},
});
+22 -1
View File
@@ -1,4 +1,7 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
import type { ItemModule } from './types.js';
export const createEventBattleTraitItemModule = (
@@ -45,7 +48,25 @@ export const createEventBattleTraitItemModule = (
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
}
if (traitModule.getBattlePhaseTriggerList) {
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
if (traitModule.key === 'che_저격') {
itemModule.getBattlePhaseTriggerList = (context) =>
context.unit
? new WarTriggerCaller(
new che_저격시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM, 0.5, 20, 40),
new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
)
: null;
} else if (traitModule.key === 'che_의술') {
itemModule.getBattlePhaseTriggerList = (context) =>
context.unit
? new WarTriggerCaller(
new che_의술시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM),
new che_의술발동(context.unit)
)
: null;
} else {
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
}
}
if (traitModule.getWarPowerMultiplier) {
itemModule.getWarPowerMultiplier =
+18 -3
View File
@@ -1,5 +1,5 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { consumeEquippedItemCharge, getEquippedItemInstance } from './inventory.js';
import { getEquippedItemInstance } from './inventory.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
import type { ItemModule } from './types.js';
@@ -17,6 +17,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
_selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (self.hasActivatedSkillOnLog('충차공격') > 0 && self.getPhase() === self.getMaxPhase() - 1) {
if (self instanceof WarUnitGeneral) {
const equipped = getEquippedItemInstance(self.getGeneral(), 'item');
if (equipped?.itemKey === ITEM_KEY && (equipped.state.charges ?? 0) <= 0) {
this.processConsumableItem();
}
}
return true;
}
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
return true;
}
@@ -28,9 +37,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
return true;
}
self.activateSkill('충차공격', '아이템사용');
self.getLogger().pushGeneralBattleDetailLog('<C>충차</>로 성벽을 공격합니다.');
consumeEquippedItemCharge(general, 'item', ITEM_KEY, 2);
self.activateSkill('충차공격');
const equipped = getEquippedItemInstance(general, 'item');
if (equipped?.itemKey === ITEM_KEY) {
const remaining = equipped.state.charges ?? 2;
// Ref decrements the purchase-time remain값 at first city contact,
// but only deletes the item in the last battle phase.
equipped.state.charges = remaining - 1;
}
return true;
}
}
+6 -1
View File
@@ -22,7 +22,7 @@ import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
import type { ItemModule, ItemModuleExport } from './types.js';
import { listEquippedItemKeys } from './utils.js';
import { removeEquippedItem } from './inventory.js';
import { registerLegacyBattleItemIdentity, removeEquippedItem } from './inventory.js';
export const ITEM_KEYS = [
'che_간파_노군입산부',
@@ -664,12 +664,17 @@ class ItemWarActionRouter<
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
const keys = listEquippedItemKeys(context.general);
const modules: Array<ItemModule<TriggerState>> = [];
let itemIdentity = { name: '-', rawName: '-' };
for (const key of keys) {
const module = this.registry.get(key);
if (module) {
modules.push(module);
if (module.slot === 'item') {
itemIdentity = { name: module.name, rawName: module.rawName };
}
}
}
registerLegacyBattleItemIdentity(context.general, itemIdentity);
return modules;
}
+22 -3
View File
@@ -12,6 +12,27 @@ import type {
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
const INVENTORY_META_KEY = 'itemInventory';
export interface LegacyBattleItemIdentity {
name: string;
rawName: string;
}
// Ref's BaseWarUnitTrigger::processConsumableItem() asks General::getItem()
// for the *item-slot* display name even when a weapon trigger raised the
// shared item flag. Keep that transient lookup out of persisted General meta.
const legacyBattleItemIdentities = new WeakMap<object, LegacyBattleItemIdentity>();
export const registerLegacyBattleItemIdentity = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
identity: LegacyBattleItemIdentity
): void => {
legacyBattleItemIdentities.set(general, identity);
};
export const getLegacyBattleItemIdentity = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>
): LegacyBattleItemIdentity => legacyBattleItemIdentities.get(general) ?? { name: '-', rawName: '-' };
const emptyState = (): GeneralItemInstanceState => ({ values: {} });
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
@@ -57,9 +78,7 @@ const readState = (value: unknown): GeneralItemInstanceState | null => {
return null;
}
const charges =
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0
? record['charges']
: undefined;
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) ? record['charges'] : undefined;
const valuesRecord = asRecord(record['values']) ?? {};
const values: Record<string, TriggerValue> = {};
for (const [key, entry] of Object.entries(valuesRecord)) {
+18 -7
View File
@@ -44,6 +44,12 @@ export type UniqueLotteryInput = {
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]> = [
[-1, 1],
[3, 2],
@@ -183,7 +189,7 @@ export const buildGenericUniqueSeed = (
export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string =>
serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId);
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
export const rollUniqueLotteryDetailed = (input: UniqueLotteryInput): UniqueLotteryOutcome => {
const {
rng,
config,
@@ -203,13 +209,13 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
const resolvedAcquireType = acquireType ?? '아이템';
if (userCount <= 0) {
return null;
return { status: 'ROLL_FAILED' };
}
const itemTypes = Object.keys(config.allItems);
const itemTypeCnt = itemTypes.length;
if (itemTypeCnt <= 0) {
return null;
return { status: 'NO_SLOT' };
}
const relYear = currentYear - startYear;
@@ -246,7 +252,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
}
if (trialCnt <= 0 || maxCnt <= 0) {
return null;
return { status: 'NO_SLOT' };
}
const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth);
@@ -289,7 +295,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
}
if (!success) {
return null;
return { status: 'ROLL_FAILED' };
}
const availableUnique: Array<[string, number]> = [];
@@ -315,10 +321,15 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
}
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>(
@@ -20,7 +20,7 @@ const STAT_KEYS = ['leadership', 'strength', 'intel'] as const;
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
type CentennialStatKey = (typeof STAT_KEYS)[number];
type CentennialDexKey = (typeof DEX_KEYS)[number];
export type CentennialDexKey = (typeof DEX_KEYS)[number];
export interface CentennialAllStarTarget {
uniqueName: string;
@@ -670,3 +670,14 @@ export const reconcileCentennialDexConversion = (
export const centennialRecordableValue = (current: number, granted: number): number =>
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);
};
+15 -3
View File
@@ -1,6 +1,6 @@
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
@@ -110,6 +110,17 @@ const isSupplyCity = (city: City): boolean => {
return city.supplyState > 0;
};
const resolveLegacyTurnHourMinute = (general: General): string => {
const raw = general.meta['turnTime'];
if (typeof raw === 'string' && raw.length >= 16) {
return raw.slice(11, 16);
}
if (general.turnTime instanceof Date && Number.isFinite(general.turnTime.getTime())) {
return general.turnTime.toISOString().slice(11, 16);
}
return '00:00';
};
export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
defender: WarUnit<TriggerState>,
attacker: WarUnitGeneral<TriggerState>
@@ -390,7 +401,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
const attackerName = attackerUnit.getName();
const cityName = cityUnit.getName();
const seedText = input.seed ? `<span class="hidden_but_copyable">(전투시드: ${input.seed})</span>` : '';
const seedText = input.seed ? `<span class='hidden_but_copyable'>(전투시드: ${input.seed})</span>` : '';
const turnHourMinute = resolveLegacyTurnHourMinute(attackerUnit.getGeneral());
const josaRo = JosaUtil.pick(cityName, '로');
const josaYi = JosaUtil.pick(attackerName, '이');
@@ -400,7 +412,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
LogFormat.MONTH
);
attackerLogger.pushGeneralActionLog(
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText}`,
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText} <1>${turnHourMinute}</>`,
LogFormat.MONTH
);
+15 -9
View File
@@ -1,9 +1,10 @@
import type { RandUtil } from '@sammo-ts/common';
import { JosaUtil, type RandUtil } from '@sammo-ts/common';
import type { General } from '@sammo-ts/logic/domain/entities.js';
import { getLegacyBattleItemIdentity, removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
import type { WarUnit } from './units.js';
import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
export interface WarTriggerContext {
rng: RandUtil;
@@ -97,12 +98,6 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
return false;
}
this.unit.activateSkill('아이템사용');
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
return false;
}
if (this.unit.hasActivatedSkill('아이템소모')) {
return false;
}
const unit = this.unit as WarUnit & {
getGeneral?: () => General;
};
@@ -110,7 +105,18 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
if (!general) {
return false;
}
const item = getLegacyBattleItemIdentity(general);
this.unit.activateSkill(item.name);
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
return false;
}
if (this.unit.hasActivatedSkill('아이템소모')) {
return false;
}
this.unit.activateSkill('아이템소모');
return removeEquippedItem(general, 'item') !== null;
const josaUl = JosaUtil.pick(item.rawName, '');
this.unit.getLogger().pushGeneralActionLog(`<C>${item.name}</>${josaUl} 사용!`, LogFormat.PLAIN);
removeEquippedItem(general, 'item');
return true;
}
}
@@ -1,13 +1,16 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '../triggers.js';
import type { WarUnit } from '../units.js';
export class che_부적 extends BaseWarUnitTrigger {
constructor(unit: WarUnit, raiseType: number = 0) {
super(unit, 0, raiseType);
super(unit, TriggerPriority.Begin, raiseType);
}
protected actionWar(self: WarUnit): boolean {
self.activateSkill('저격불가', '부상무효');
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
// Ref WarActivateSkills(..., isSelf=false): the talisman's owner is
// injury-proof, while the opposing unit is prevented from sniping.
oppose.activateSkill('저격불가');
return true;
}
}
@@ -6,8 +6,8 @@ import type { WarTriggerModule } from './types.js';
// 의술: 치료 시도
export class che_의술시도 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Pre + 350);
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
super(unit, TriggerPriority.Pre + 350, raiseType);
}
protected actionWar(
@@ -36,8 +36,8 @@ export class che_의술시도 extends BaseWarUnitTrigger {
// 의술: 치료 발동
export class che_의술발동 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 550);
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
super(unit, TriggerPriority.Post + 550, raiseType);
}
protected actionWar(
@@ -58,7 +58,9 @@ export class che_저지 extends BaseWarUnitTrigger {
self.addDex(self.getCrewType(), calcDamage);
self.addLevelExp(calcDamage / 50);
let rice = self.calcRiceConsumption(calcDamage);
// Ref calcRiceConsumption() declares an int parameter, so the
// fractional 90% counter-damage is truncated before rice cost.
let rice = self.calcRiceConsumption(Math.trunc(calcDamage));
rice *= 0.25;
const general = self.getGeneral();
general.rice = Math.max(0, general.rice - rice);
@@ -1,13 +1,15 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '../triggers.js';
import type { WarUnit } from '../units.js';
export class che_진압 extends BaseWarUnitTrigger {
constructor(unit: WarUnit, raiseType: number = 0) {
super(unit, 0, raiseType);
super(unit, TriggerPriority.Begin, raiseType);
}
protected actionWar(self: WarUnit): boolean {
self.activateSkill('반계불가', '격노불가');
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
// Ref's 진압 is an opposing-unit restriction, not a self debuff.
oppose.activateSkill('반계불가', '격노불가');
return true;
}
}
+14 -5
View File
@@ -192,10 +192,16 @@ export class WarUnitGeneral<
return truncate ? Math.trunc(clamped) : clamped;
}
private resolveMainStat(armType: number, withInjury = true): number {
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury });
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury });
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { withInjury });
private resolveMainStat(armType: number, withInjury = true, truncate = true): number {
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, {
withInjury,
truncate,
});
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury, truncate });
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, {
withInjury,
truncate,
});
if (armType === this.config.armTypes.wizard) {
return intelligence;
@@ -279,7 +285,10 @@ export class WarUnitGeneral<
return 0;
}
const mainStat = this.resolveMainStat(armType, false);
// GameUnitDetail::getCriticalRatio requests each Ref stat with
// useFloor=false, so action bonuses such as 징병's +25% leadership must
// retain their fractional part until after the probability is formed.
const mainStat = this.resolveMainStat(armType, false, false);
const coef =
armType === this.config.armTypes.wizard ||
armType === this.config.armTypes.siege ||
@@ -101,4 +101,13 @@ describe('GeneralItemInventory', () => {
values: { source: 'shop' },
});
});
it('round-trips the negative remain value produced by the legacy ram lifecycle', () => {
const general = makeGeneral();
equipNewItem(general, 'item', 'event_충차', { charges: -1 });
const parsed = parseItemInventory(serializeItemInventory(general.itemInventory!), general.role.items);
expect(getEquippedItemInstance({ ...general, itemInventory: parsed }, 'item')?.state.charges).toBe(-1);
});
});
+46
View File
@@ -10,6 +10,7 @@ import {
countOccupiedUniqueItems,
resolveUniqueConfig,
rollUniqueLottery,
rollUniqueLotteryDetailed,
} from '../src/rewards/uniqueLottery.js';
const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({
@@ -118,6 +119,51 @@ describe('unique lottery', () => {
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', () => {
const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
+170 -3
View File
@@ -167,6 +167,93 @@ const buildGeneral = (strength: number): General => ({
});
describe('war triggers', () => {
it('applies the legacy talisman immunity to self and snipe restriction to the opponent', async () => {
const attacker = buildGeneral(80);
attacker.role.items.item = 'che_부적_태현청생부';
const defender = { ...buildGeneral(80), id: 2, name: 'Defender' };
const itemModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['che_부적_태현청생부']))
).war;
const events: Array<{
event: string;
attacker: { activatedSkills: Record<string, number> };
defender: { activatedSkills: Record<string, number> } | null;
}> = [];
resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: {
general: attacker,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
defenders: [
{
general: defender,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
],
defenderCity: buildCity(),
defenderNation: buildNation(),
trace: (event) => events.push(event),
});
const initialized = events.find((event) => event.event === 'opponent_initialized');
expect(initialized?.attacker.activatedSkills).toMatchObject({ 부상무효: 1 });
expect(initialized?.attacker.activatedSkills).not.toHaveProperty('저격불가');
expect(initialized?.defender?.activatedSkills).toMatchObject({ 저격불가: 1 });
expect(initialized?.defender?.activatedSkills).not.toHaveProperty('부상무효');
});
it('applies the legacy suppression restrictions to the opponent', async () => {
const attacker = buildGeneral(80);
attacker.role.items.item = 'che_진압_박혁론';
const defender = { ...buildGeneral(80), id: 2, name: 'Defender' };
const itemModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['che_진압_박혁론']))
).war;
const events: Array<{
event: string;
attacker: { activatedSkills: Record<string, number> };
defender: { activatedSkills: Record<string, number> } | null;
}> = [];
resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: {
general: attacker,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
defenders: [
{
general: defender,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
],
defenderCity: buildCity(),
defenderNation: buildNation(),
trace: (event) => events.push(event),
});
const phase = events.find((event) => event.event === 'phase_triggered');
expect(phase?.attacker.activatedSkills).not.toHaveProperty('반계불가');
expect(phase?.attacker.activatedSkills).not.toHaveProperty('격노불가');
expect(phase?.defender?.activatedSkills).toMatchObject({ 반계불가: 1, 격노불가: 1 });
});
it('passes battle time and maximum tech level to year-scaling stat items', async () => {
const general = buildGeneral(80);
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
@@ -604,8 +691,14 @@ describe('resolveWarBattle', () => {
for (const expectedCharges of [1, null] as const) {
general.crew = 5000;
general.rice = 10000;
const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 };
resolveWarBattle({
const defenderCity = {
...buildCity(),
defence: 100_000,
defenceMax: 100_000,
wall: 3000,
wallMax: 3000,
};
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
@@ -625,13 +718,51 @@ describe('resolveWarBattle', () => {
if (expectedCharges === null) {
expect(equipped).toBeNull();
expect(general.role.items.item).toBeNull();
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({
충차공격: 1,
아이템사용: 1,
충차: 1,
아이템소모: 1,
});
expect(outcome.logs.some((entry) => entry.text === '<C>충차</>를 사용!')).toBe(true);
} else {
expect(equipped?.state.charges).toBe(expectedCharges);
expect(general.role.items.item).toBe('event_충차');
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({ 충차공격: 1 });
expect(outcome.metrics?.attackerActivatedSkills).not.toHaveProperty('아이템사용');
}
}
});
it('preserves a negative legacy ram remain value when combat ends before the final phase', async () => {
const general = { ...buildGeneral(100), crew: 5000, rice: 10000 };
equipNewItem(general, 'item', 'event_충차', { charges: 0 });
const itemModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['event_충차']))
).war;
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: {
general,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
defenders: [],
defenderCity: { ...buildCity(), defence: 1, defenceMax: 1 },
defenderNation: buildNation(),
});
expect(outcome.conquered).toBe(true);
expect(getEquippedItemInstance(general, 'item')?.state.charges).toBe(-1);
expect(general.role.items.item).toBe('event_충차');
expect(outcome.metrics?.attackerActivatedSkills).not.toHaveProperty('아이템사용');
});
it('removes a one-use battle item through the canonical inventory', async () => {
const general = buildGeneral(100);
equipNewItem(general, 'item', 'che_저격_수극');
@@ -639,7 +770,7 @@ describe('resolveWarBattle', () => {
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
).war;
resolveWarBattle({
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
@@ -657,6 +788,42 @@ describe('resolveWarBattle', () => {
expect(getEquippedItemInstance(general, 'item')).toBeNull();
expect(general.role.items.item).toBeNull();
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({
아이템사용: 1,
'수극(저격)': 1,
아이템소모: 1,
});
expect(outcome.logs.some((entry) => entry.text === '<C>수극(저격)</>을 사용!')).toBe(true);
});
it("keeps Ref's '-' item-name activation when a weapon trigger fires without an item-slot item", async () => {
const general = { ...buildGeneral(100), crew: 5000, rice: 10000 };
equipNewItem(general, 'weapon', 'che_무기_07_맥궁');
const itemModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['che_무기_07_맥궁']))
).war;
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: {
general,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
defenders: [],
defenderCity: buildCity(),
defenderNation: buildNation(),
});
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({
아이템사용: 1,
'-': 1,
});
expect(general.role.items.weapon).toBe('che_무기_07_맥궁');
});
it('handles supply rout when defender nation has no rice', () => {
@@ -0,0 +1,23 @@
# Battle differential fixtures
`basic-infantry.json` is the tracked, deterministic smoke fixture for the Ref ↔ Core battle comparator. Every fixture must explicitly provide a positive integer `city` for the attacker and every defender. The attacker city must equal `attackerCity.city`; each defender city must equal `defenderCity.city`. The runner rejects omitted or inconsistent current-city state before invoking either engine.
The captured corpus test is intentionally conditional. It is skipped unless `BATTLE_CORPUS_PATH` points to an existing JSONL fixture corpus; this repository does not generate or silently substitute a corpus. Each corpus row is validated by the same city contract.
Reference execution can use an already instrumented container through `REF_COMPARE_CONTAINER`, or an instrumentation checkout through `REF_COMPARE_SOURCE_ROOT`. Source-root execution creates only a temporary bind-mounted copy. It discovers the single network of the official reference Compose PHP service, or accepts an existing network named by `REF_COMPARE_NETWORK`. Missing or ambiguous networks fail closed. The runner never removes Compose containers, networks, volumes, or databases.
The Ref trace runner seeds `year`, `month`, and `startyear` only in KVStorage's process-local cache. This is required for legacy battle items that read the game clock from `game_env`; it keeps their fixture time deterministic without writing to the shared reference database.
Precomputed traces are accepted only when `BATTLE_REFERENCE_TRACE_PATH` is accompanied by `BATTLE_REFERENCE_MANIFEST_PATH`, or by a sibling `<trace>.manifest.json`. Manifest schema version 1 requires:
```json
{
"schemaVersion": 1,
"fixtureCount": 0,
"fixtureJsonlSha256": "sha256 of normalized fixture JSONL",
"traceCount": 0,
"traceJsonlSha256": "sha256 of the exact trace file bytes"
}
```
Counts and hashes must match exactly. Every trace row must also carry `fixtureIdentity.schemaVersion`, the exact fixture `seed`, and the SHA-256 of that fixture row. Missing, reordered, truncated, appended, or stale trace data is rejected.
@@ -6,7 +6,7 @@
"repeatCnt": 1,
"action": "battle",
"attackerGeneral": {
"no": 1, "name": "공격자", "nation": 1, "turntime": "2026-01-01 00:00:00",
"no": 1, "name": "공격자", "nation": 1, "city": 1, "turntime": "2026-01-01 00:00:00",
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
@@ -28,7 +28,7 @@
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
},
"defenderGenerals": [{
"no": 2, "name": "수비자", "nation": 2, "turntime": "2026-01-01 00:00:00",
"no": 2, "name": "수비자", "nation": 2, "city": 2, "turntime": "2026-01-01 00:00:00",
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
@@ -1,4 +1,5 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -16,8 +17,16 @@ import {
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
ActionLogger,
formatLogText,
LogCategory,
LogFormat,
LogScope,
type LogEntryDraft,
type UnitSetDefinition,
type WarBattleOutcome,
type WarBattleTraceEvent,
type WarBattleTraceUnitSnapshot,
type WarEngineConfig,
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
@@ -32,13 +41,19 @@ import type {
interface ReferenceTrace {
engine: 'ref';
seed: string;
fixtureIdentity: FixtureIdentity;
conquered: boolean;
attacker: WarBattleTraceEvent['attacker'];
city: WarBattleTraceEvent['city'];
finishedDefenders: WarBattleTraceEvent['attacker'][];
defenderOrder?: {
before: Array<{ id: number; order: number }>;
after: Array<{ id: number; order: number }>;
};
events: WarBattleTraceEvent[];
rng: RandomCall[];
boolRng: BoolRandomCall[];
logs: {
attacker: ReferenceLogBuckets;
defenders: Record<string, ReferenceLogBuckets>;
@@ -46,6 +61,18 @@ interface ReferenceTrace {
};
}
interface FixtureIdentity {
schemaVersion: 1;
seed: string;
sha256: string;
}
interface BoolRandomCall {
rngSeq: number;
probability: number;
result: boolean;
}
interface ReferenceLogBuckets {
generalHistoryLog: string[];
generalActionLog: string[];
@@ -56,6 +83,65 @@ interface ReferenceLogBuckets {
globalActionLog: string[];
}
interface CapturedCoreLogger {
generalId?: number;
nationId?: number;
entries: LogEntryDraft[];
}
interface CoreLogCapture {
loggerFactory: (options: { generalId?: number; nationId?: number }) => ActionLogger;
byGeneralId: Map<number, CapturedCoreLogger>;
city: CapturedCoreLogger | null;
}
class ComparisonCapturingActionLogger extends ActionLogger {
public constructor(
options: { generalId?: number; nationId?: number },
private readonly capture: (entries: LogEntryDraft[]) => void
) {
super(options);
}
public override flush(): LogEntryDraft[] {
const entries = super.flush();
this.capture(entries);
return entries;
}
public override rollback(): LogEntryDraft[] {
const entries = super.rollback();
this.capture(entries);
return entries;
}
}
const createCoreLogCapture = (): CoreLogCapture => {
const capture: CoreLogCapture = {
byGeneralId: new Map(),
city: null,
loggerFactory: () => {
throw new Error('loggerFactory is not initialized');
},
};
capture.loggerFactory = (options) => {
const bucket: CapturedCoreLogger = { ...options, entries: [] };
if (options.generalId === undefined) {
if (capture.city) {
throw new Error('battle comparison created more than one city logger');
}
capture.city = bucket;
} else {
if (capture.byGeneralId.has(options.generalId)) {
throw new Error(`battle comparison duplicated general logger ${options.generalId}`);
}
capture.byGeneralId.set(options.generalId, bucket);
}
return new ComparisonCapturingActionLogger(options, (entries) => bucket.entries.push(...entries));
};
return capture;
};
interface RandomCall {
seq: number;
operation: string;
@@ -80,6 +166,7 @@ type ReferenceTraitCatalog = Record<
class TracingRng implements RNG {
public readonly calls: RandomCall[] = [];
public readonly boolCalls: BoolRandomCall[] = [];
public constructor(private readonly inner: RNG) {}
@@ -111,6 +198,19 @@ class TracingRng implements RNG {
return result;
}
public createRandUtil(): RandUtil {
const calls = this.calls;
const boolCalls = this.boolCalls;
return new (class extends RandUtil {
public override nextBool(probability: number = 0.5): boolean {
const rngSeq = calls.length;
const result = super.nextBool(probability);
boolCalls.push({ rngSeq, probability, result });
return result;
}
})(this);
}
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
this.calls.push({ seq: this.calls.length, operation, arguments: args, result });
}
@@ -135,7 +235,213 @@ const findWorkspaceRoot = (start: string): string | null => {
const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
const assertFixtureGeneralCityContract = (fixtureJson: string, label = 'battle fixture'): FixtureIdentity => {
const normalizedFixtureJson = fixtureJson.trim();
const fixture = JSON.parse(normalizedFixtureJson) as unknown;
if (!isRecord(fixture)) {
throw new Error(`${label}: fixture root must be an object`);
}
const assertSide = (side: 'attacker' | 'defender', general: unknown, city: unknown, index?: number): void => {
const suffix = index === undefined ? '' : `[${index}]`;
if (!isRecord(general) || !isRecord(city)) {
throw new Error(`${label}: ${side}${suffix} general/city must be objects`);
}
const generalCity = general['city'];
const currentCity = city['city'];
if (!Number.isSafeInteger(generalCity) || (generalCity as number) <= 0) {
throw new Error(`${label}: ${side}General${suffix}.city must be an explicit positive integer`);
}
if (!Number.isSafeInteger(currentCity) || (currentCity as number) <= 0) {
throw new Error(`${label}: ${side}City.city must be a positive integer`);
}
if (generalCity !== currentCity) {
throw new Error(
`${label}: ${side}General${suffix}.city=${String(generalCity)} must equal current city ${String(currentCity)}`
);
}
};
assertSide('attacker', fixture['attackerGeneral'], fixture['attackerCity']);
const defenderCity = fixture['defenderCity'];
const rawDefenders = fixture['defenderGenerals'];
const defenders = Array.isArray(rawDefenders)
? rawDefenders
: isRecord(rawDefenders)
? Object.values(rawDefenders)
: null;
if (!defenders) {
throw new Error(`${label}: defenderGenerals must be an array or ID-keyed object`);
}
defenders.forEach((general, index) => assertSide('defender', general, defenderCity, index));
const seed = typeof fixture['seed'] === 'string' ? fixture['seed'] : 'battle-differential';
return {
schemaVersion: 1,
seed,
sha256: sha256(normalizedFixtureJson),
};
};
const assertReferenceFixtureIdentity = (
reference: ReferenceTrace,
fixtureJson: string,
label = 'reference trace'
): void => {
const expected = assertFixtureGeneralCityContract(fixtureJson, label);
expect(reference.fixtureIdentity, `${label}: fixture identity`).toEqual(expected);
expect(reference.seed, `${label}: seed`).toBe(expected.seed);
};
const referenceRuntimeCopyFilter = (resolvedCompareRoot: string, source: string): boolean => {
const relative = path.relative(resolvedCompareRoot, source);
return !(
relative === '.git' ||
relative.startsWith(`.git${path.sep}`) ||
relative === 'vendor' ||
relative.startsWith(`vendor${path.sep}`) ||
relative === 'd_log' ||
relative.startsWith(`d_log${path.sep}`) ||
relative === path.join('hwe', 'd_setting') ||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
);
};
const assertSafeDockerNetworkName = (network: string): string => {
if (!/^[A-Za-z0-9_.-]+$/u.test(network)) {
throw new Error('Reference Docker network name contains unsupported characters.');
}
return network;
};
const resolveReferenceDockerNetwork = (workspaceRoot: string): string => {
const explicitNetwork = process.env['REF_COMPARE_NETWORK'];
if (explicitNetwork) {
const network = assertSafeDockerNetworkName(explicitNetwork);
try {
const resolved = execFileSync('docker', ['network', 'inspect', '--format', '{{.Name}}', network], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
if (resolved !== network) {
throw new Error('network identity mismatch');
}
return network;
} catch {
throw new Error('REF_COMPARE_NETWORK does not identify an available Docker network.');
}
}
const composeDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
try {
const phpContainerId = execFileSync('docker', ['compose', 'ps', '-q', 'php'], {
cwd: composeDirectory,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
if (!phpContainerId || !/^[a-f0-9]+$/u.test(phpContainerId)) {
throw new Error('reference php container is unavailable');
}
const networks = execFileSync(
'docker',
[
'inspect',
'--format',
'{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}',
phpContainerId,
],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}
)
.split(/\r?\n/u)
.map((entry) => entry.trim())
.filter(Boolean);
if (networks.length !== 1) {
throw new Error('reference php container must have exactly one discoverable network');
}
return assertSafeDockerNetworkName(networks[0]!);
} catch {
throw new Error(
'Unable to discover the official reference Compose network. Start that stack or set REF_COMPARE_NETWORK explicitly.'
);
}
};
const runReferenceSourceScript = (options: {
workspaceRoot: string;
compareSourceRoot: string;
script: string;
args?: string[];
input?: string;
maxBuffer?: number;
}): string => {
const resolvedCompareRoot = path.resolve(options.compareSourceRoot);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-compare-'));
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
recursive: true,
filter: (source) => referenceRuntimeCopyFilter(resolvedCompareRoot, source),
});
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
try {
const network = resolveReferenceDockerNetwork(options.workspaceRoot);
try {
return execFileSync(
'docker',
[
'run',
'--rm',
'-i',
'--network',
network,
'-v',
`${runtimeRoot}:/var/www/html`,
'-v',
`${path.join(options.workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
'-v',
`${path.join(options.workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
'sam-rebuild-ref-php:8.3',
'php',
'-d',
'display_errors=0',
'-d',
'log_errors=0',
`/var/www/html/${options.script}`,
...(options.args ?? []),
],
{
input: options.input,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }),
}
);
} catch (error) {
const failure = error as { status?: number | null; stderr?: string | Buffer };
const stderr = String(failure.stderr ?? '')
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 500);
// Intentionally omit the raw child-process error as the cause: it
// retains prior JSONL stdout and can expose a huge fixture corpus.
// eslint-disable-next-line preserve-caught-error
throw new Error(
`reference comparison script failed (exit ${String(failure.status ?? 'unknown')})${stderr ? `: ${stderr}` : ''}`
);
}
} finally {
fs.rmSync(runtimeRoot, { recursive: true, force: true });
}
};
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
assertFixtureGeneralCityContract(fixtureJson);
const compareContainer = process.env.REF_COMPARE_CONTAINER;
if (compareContainer) {
const stdout = execFileSync(
@@ -156,61 +462,22 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as ReferenceTrace;
const reference = JSON.parse(stdout) as ReferenceTrace;
assertReferenceFixtureIdentity(reference, fixtureJson);
return reference;
}
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
if (compareSourceRoot) {
const resolvedCompareRoot = path.resolve(compareSourceRoot);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-'));
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
recursive: true,
filter: (source) => {
const relative = path.relative(resolvedCompareRoot, source);
return !(
relative === '.git' ||
relative.startsWith(`.git${path.sep}`) ||
relative === 'vendor' ||
relative.startsWith(`vendor${path.sep}`) ||
relative === 'd_log' ||
relative.startsWith(`d_log${path.sep}`) ||
relative === path.join('hwe', 'd_setting') ||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
);
},
const stdout = runReferenceSourceScript({
workspaceRoot,
compareSourceRoot,
script: 'hwe/compare/battle_trace.php',
args: ['-'],
input: fixtureJson,
});
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
try {
const stdout = execFileSync(
'docker',
[
'run',
'--rm',
'-i',
'-v',
`${runtimeRoot}:/var/www/html`,
'-v',
`${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
'-v',
`${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
'sam-rebuild-ref-php:8.3',
'php',
'-d',
'display_errors=0',
'-d',
'log_errors=0',
'/var/www/html/hwe/compare/battle_trace.php',
'-',
],
{
input: fixtureJson,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as ReferenceTrace;
} finally {
fs.rmSync(runtimeRoot, { recursive: true, force: true });
}
const reference = JSON.parse(stdout) as ReferenceTrace;
assertReferenceFixtureIdentity(reference, fixtureJson);
return reference;
}
const stdout = execFileSync(
'docker',
@@ -222,21 +489,63 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as ReferenceTrace;
const reference = JSON.parse(stdout) as ReferenceTrace;
assertReferenceFixtureIdentity(reference, fixtureJson);
return reference;
};
interface BattleReferenceManifest {
schemaVersion: 1;
fixtureCount: number;
fixtureJsonlSha256: string;
traceCount: number;
traceJsonlSha256: string;
}
const normalizeJsonlForManifest = (lines: string[]): string => `${lines.map((line) => line.trim()).join('\n')}\n`;
const readBoundPrecomputedTraces = (tracePath: string, fixtureLines: string[]): ReferenceTrace[] => {
const resolvedTracePath = path.resolve(tracePath);
const rawTraceJsonl = fs.readFileSync(resolvedTracePath, 'utf8');
const traceLines = rawTraceJsonl.split(/\r?\n/u).filter(Boolean);
const manifestPath = path.resolve(
process.env['BATTLE_REFERENCE_MANIFEST_PATH'] ?? `${resolvedTracePath}.manifest.json`
);
if (!fs.existsSync(manifestPath)) {
throw new Error(
'BATTLE_REFERENCE_TRACE_PATH requires BATTLE_REFERENCE_MANIFEST_PATH or a sibling .manifest.json file.'
);
}
const manifest = readJson<BattleReferenceManifest>(manifestPath);
if (manifest.schemaVersion !== 1) {
throw new Error('Unsupported battle reference manifest schemaVersion.');
}
if (traceLines.length !== fixtureLines.length) {
throw new Error(`precomputed ref corpus has ${traceLines.length} traces for ${fixtureLines.length} fixtures`);
}
const expectedFixtureJsonl = normalizeJsonlForManifest(fixtureLines);
const checks: Array<[string, unknown, unknown]> = [
['fixtureCount', manifest.fixtureCount, fixtureLines.length],
['traceCount', manifest.traceCount, traceLines.length],
['fixtureJsonlSha256', manifest.fixtureJsonlSha256, sha256(expectedFixtureJsonl)],
['traceJsonlSha256', manifest.traceJsonlSha256, sha256(rawTraceJsonl)],
];
for (const [label, actual, expected] of checks) {
if (actual !== expected) {
throw new Error(`battle reference manifest ${label} mismatch`);
}
}
const traces = traceLines.map((line) => JSON.parse(line) as ReferenceTrace);
traces.forEach((trace, index) => assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `trace[${index}]`));
return traces;
};
const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => {
fixtureLines.forEach((line, index) => assertFixtureGeneralCityContract(line, `fixture[${index}]`));
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
if (precomputedTracePath) {
const traces = fs
.readFileSync(path.resolve(precomputedTracePath), 'utf8')
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line) as ReferenceTrace);
if (traces.length < fixtureLines.length) {
throw new Error(`precomputed ref corpus has ${traces.length} traces for ${fixtureLines.length} fixtures`);
}
return traces.slice(0, fixtureLines.length);
return readBoundPrecomputedTraces(precomputedTracePath, fixtureLines);
}
const compareContainer = process.env.REF_COMPARE_CONTAINER;
if (compareContainer) {
@@ -259,84 +568,54 @@ const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]):
maxBuffer: 512 * 1024 * 1024,
}
);
return stdout
const traces = stdout
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line) as ReferenceTrace);
if (traces.length !== fixtureLines.length) {
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
}
traces.forEach((trace, index) =>
assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `container trace[${index}]`)
);
return traces;
}
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
if (!compareSourceRoot) {
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
}
const resolvedCompareRoot = path.resolve(compareSourceRoot);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-'));
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
recursive: true,
filter: (source) => {
const relative = path.relative(resolvedCompareRoot, source);
return !(
relative === '.git' ||
relative.startsWith(`.git${path.sep}`) ||
relative === 'vendor' ||
relative.startsWith(`vendor${path.sep}`) ||
relative === 'd_log' ||
relative.startsWith(`d_log${path.sep}`) ||
relative === path.join('hwe', 'd_setting') ||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
);
},
const stdout = runReferenceSourceScript({
workspaceRoot,
compareSourceRoot,
script: 'hwe/compare/battle_trace.php',
args: ['--jsonl'],
input: normalizeJsonlForManifest(fixtureLines),
maxBuffer: 512 * 1024 * 1024,
});
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
try {
const traces: ReferenceTrace[] = [];
const chunkSize = 200;
for (let offset = 0; offset < fixtureLines.length; offset += chunkSize) {
const chunk = fixtureLines.slice(offset, offset + chunkSize);
const stdout = execFileSync(
'docker',
[
'run',
'--rm',
'-i',
'-v',
`${runtimeRoot}:/var/www/html`,
'-v',
`${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
'-v',
`${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
'sam-rebuild-ref-php:8.3',
'php',
'-d',
'display_errors=0',
'-d',
'log_errors=0',
'/var/www/html/hwe/compare/battle_trace.php',
'--jsonl',
],
{
input: `${chunk.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
maxBuffer: 512 * 1024 * 1024,
}
);
traces.push(
...stdout
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line) as ReferenceTrace)
);
}
if (traces.length !== fixtureLines.length) {
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
}
return traces;
} finally {
fs.rmSync(runtimeRoot, { recursive: true, force: true });
const traces = stdout
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line) as ReferenceTrace);
if (traces.length !== fixtureLines.length) {
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
}
traces.forEach((trace, index) =>
assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `source trace[${index}]`)
);
return traces;
};
const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record<string, ReferenceItemMetadata> => {
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
if (compareSourceRoot) {
const stdout = runReferenceSourceScript({
workspaceRoot,
compareSourceRoot,
script: 'hwe/compare/item_catalog.php',
input: JSON.stringify(itemKeys),
});
return JSON.parse(stdout) as Record<string, ReferenceItemMetadata>;
}
const stdout = execFileSync(
'docker',
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'],
@@ -351,6 +630,15 @@ const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Rec
};
const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog => {
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
if (compareSourceRoot) {
const stdout = runReferenceSourceScript({
workspaceRoot,
compareSourceRoot,
script: 'hwe/compare/trait_catalog.php',
});
return JSON.parse(stdout) as ReferenceTraitCatalog;
}
const stdout = execFileSync(
'docker',
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'],
@@ -366,45 +654,153 @@ const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
expect(typeof actual, `${label}: actual type`).toBe('number');
expect(typeof expected, `${label}: reference type`).toBe('number');
if (process.env['STRICT_BATTLE_PARITY'] === '1') {
expect(actual, `${label}: exact battle parity`).toBe(expected);
const actualNumber = actual as number;
const expectedNumber = expected as number;
expect(Number.isFinite(actualNumber), `${label}: actual must be finite`).toBe(true);
expect(Number.isFinite(expectedNumber), `${label}: reference must be finite`).toBe(true);
if (Number.isSafeInteger(actualNumber) && Number.isSafeInteger(expectedNumber)) {
expect(actualNumber, `${label}: integer battle parity`).toBe(expectedNumber);
return;
}
const reference = expected as number;
const configuredRelativeTolerance = Number.parseFloat(
process.env['BATTLE_TRACE_RELATIVE_TOLERANCE'] ?? '0.01'
);
if (!Number.isFinite(configuredRelativeTolerance) || configuredRelativeTolerance < 0) {
throw new Error('BATTLE_TRACE_RELATIVE_TOLERANCE must be a non-negative finite number');
}
const tolerance = Math.max(
Number.EPSILON * Math.max(1, Math.abs(reference)) * 8,
Math.abs(reference) * configuredRelativeTolerance
Number.EPSILON * Math.max(1, Math.abs(expectedNumber)) * 16,
Math.abs(expectedNumber) * 1e-12,
1e-12
);
expect(
Math.abs((actual as number) - reference),
`${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}`
Math.abs(actualNumber - expectedNumber),
`${label}: core=${String(actualNumber)}, ref=${String(expectedNumber)}, tolerance=${tolerance}`
).toBeLessThanOrEqual(tolerance);
};
const isCanonicalEmptyMapPath = (label: string): boolean =>
label.endsWith('.activatedSkills') || label.endsWith('.details');
const normalizeCanonicalEmptyMap = (value: unknown, label: string): unknown => {
if (isCanonicalEmptyMapPath(label) && Array.isArray(value) && value.length === 0) {
return {};
}
return value;
};
const assertCanonicalValue = (rawActual: unknown, rawExpected: unknown, label: string): void => {
const actual = normalizeCanonicalEmptyMap(rawActual, label);
const expected = normalizeCanonicalEmptyMap(rawExpected, label);
if (typeof actual === 'number' || typeof expected === 'number') {
expectNearlyEqual(actual, expected, label);
return;
}
if (Array.isArray(actual) || Array.isArray(expected)) {
expect(Array.isArray(actual), `${label}: core array type`).toBe(true);
expect(Array.isArray(expected), `${label}: ref array type`).toBe(true);
const actualArray = actual as unknown[];
const expectedArray = expected as unknown[];
expect(actualArray.length, `${label}: array length`).toBe(expectedArray.length);
for (let index = 0; index < expectedArray.length; index += 1) {
assertCanonicalValue(actualArray[index], expectedArray[index], `${label}[${index}]`);
}
return;
}
if (isRecord(actual) || isRecord(expected)) {
expect(isRecord(actual), `${label}: core object type`).toBe(true);
expect(isRecord(expected), `${label}: ref object type`).toBe(true);
const actualObject = actual as Record<string, unknown>;
const expectedObject = expected as Record<string, unknown>;
const actualKeys = Object.keys(actualObject)
.filter((key) => actualObject[key] !== undefined)
.sort();
const expectedKeys = Object.keys(expectedObject)
.filter((key) => expectedObject[key] !== undefined)
.sort();
expect(actualKeys, `${label}: object keys`).toEqual(expectedKeys);
for (const key of expectedKeys) {
assertCanonicalValue(actualObject[key], expectedObject[key], `${label}.${key}`);
}
return;
}
expect(actual, label).toBe(expected);
};
const buildCapturedLogBuckets = (
capture: CapturedCoreLogger | null | undefined,
year: number,
month: number
): ReferenceLogBuckets => {
const buckets: ReferenceLogBuckets = {
generalHistoryLog: [],
generalActionLog: [],
generalBattleResultLog: [],
generalBattleDetailLog: [],
nationalHistoryLog: [],
globalHistoryLog: [],
globalActionLog: [],
};
for (const entry of capture?.entries ?? []) {
const text = formatLogText(entry.text, entry.format ?? LogFormat.RAWTEXT, year, month);
if (entry.scope === LogScope.GENERAL) {
switch (entry.category) {
case LogCategory.HISTORY:
buckets.generalHistoryLog.push(text);
break;
case LogCategory.ACTION:
buckets.generalActionLog.push(text);
break;
case LogCategory.BATTLE_BRIEF:
buckets.generalBattleResultLog.push(text);
break;
case LogCategory.BATTLE_DETAIL:
buckets.generalBattleDetailLog.push(text);
break;
default:
break;
}
} else if (entry.scope === LogScope.NATION && entry.category === LogCategory.HISTORY) {
buckets.nationalHistoryLog.push(text);
} else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY) {
buckets.globalHistoryLog.push(text);
} else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY) {
buckets.globalActionLog.push(text);
}
}
return buckets;
};
const assertAllLogBucketsParity = (
capture: CoreLogCapture,
reference: ReferenceTrace,
fixture: BattleSimRequestPayload,
label: string
): void => {
assertCanonicalValue(
buildCapturedLogBuckets(capture.byGeneralId.get(fixture.attackerGeneral.no), fixture.year, fixture.month),
reference.logs.attacker,
`${label}.logs.attacker`
);
for (const defender of fixture.defenderGenerals) {
assertCanonicalValue(
buildCapturedLogBuckets(capture.byGeneralId.get(defender.no), fixture.year, fixture.month),
reference.logs.defenders[String(defender.no)] ?? {
generalHistoryLog: [],
generalActionLog: [],
generalBattleResultLog: [],
generalBattleDetailLog: [],
nationalHistoryLog: [],
globalHistoryLog: [],
globalActionLog: [],
},
`${label}.logs.defenders.${defender.no}`
);
}
assertCanonicalValue(
buildCapturedLogBuckets(capture.city, fixture.year, fixture.month),
reference.logs.city,
`${label}.logs.city`
);
};
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
Array.isArray(value) && value.length === 0 ? {} : value;
const describeSequenceDifference = (label: string, actual: unknown[], expected: unknown[]): string | null => {
const commonLength = Math.min(actual.length, expected.length);
for (let index = 0; index < commonLength; index += 1) {
if (JSON.stringify(actual[index]) !== JSON.stringify(expected[index])) {
const start = Math.max(0, index - 2);
const end = index + 3;
return `${label}[${index}]: core=${JSON.stringify(actual.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`;
}
}
if (actual.length !== expected.length) {
return `${label} length: core=${actual.length} ref=${expected.length}`;
}
return null;
};
const describeTextDifference = (actual: string | undefined, expected: string): string => {
const actualText = actual ?? '';
let index = 0;
@@ -448,19 +844,23 @@ const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null):
arguments: normalizeRandomArguments(args),
result,
}));
const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng);
if (rngDifference) {
throw new Error(rngDifference);
}
assertCanonicalValue(normalizedCoreRng, normalizedReferenceRng, 'rng');
assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng');
};
const assertTraceParity = (
coreEvents: WarBattleTraceEvent[],
reference: ReferenceTrace,
coreRng: TracingRng | null
coreRng: TracingRng | null,
coreOutcome: WarBattleOutcome | null
): void => {
const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null;
const comparableCoreEvents = defenderOrderEvent ? coreEvents.slice(1) : coreEvents;
const comparableCoreEvents = (defenderOrderEvent ? coreEvents.slice(1) : coreEvents).map((event, seq) => ({
...event,
// Core emits one comparison-only defender_order event before the Ref
// processWar_NG sequence. Renumber only the canonical shared sequence.
seq,
}));
if (reference.defenderOrder) {
// Ref retains non-participating (order <= 0) defenders at the tail and
// stops when it reaches them. Core discards them before sorting. The
@@ -470,12 +870,14 @@ const assertTraceParity = (
after: reference.defenderOrder.after.filter(({ order }) => order > 0),
};
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual(
effectiveReferenceOrder.before.map(({ id }) => id)
);
expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual(
effectiveReferenceOrder.after.map(({ id }) => id)
);
expect(
coreOrder?.before.map(({ id }) => id),
'defender order before IDs'
).toEqual(effectiveReferenceOrder.before.map(({ id }) => id));
expect(
coreOrder?.after.map(({ id }) => id),
'defender order after IDs'
).toEqual(effectiveReferenceOrder.after.map(({ id }) => id));
for (const side of ['before', 'after'] as const) {
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
expectNearlyEqual(
@@ -487,37 +889,145 @@ const assertTraceParity = (
}
}
assertRngParity(reference, coreRng);
const coreEventNames = comparableCoreEvents.map((event) => event.event);
const referenceEventNames = reference.events.map((event) => event.event);
expect(
coreEventNames,
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
).toEqual(referenceEventNames);
assertCanonicalValue(comparableCoreEvents, reference.events, 'events');
assertFinalOutcomeParity(coreOutcome, coreEvents, reference);
};
for (let index = 0; index < reference.events.length; index += 1) {
const core = comparableCoreEvents[index]!;
const ref = reference.events[index]!;
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`);
expect(core.attacker.phase, `event ${index} attacker.phase`).toBe(ref.attacker.phase);
expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase);
expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase);
if (core.defender && ref.defender) {
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase);
expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase);
expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase);
} else {
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
}
if (core.event === 'phase_damage') {
for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) {
expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`);
}
const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => {
const value = general.meta[key];
return typeof value === 'number' ? value : 0;
};
const buildOutcomeGeneralSnapshot = (
transient: WarBattleTraceUnitSnapshot,
general: WarBattleOutcome['attacker'],
report: WarBattleOutcome['reports'][number],
activatedSkills: Record<string, number>
): WarBattleTraceUnitSnapshot => ({
...transient,
kind: 'general',
id: general.id,
name: general.name,
isAttacker: report.isAttacker,
crewTypeId: general.crewTypeId,
phase: report.phase ?? transient.phase,
hp: general.crew,
killed: report.killed,
dead: report.dead,
activatedSkills,
general: {
crew: general.crew,
rice: general.rice,
train: general.train,
atmos: general.atmos,
injury: general.injury,
experience: general.experience,
dedication: general.dedication,
dex1: outcomeMetaNumber(general, 'dex1'),
dex2: outcomeMetaNumber(general, 'dex2'),
dex3: outcomeMetaNumber(general, 'dex3'),
dex4: outcomeMetaNumber(general, 'dex4'),
dex5: outcomeMetaNumber(general, 'dex5'),
},
});
const assertFinalOutcomeParity = (
coreOutcome: WarBattleOutcome | null,
coreEvents: WarBattleTraceEvent[],
reference: ReferenceTrace
): void => {
expect(coreOutcome, 'comparison onBattleResolved callback').not.toBeNull();
if (!coreOutcome) {
return;
}
const finalEvent = coreEvents.at(-1);
expect(finalEvent?.event, 'final battle trace event').toBe('battle_end');
if (!finalEvent) {
return;
}
const attackerReport = coreOutcome.reports.find(
(report) => report.type === 'general' && report.id === coreOutcome.attacker.id && report.isAttacker
);
const cityReport = coreOutcome.reports.find(
(report) => report.type === 'city' && report.id === coreOutcome.defenderCity.id
);
expect(attackerReport, 'final attacker report').toBeDefined();
expect(cityReport, 'final city report').toBeDefined();
if (!attackerReport || !cityReport) {
return;
}
const latestDefenderSnapshots = new Map<number, WarBattleTraceUnitSnapshot>();
for (const event of coreEvents) {
if (event.defender?.kind === 'general') {
latestDefenderSnapshots.set(event.defender.id, event.defender);
}
}
const metrics = coreOutcome.metrics;
const coreAttacker = buildOutcomeGeneralSnapshot(
finalEvent.attacker,
coreOutcome.attacker,
attackerReport,
metrics?.attackerActivatedSkills ?? {}
);
const coreCity: WarBattleTraceUnitSnapshot = {
...finalEvent.city,
kind: 'city',
id: coreOutcome.defenderCity.id,
name: coreOutcome.defenderCity.name,
isAttacker: cityReport.isAttacker,
phase: cityReport.phase ?? finalEvent.city.phase,
killed: cityReport.killed,
dead: cityReport.dead,
cityState: {
defence: coreOutcome.defenderCity.defence,
wall: coreOutcome.defenderCity.wall,
population: coreOutcome.defenderCity.population,
},
};
const coreFinishedDefenders = reference.finishedDefenders.map((expectedSnapshot) => {
if (expectedSnapshot.kind === 'city') {
return coreCity;
}
const defenderIndex = coreOutcome.defenders.findIndex((general) => general.id === expectedSnapshot.id);
expect(defenderIndex, `final defender ${expectedSnapshot.id} exists`).toBeGreaterThanOrEqual(0);
const general = coreOutcome.defenders[defenderIndex];
const orderedDefenderReports = coreOutcome.reports.filter(
(candidate) => candidate.type === 'general' && !candidate.isAttacker
);
const metricIndex = orderedDefenderReports.findIndex((candidate) => candidate.id === expectedSnapshot.id);
const report = metricIndex >= 0 ? orderedDefenderReports[metricIndex] : undefined;
const transient = latestDefenderSnapshots.get(expectedSnapshot.id);
expect(general, `final defender ${expectedSnapshot.id} state`).toBeDefined();
expect(report, `final defender ${expectedSnapshot.id} report`).toBeDefined();
expect(transient, `final defender ${expectedSnapshot.id} transient snapshot`).toBeDefined();
if (!general || !report || !transient) {
return expectedSnapshot;
}
return buildOutcomeGeneralSnapshot(
transient,
general,
report,
metrics?.defenderActivatedSkills[metricIndex] ?? {}
);
});
assertCanonicalValue(
{
conquered: coreOutcome.conquered,
attacker: coreAttacker,
city: coreCity,
finishedDefenders: coreFinishedDefenders,
},
{
conquered: reference.conquered,
attacker: reference.attacker,
city: reference.city,
finishedDefenders: reference.finishedDefenders,
},
'finalOutcome'
);
};
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
@@ -533,8 +1043,27 @@ const battleCorpusPath = process.env.BATTLE_CORPUS_PATH;
const itWithBattleCorpus = battleCorpusPath ? it : it.skip;
describeWithReference('ref ↔ core2026 battle differential', () => {
it('rejects battle fixtures whose general current-city contract is missing or inconsistent', () => {
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
);
const missingCity = structuredClone(fixture) as BattleSimRequestPayload & {
attackerGeneral: BattleSimGeneralPayload & { city?: number };
};
delete missingCity.attackerGeneral.city;
expect(() => assertFixtureGeneralCityContract(JSON.stringify(missingCity), 'missing-city')).toThrow(
'attackerGeneral.city must be an explicit positive integer'
);
const wrongDefenderCity = structuredClone(fixture);
wrongDefenderCity.defenderGenerals[0]!.city = fixture.attackerCity.city;
expect(() => assertFixtureGeneralCityContract(JSON.stringify(wrongDefenderCity), 'wrong-city')).toThrow(
'defenderGeneral[0].city=1 must equal current city 2'
);
});
itWithBattleCorpus(
'replays a captured battle corpus with matching trace, RNG, skills, outcome, and attacker logs',
'replays a captured battle corpus with matching trace, RNG, full outcome, and all log buckets [conditional: BATTLE_CORPUS_PATH]',
{ timeout: 600_000 },
() => {
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
@@ -560,7 +1089,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const failures: string[] = [];
const categoryCounts = new Map<string, number>();
const recordFailure = (category: string, index: number, fixture: BattleSimRequestPayload, detail: string) => {
const recordFailure = (
category: string,
index: number,
fixture: BattleSimRequestPayload,
detail: string
) => {
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
if (failures.length < 40) {
failures.push(
@@ -593,7 +1127,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const reference = referenceTraces[index]!;
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const coreResult = processBattleSimJob(
{
...fixture,
@@ -604,50 +1140,27 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
{
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
}
);
try {
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
} catch (error) {
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
}
const finalReference = reference.events.at(-1);
if (finalReference) {
const outcome = {
phase: coreResult.phase,
killed: coreResult.killed,
dead: coreResult.dead,
};
const expectedOutcome = {
phase: finalReference.attacker.phase,
killed: finalReference.attacker.killed,
dead: finalReference.attacker.dead,
};
if (JSON.stringify(outcome) !== JSON.stringify(expectedOutcome)) {
recordFailure(
'outcome',
index,
fixture,
`core=${JSON.stringify(outcome)} ref=${JSON.stringify(expectedOutcome)}`
);
}
const coreSkills = coreResult.attackerSkills ?? {};
const rawReferenceSkills = finalReference.attacker.activatedSkills;
const referenceSkills = Array.isArray(rawReferenceSkills) ? {} : (rawReferenceSkills ?? {});
if (JSON.stringify(coreSkills) !== JSON.stringify(referenceSkills)) {
recordFailure(
'skills',
index,
fixture,
`core=${JSON.stringify(coreSkills)} ref=${JSON.stringify(referenceSkills)}`
);
}
try {
assertAllLogBucketsParity(coreLogs, reference, fixture, `fixture[${index}]`);
} catch (error) {
recordFailure('logs', index, fixture, error instanceof Error ? error.message : String(error));
}
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
@@ -734,6 +1247,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const coreResult = processBattleSimJob(
{
...base,
@@ -744,16 +1258,19 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
{
trace: (event) => coreEvents.push(event),
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
}
);
try {
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched');
if (entry.directCity) {
expect(
@@ -792,7 +1309,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
);
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
'적군의 전멸에 <span style="color: cyan;">진격</span>이 이어집니다!'
);
}
} catch (error) {
@@ -874,17 +1391,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
base.attackerGeneral.strength = 85;
base.attackerGeneral.intel = 80;
base.attackerGeneral.special =
entry.kind === 'dualSlot'
? entry.special
: entry.kind === 'eventDomestic'
? entry.key
: 'None';
entry.kind === 'dualSlot' ? entry.special : entry.kind === 'eventDomestic' ? entry.key : 'None';
base.attackerGeneral.special2 =
entry.kind === 'dualSlot'
? entry.special2
: entry.kind === 'war'
? entry.key
: 'None';
entry.kind === 'dualSlot' ? entry.special2 : entry.kind === 'war' ? entry.key : 'None';
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
if (entry.kind === 'nation') {
base.attackerNation.type = entry.key;
@@ -892,6 +1401,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(
{
...base,
@@ -901,14 +1411,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
{
trace: (event) => coreEvents.push(event),
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
}
);
try {
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
assertTraceParity(
coreEvents,
runReferenceTrace(workspaceRoot!, JSON.stringify(base)),
coreRng,
coreOutcome
);
} catch (error) {
throw new Error(
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
@@ -989,7 +1507,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
};
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(
{
...base,
@@ -999,13 +1519,45 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
{
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
}
);
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, base, 'trait-item.non-stacking-musang');
const runFirstPhasePower = (fixture: BattleSimRequestPayload & { startYear: number }): number => {
const events: WarBattleTraceEvent[] = [];
processBattleSimJob(
{
...fixture,
unitSet,
config,
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
},
{ trace: (event) => events.push(event) }
);
const firstPhase = events.find((event) => event.event === 'phase_power');
expect(firstPhase, '무쌍 first phase power').toBeDefined();
return firstPhase!.attacker.rawWarPower;
};
const combinedPower = coreEvents.find((event) => event.event === 'phase_power')!.attacker.rawWarPower;
const traitOnly = structuredClone(base);
traitOnly.attackerGeneral.item = 'None';
const itemOnly = structuredClone(base);
itemOnly.attackerGeneral.special2 = 'None';
const control = structuredClone(itemOnly);
control.attackerGeneral.item = 'None';
expect(combinedPower, 'duplicate 무쌍 does not stack over trait').toBe(runFirstPhasePower(traitOnly));
expect(combinedPower, 'duplicate 무쌍 does not stack over item').toBe(runFirstPhasePower(itemOnly));
expect(combinedPower, '무쌍 has a real battle effect').not.toBe(runFirstPhasePower(control));
});
it('matches 척사 items against region-restricted troops', () => {
@@ -1033,7 +1585,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
base.attackerGeneral.crew = 5000;
base.defenderGenerals[0]!.crewtype = 1101;
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(
{
...base,
@@ -1043,17 +1597,39 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
},
{
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
}
);
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, base, `item.${itemKey}.region-opponent`);
const control = structuredClone(base);
control.attackerGeneral.item = 'None';
const controlEvents: WarBattleTraceEvent[] = [];
processBattleSimJob(
{
...control,
unitSet,
config,
time: { year: control.year, month: control.month, startYear: control.startYear },
},
{ trace: (event) => controlEvents.push(event) }
);
const itemPower = coreEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower;
const controlPower = controlEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower;
expect(itemPower, `${itemKey}: region troop effect is observed`).not.toBe(controlPower);
}
});
it('keeps the detailed event sequence and phase values within 1%', () => {
it('matches the complete canonical event, RNG, state, and logger snapshots', () => {
const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
@@ -1086,18 +1662,271 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const coreResult = processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
expect(coreResult.result).toBe(true);
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, request, 'basic-infantry');
});
it('matches officer levels 1-4 in assigned and off-city battles on both sides', () => {
const unitSet = readJson<UnitSetDefinition>(
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
);
const config: WarEngineConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 1000,
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
};
const cases: Array<{
role: 'attacker' | 'defender';
level: number;
assigned: boolean;
fixture: BattleSimRequestPayload & { startYear: number };
}> = [];
for (const role of ['attacker', 'defender'] as const) {
for (const level of [1, 2, 3, 4]) {
for (const assigned of [true, false]) {
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
);
fixture.seed = `battle-differential-officer-${role}-${level}-${assigned ? 'assigned' : 'off-city'}`;
const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!;
const counterpart = role === 'attacker' ? fixture.defenderGenerals[0]! : fixture.attackerGeneral;
const currentCity = role === 'attacker' ? fixture.attackerCity.city : fixture.defenderCity.city;
const counterpartCity = role === 'attacker' ? fixture.defenderCity.city : fixture.attackerCity.city;
general.officer_level = level;
general.officer_city = assigned ? currentCity : currentCity + 1000;
// Keep the opposite unit neutral so the subject officer's attack/defence
// multiplier is observable without the counterpart's level-3 5% modifier.
counterpart.officer_level = 1;
counterpart.officer_city = counterpartCity;
cases.push({ role, level, assigned, fixture });
}
}
}
const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture));
const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines);
const officerSignatures = new Map<string, string>();
cases.forEach(({ role, level, assigned, fixture }, index) => {
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(
{
...fixture,
unitSet,
config,
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
},
{
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return coreRng.createRandUtil();
},
}
);
const reference = references[index]!;
const label = `officer.${role}.level${level}.${assigned ? 'assigned' : 'off-city'}`;
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, fixture, label);
const phasePower = coreEvents.find((event) => event.event === 'phase_power');
const snapshot = role === 'attacker' ? phasePower?.attacker : phasePower?.defender;
const counterpartSnapshot = role === 'attacker' ? phasePower?.defender : phasePower?.attacker;
expect(snapshot?.kind, `${label}: participating general`).toBe('general');
expect(counterpartSnapshot?.kind, `${label}: counterpart general`).toBe('general');
officerSignatures.set(
`${role}-${level}-${assigned}`,
JSON.stringify({
subjectRawWarPower: snapshot!.rawWarPower,
counterpartWarPowerMultiplier: counterpartSnapshot!.warPowerMultiplier,
})
);
});
for (const role of ['attacker', 'defender'] as const) {
expect(officerSignatures.get(`${role}-1-true`), `${role}: level 1 ignores assignment`).toBe(
officerSignatures.get(`${role}-1-false`)
);
for (const level of [2, 3, 4]) {
expect(
officerSignatures.get(`${role}-${level}-false`),
`${role}: off-city level ${level} falls back`
).toBe(officerSignatures.get(`${role}-1-true`));
expect(
officerSignatures.get(`${role}-${level}-true`),
`${role}: assigned level ${level} keeps the officer battle signature`
).not.toBe(officerSignatures.get(`${role}-${level}-false`));
}
}
});
it('matches every distinct CHE crew battle signature on attacker and defender paths', { timeout: 180_000 }, () => {
const unitSet = readJson<UnitSetDefinition>(
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
);
const crewTypes = unitSet.crewTypes ?? [];
const signatures = crewTypes.map((crewType) =>
JSON.stringify({
armType: crewType.armType,
attack: crewType.attack,
defence: crewType.defence,
speed: crewType.speed,
avoid: crewType.avoid,
magicCoef: crewType.magicCoef,
rice: crewType.rice,
attackCoef: crewType.attackCoef,
defenceCoef: crewType.defenceCoef,
iActionList: crewType.iActionList,
initSkillTrigger: crewType.initSkillTrigger,
phaseSkillTrigger: crewType.phaseSkillTrigger,
})
);
expect(new Set(signatures).size, 'unitset_che distinct battle signatures').toBe(crewTypes.length);
const config: WarEngineConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 1000,
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
};
const cases: Array<{
role: 'attacker' | 'defender';
crewTypeId: number;
fixture: BattleSimRequestPayload & { startYear: number };
}> = [];
const crewFilter = process.env.CREW_PARITY_FILTER;
const crewRoleFilter = process.env.CREW_PARITY_ROLE;
for (const crewType of crewTypes.filter((entry) => entry.id !== config.castleCrewTypeId)) {
if (crewFilter && String(crewType.id) !== crewFilter) {
continue;
}
for (const role of ['attacker', 'defender'] as const) {
if (crewRoleFilter && role !== crewRoleFilter) {
continue;
}
// In the official assertion-enabled Ref image, attacker-side
// 정란/벽력거 routes the castle first and then the castle's
// general-only phase trigger aborts. Their distinct phase skill
// remains covered on the defender path; the Ref runtime defect is
// documented as an explicit remaining boundary.
if (role === 'attacker' && (crewType.id === 1500 || crewType.id === 1502)) {
continue;
}
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
);
fixture.seed = `battle-differential-crew-${role}-${crewType.id}`;
// Keep every synthetic pairing in general-vs-general combat for the
// whole phase budget. City combat is covered separately and the Ref
// castle unit intentionally carries a general-only phase assertion.
fixture.attackerGeneral.crew = 50000;
fixture.attackerGeneral.rice = 1000000;
fixture.attackerGeneral.leadership = 90;
fixture.attackerGeneral.strength = 90;
fixture.attackerGeneral.intel = 90;
fixture.defenderGenerals[0]!.crew = 50000;
fixture.defenderGenerals[0]!.rice = 1000000;
fixture.defenderGenerals[0]!.leadership = 85;
fixture.defenderGenerals[0]!.strength = 85;
fixture.defenderGenerals[0]!.intel = 85;
fixture.defenderCity.def = 400;
fixture.defenderCity.wall = 400;
fixture.defenderCity.def_max = 400;
fixture.defenderCity.wall_max = 400;
const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!;
general.crewtype = crewType.id;
general.dex1 = 12000;
general.dex2 = 12000;
general.dex3 = 12000;
general.dex4 = 12000;
general.dex5 = 12000;
cases.push({ role, crewTypeId: crewType.id, fixture });
}
}
const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture));
const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines);
cases.forEach(({ role, crewTypeId, fixture }, index) => {
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(
{
...fixture,
unitSet,
config,
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
},
{
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return coreRng.createRandUtil();
},
}
);
const reference = references[index]!;
const label = `crew.${role}.${crewTypeId}`;
try {
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, fixture, label);
} catch (error) {
const debug =
process.env.CREW_PARITY_DEBUG === '1'
? ` coreEvents=${JSON.stringify(coreEvents.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))} refEvents=${JSON.stringify(reference.events.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))}`
: '';
throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}${debug}`, {
cause: error,
});
}
if (role === 'defender' && (crewTypeId === 1500 || crewTypeId === 1502)) {
expect(
coreEvents.some((event) => (event.defender?.activatedSkills['선제'] ?? 0) > 0),
`${label}: 정란/벽력거 선제사격 must activate`
).toBe(true);
}
if (role === 'defender' && crewTypeId === 1503) {
expect(
coreEvents.some((event) => (event.defender?.activatedSkills['저지'] ?? 0) > 0),
`${label}: 목우 저지 must activate for the fixed seed`
).toBe(true);
}
});
});
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
@@ -1142,11 +1971,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const result = processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
@@ -1157,12 +1990,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
)
).toBe(true);
assertRngParity(reference, coreRng);
const finalReference = reference.events.at(-1)!;
expect({ phase: result.phase, killed: result.killed }).toEqual({
phase: finalReference.attacker.phase,
killed: finalReference.attacker.killed,
});
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
// Ref keeps the injury-adjusted intelligence fraction here:
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
@@ -1185,7 +2013,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
{
rngFactory: (seed) => {
fractionalCoreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(fractionalCoreRng);
return fractionalCoreRng.createRandUtil();
},
}
);
@@ -1262,18 +2090,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const result = processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
expect(result.result).toBe(true);
expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2);
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
});
it('matches siege dexterity and castle damage handling', () => {
@@ -1315,18 +2147,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const result = processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
expect(result.result).toBe(true);
expect(reference.events.some((event) => event.defender?.kind === 'city')).toBe(true);
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
});
it('matches the no-defender supply-retreat branch without consuming RNG', () => {
@@ -1358,18 +2194,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
};
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
const result = processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
expect(result.result).toBe(true);
expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']);
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
});
it('matches every scenario item in an attacker battle simulation', { timeout: 180_000 }, () => {
@@ -1450,17 +2290,24 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
time: { year: base.year, month: base.month, startYear: base.startYear },
};
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
try {
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, base, `item.attacker.${itemKey}`);
} catch (error) {
const debug =
process.env['ITEM_PARITY_DEBUG'] === '1'
@@ -1558,17 +2405,24 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
time: { year: base.year, month: base.month, startYear: base.startYear },
};
const coreEvents: WarBattleTraceEvent[] = [];
const coreLogs = createCoreLogCapture();
let coreRng: TracingRng | null = null;
let coreOutcome: WarBattleOutcome | null = null;
processBattleSimJob(payload, {
trace: (event) => coreEvents.push(event),
loggerFactory: coreLogs.loggerFactory,
onBattleResolved: (outcome) => {
coreOutcome = outcome;
},
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
return coreRng.createRandUtil();
},
});
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
try {
assertTraceParity(coreEvents, reference, coreRng);
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
assertAllLogBucketsParity(coreLogs, reference, base, `item.defender.${itemKey}`);
} catch (error) {
const debug =
process.env['ITEM_PARITY_DEBUG'] === '1'
@@ -210,7 +210,9 @@ integration('live sortie PostgreSQL persistence retry', () => {
commandProfile: createCoreTurnCommandProfile(request),
});
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
schedule: {
entries: [{ startMinute: 0, tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)) }],
},
generalTurnHandler: handler,
});
const actor = world.getGeneralById(request.actorGeneralId);