merge: 전투·명예·유산 교차기수 패리티를 main에 반영한다
This commit is contained in:
@@ -2,36 +2,27 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
import { asNumber, asRecord, parseJson, LiteHashDRBG, type TurnDaemonInheritanceAction } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
ItemLoader,
|
ItemLoader,
|
||||||
isItemKey,
|
isItemKey,
|
||||||
loadWarTraitModules,
|
loadWarTraitModules,
|
||||||
sendMessage,
|
|
||||||
WarTraitLoader,
|
WarTraitLoader,
|
||||||
WAR_TRAIT_KEYS,
|
WAR_TRAIT_KEYS,
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
isCentennialStatResetAllowed,
|
isCentennialStatResetAllowed,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
|
import type { InheritBuffType, ItemSlot } from '@sammo-ts/logic';
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import {
|
import {
|
||||||
appendInheritanceLog,
|
|
||||||
buildResetCost,
|
buildResetCost,
|
||||||
computeInheritanceItems,
|
computeInheritanceItems,
|
||||||
readInheritancePoint,
|
|
||||||
readUserStateMeta,
|
|
||||||
resolveInheritConstants,
|
resolveInheritConstants,
|
||||||
setInheritancePoint,
|
|
||||||
sumInheritanceItems,
|
sumInheritanceItems,
|
||||||
writeUserStateMeta,
|
|
||||||
} from '../../services/inheritance.js';
|
} from '../../services/inheritance.js';
|
||||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||||
import { buildTargetFromGeneral } from '../../messages/targets.js';
|
|
||||||
import { insertMessage } from '../../messages/store.js';
|
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
|
||||||
|
|
||||||
const BUFF_KEYS: InheritBuffType[] = [
|
const BUFF_KEYS: InheritBuffType[] = [
|
||||||
'warAvoidRatio',
|
'warAvoidRatio',
|
||||||
@@ -46,17 +37,6 @@ const BUFF_KEYS: InheritBuffType[] = [
|
|||||||
|
|
||||||
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||||
|
|
||||||
const BUFF_LABELS: Record<InheritBuffType, string> = {
|
|
||||||
warAvoidRatio: '회피 확률 증가',
|
|
||||||
warCriticalRatio: '필살 확률 증가',
|
|
||||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
|
||||||
domesticSuccessProb: '내정 성공률 증가',
|
|
||||||
domesticFailProb: '내정 실패율 감소',
|
|
||||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
|
||||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
|
||||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
|
||||||
};
|
|
||||||
|
|
||||||
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
||||||
|
|
||||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||||
@@ -74,13 +54,6 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
|
||||||
|
|
||||||
const readStringList = (raw: unknown): string[] => {
|
|
||||||
const parsed = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
|
|
||||||
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||||
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||||
@@ -131,31 +104,24 @@ const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const patchGeneral = async (
|
const requestInheritanceAction = async (
|
||||||
ctx: Pick<GameApiContext, 'turnDaemon'>,
|
ctx: Pick<GameApiContext, 'turnDaemon' | 'requestId'>,
|
||||||
generalId: number,
|
userId: string,
|
||||||
patch: {
|
input: TurnDaemonInheritanceAction
|
||||||
meta?: Record<string, unknown>;
|
) => {
|
||||||
turnTime?: string;
|
|
||||||
stats?: {
|
|
||||||
leadership?: number;
|
|
||||||
strength?: number;
|
|
||||||
intelligence?: number;
|
|
||||||
};
|
|
||||||
specialWar?: string | null;
|
|
||||||
}
|
|
||||||
): Promise<void> => {
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'patchGeneral',
|
type: 'inheritanceAction',
|
||||||
generalId,
|
userId,
|
||||||
patch,
|
input,
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:inherit.${input.action}:engine:0:inheritanceAction` } : {}),
|
||||||
});
|
});
|
||||||
if (!result || result.type !== 'patchGeneral') {
|
if (!result || result.type !== 'inheritanceAction') {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
throw new TRPCError({ code: result.code, message: result.reason });
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
||||||
@@ -189,54 +155,6 @@ export const resolveResetTurnTimeBase = (options: {
|
|||||||
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
|
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
|
|
||||||
const raw = meta.season;
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return Math.floor(raw);
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return Math.floor(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readResetSeasons = (meta: Record<string, unknown>): number[] => {
|
|
||||||
if (!Array.isArray(meta.last_stat_reset)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return meta.last_stat_reset
|
|
||||||
.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null))
|
|
||||||
.filter((value): value is number => value !== null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => {
|
|
||||||
const total = weights.reduce((acc, value) => acc + value, 0);
|
|
||||||
if (total <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let cursor = rng.nextFloat1() * total;
|
|
||||||
for (let i = 0; i < weights.length; i += 1) {
|
|
||||||
cursor -= weights[i] ?? 0;
|
|
||||||
if (cursor <= 0) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return weights.length - 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => {
|
|
||||||
const bonusCount = rng.nextInt(2) + 3;
|
|
||||||
const bonus = [0, 0, 0] as [number, number, number];
|
|
||||||
for (let i = 0; i < bonusCount; i += 1) {
|
|
||||||
const index = pickWeightedIndex(rng, baseStats);
|
|
||||||
bonus[index] += 1;
|
|
||||||
}
|
|
||||||
return bonus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const inheritRouter = router({
|
export const inheritRouter = router({
|
||||||
getStatus: authedProcedure.query(async ({ ctx }) => {
|
getStatus: authedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
@@ -380,7 +298,7 @@ export const inheritRouter = router({
|
|||||||
});
|
});
|
||||||
return logs;
|
return logs;
|
||||||
}),
|
}),
|
||||||
buyHiddenBuff: authedProcedure
|
buyHiddenBuff: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(BUFF_KEYS),
|
type: z.enum(BUFF_KEYS),
|
||||||
@@ -393,56 +311,14 @@ export const inheritRouter = router({
|
|||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldState = await resolveWorld(ctx);
|
const result = await requestInheritanceAction(ctx, userId, {
|
||||||
const worldMeta = asRecord(worldState.meta);
|
action: 'buyHiddenBuff',
|
||||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
buffType: input.type,
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
level: input.level,
|
||||||
}
|
|
||||||
|
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, meta: true },
|
|
||||||
});
|
});
|
||||||
if (!general) {
|
return { ok: true, remainPoint: result.remainPoint };
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
|
||||||
const prevLevel = readBuffLevel(buff, input.type);
|
|
||||||
if (input.level === prevLevel) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
|
|
||||||
}
|
|
||||||
if (input.level < prevLevel) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 더 높은 등급을 구입했습니다.' });
|
|
||||||
}
|
|
||||||
const cost = inheritConst.inheritBuffPoints[input.level] - inheritConst.inheritBuffPoints[prevLevel];
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < cost) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffText = BUFF_LABELS[input.type];
|
|
||||||
const moreText = prevLevel > 0 ? '추가' : '';
|
|
||||||
buff[input.type] = input.level;
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
meta: {
|
|
||||||
...asRecord(general.meta),
|
|
||||||
inheritBuff: serializeBuffRecord(buff),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${cost} 포인트로 ${buffText} ${input.level} 단계 ${moreText}구입`
|
|
||||||
);
|
|
||||||
return { ok: true, remainPoint: currentPoint - cost };
|
|
||||||
}),
|
}),
|
||||||
setNextSpecialWar: authedProcedure
|
setNextSpecialWar: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
specialKey: z.string(),
|
specialKey: z.string(),
|
||||||
@@ -454,197 +330,32 @@ export const inheritRouter = router({
|
|||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldState = await resolveWorld(ctx);
|
await requestInheritanceAction(ctx, userId, { action: 'setNextSpecialWar', specialKey: input.specialKey });
|
||||||
const worldMeta = asRecord(worldState.meta);
|
|
||||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isWarTraitKey(input.specialKey)) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '잘못된 전투 특기입니다.' });
|
|
||||||
}
|
|
||||||
const config = asRecord(worldState.config);
|
|
||||||
const constValues = asRecord(config.const);
|
|
||||||
const allowedSpecialWar = Array.isArray(constValues.availableSpecialWar)
|
|
||||||
? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string')
|
|
||||||
: [];
|
|
||||||
if (allowedSpecialWar.length > 0 && !allowedSpecialWar.includes(input.specialKey)) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '허용되지 않은 전투 특기입니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < inheritConst.inheritSpecificSpecialPoint) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, meta: true, special2Code: true },
|
|
||||||
});
|
|
||||||
if (!general) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
if (general.special2Code === input.specialKey) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 보유하고 있습니다.' });
|
|
||||||
}
|
|
||||||
const meta = asRecord(general.meta);
|
|
||||||
const reservedSpecial =
|
|
||||||
typeof meta.inheritSpecificSpecialWar === 'string' ? meta.inheritSpecificSpecialWar : null;
|
|
||||||
if (reservedSpecial === input.specialKey) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 예약하였습니다.' });
|
|
||||||
}
|
|
||||||
if (reservedSpecial) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
|
|
||||||
const warName = warModule?.name ?? input.specialKey;
|
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
inheritSpecificSpecialWar: input.specialKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setInheritancePoint(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
'previous',
|
|
||||||
currentPoint - inheritConst.inheritSpecificSpecialPoint
|
|
||||||
);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${inheritConst.inheritSpecificSpecialPoint} 포인트로 다음 전투 특기로 ${warName} 지정`
|
|
||||||
);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
resetSpecialWar: authedProcedure.mutation(async ({ ctx }) => {
|
resetSpecialWar: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldState = await resolveWorld(ctx);
|
await requestInheritanceAction(ctx, userId, { action: 'resetSpecialWar' });
|
||||||
const worldMeta = asRecord(worldState.meta);
|
|
||||||
if (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 0) {
|
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, special2Code: true, meta: true },
|
|
||||||
});
|
|
||||||
if (!general) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
if (!general.special2Code || general.special2Code === 'None') {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 전투 특기가 공란입니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const currentLevel = asNumber(asRecord(general.meta).inheritResetSpecialWar, -1);
|
|
||||||
const nextLevel = currentLevel + 1;
|
|
||||||
const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel);
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < cost) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = asRecord(general.meta);
|
|
||||||
const prevList = readStringList(meta.prev_types_special2);
|
|
||||||
prevList.push(general.special2Code);
|
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
specialWar: null,
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
inheritResetSpecialWar: nextLevel,
|
|
||||||
prev_types_special2: prevList,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${cost} 포인트로 전투 특기 초기화`
|
|
||||||
);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
|
resetTurnTime: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldState = await resolveWorld(ctx);
|
const result = await requestInheritanceAction(ctx, userId, { action: 'resetTurnTime' });
|
||||||
const worldMeta = asRecord(worldState.meta);
|
return {
|
||||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
ok: true,
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
nextTurnTimeBase: result.nextTurnTimeBase!,
|
||||||
}
|
nextTurnTimeLabel: result.nextTurnTimeLabel!,
|
||||||
|
};
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, meta: true, turnTick: true },
|
|
||||||
});
|
|
||||||
if (!general) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const currentLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1);
|
|
||||||
const nextLevel = currentLevel + 1;
|
|
||||||
const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel);
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < cost) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const generalMeta = asRecord(general.meta);
|
|
||||||
const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0;
|
|
||||||
const seedTurnTime =
|
|
||||||
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
|
|
||||||
? rawSeedTurnTime
|
|
||||||
: typeof rawSeedTurnTime === 'bigint'
|
|
||||||
? Number(rawSeedTurnTime)
|
|
||||||
: 0;
|
|
||||||
const hiddenSeed =
|
|
||||||
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
|
||||||
? worldMeta.hiddenSeed
|
|
||||||
: 'inherit';
|
|
||||||
const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({
|
|
||||||
hiddenSeed,
|
|
||||||
userId,
|
|
||||||
previousTurnTimeBase: seedTurnTime,
|
|
||||||
tickSeconds: worldState.tickSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
meta: {
|
|
||||||
...generalMeta,
|
|
||||||
inheritResetTurnTime: nextLevel,
|
|
||||||
nextTurnTimeBase,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용`
|
|
||||||
);
|
|
||||||
return { ok: true, nextTurnTimeBase, nextTurnTimeLabel };
|
|
||||||
}),
|
}),
|
||||||
resetStat: authedProcedure
|
resetStat: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
leadership: z.number().int(),
|
leadership: z.number().int(),
|
||||||
@@ -658,191 +369,21 @@ export const inheritRouter = router({
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const worldState = await resolveWorld(ctx);
|
const result = await requestInheritanceAction(ctx, userId, {
|
||||||
const worldMeta = asRecord(worldState.meta);
|
action: 'resetStat',
|
||||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
leadership: input.leadership,
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
strength: input.strength,
|
||||||
}
|
intel: input.intel,
|
||||||
const config = asRecord(worldState.config);
|
...(input.inheritBonusStat ? { inheritBonusStat: input.inheritBonusStat } : {}),
|
||||||
const statConfig = asRecord(config.stat);
|
|
||||||
const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel);
|
|
||||||
const statMin = asNumber(statConfig.min, 1);
|
|
||||||
const statMax = asNumber(statConfig.max, 999);
|
|
||||||
|
|
||||||
const total = input.leadership + input.strength + input.intel;
|
|
||||||
if (total !== statTotal) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`,
|
|
||||||
});
|
});
|
||||||
}
|
return { ok: true, stats: result.stats! };
|
||||||
if (
|
|
||||||
input.leadership < statMin ||
|
|
||||||
input.strength < statMin ||
|
|
||||||
input.intel < statMin ||
|
|
||||||
input.leadership > statMax ||
|
|
||||||
input.strength > statMax ||
|
|
||||||
input.intel > statMax
|
|
||||||
) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '능력치 범위를 벗어났습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const bonus = input.inheritBonusStat ?? [0, 0, 0];
|
|
||||||
const bonusSum = bonus.reduce((acc, value) => acc + value, 0);
|
|
||||||
if (bonus.some((value) => value < 0)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '보너스 능력치가 음수입니다. 다시 입력해주세요!',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, npcState: true },
|
|
||||||
});
|
|
||||||
if (!general) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
if (general.npcState >= 2) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' });
|
|
||||||
}
|
|
||||||
if (!isCentennialStatResetAllowed(config)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
|
|
||||||
if (currentPoint < cost) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const seasonValue = resolveSeasonValue(worldMeta);
|
|
||||||
if (seasonValue !== null) {
|
|
||||||
const userState = await readUserStateMeta(ctx.db, userId);
|
|
||||||
const resetSeasons = readResetSeasons(userState);
|
|
||||||
if (resetSeasons.includes(seasonValue)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '이번 시즌에 이미 능력치를 초기화하셨습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalBonus =
|
|
||||||
bonusSum === 0
|
|
||||||
? buildRandomBonus(
|
|
||||||
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
|
|
||||||
[input.leadership, input.strength, input.intel]
|
|
||||||
)
|
|
||||||
: (bonus as [number, number, number]);
|
|
||||||
const nextStats = {
|
|
||||||
leadership: input.leadership + finalBonus[0],
|
|
||||||
strength: input.strength + finalBonus[1],
|
|
||||||
intel: input.intel + finalBonus[2],
|
|
||||||
};
|
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
stats: {
|
|
||||||
leadership: nextStats.leadership,
|
|
||||||
strength: nextStats.strength,
|
|
||||||
intelligence: nextStats.intel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정`
|
|
||||||
);
|
|
||||||
if (bonusSum > 0) {
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (cost > 0) {
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
|
||||||
}
|
|
||||||
if (seasonValue !== null) {
|
|
||||||
const userState = await readUserStateMeta(ctx.db, userId);
|
|
||||||
const resetSeasons = readResetSeasons(userState);
|
|
||||||
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
|
|
||||||
await writeUserStateMeta(ctx.db, userId, {
|
|
||||||
...userState,
|
|
||||||
last_stat_reset: nextSeasons,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ok: true, stats: nextStats };
|
|
||||||
}),
|
}),
|
||||||
buyRandomUnique: authedProcedure.mutation(async ({ ctx }) => {
|
buyRandomUnique: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const worldState = await resolveWorld(ctx);
|
await requestInheritanceAction(ctx, userId, { action: 'buyRandomUnique' });
|
||||||
const worldMeta = asRecord(worldState.meta);
|
|
||||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
|
||||||
}
|
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < inheritConst.inheritItemRandomPoint) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, meta: true },
|
|
||||||
});
|
|
||||||
if (!general) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
const meta = asRecord(general.meta);
|
|
||||||
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
inheritRandomUnique: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritItemRandomPoint);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${inheritConst.inheritItemRandomPoint} 포인트로 랜덤 유니크 구입`
|
|
||||||
);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
openUniqueAuction: engineAuthedProcedure
|
openUniqueAuction: engineAuthedProcedure
|
||||||
@@ -885,7 +426,7 @@ export const inheritRouter = router({
|
|||||||
);
|
);
|
||||||
return { ok: true, ...result };
|
return { ok: true, ...result };
|
||||||
}),
|
}),
|
||||||
checkOwner: authedProcedure
|
checkOwner: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
targetGeneralId: z.number().int().positive(),
|
targetGeneralId: z.number().int().positive(),
|
||||||
@@ -896,79 +437,10 @@ export const inheritRouter = router({
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const worldState = await resolveWorld(ctx);
|
const result = await requestInheritanceAction(ctx, userId, {
|
||||||
const worldMeta = asRecord(worldState.meta);
|
action: 'checkOwner',
|
||||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
targetGeneralId: input.targetGeneralId,
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
});
|
||||||
}
|
return { ok: true, ownerName: result.ownerName!, targetName: result.targetName! };
|
||||||
const inheritConst = resolveInheritConstants(worldState as WorldStateRow);
|
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < inheritConst.inheritCheckOwnerPoint) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [general, target] = await Promise.all([
|
|
||||||
ctx.db.general.findFirst({ where: { userId } }),
|
|
||||||
ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }),
|
|
||||||
]);
|
|
||||||
if (!general) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
if (!target || !target.userId) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 장수가 존재하지 않습니다.' });
|
|
||||||
}
|
|
||||||
if (target.id === general.id) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawOwnerName = asRecord(target.meta).ownerName;
|
|
||||||
const ownerName =
|
|
||||||
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
|
|
||||||
|
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인`
|
|
||||||
);
|
|
||||||
|
|
||||||
const [generalTarget, checkedTarget, gameTime] = await Promise.all([
|
|
||||||
buildTargetFromGeneral(ctx.db, general),
|
|
||||||
buildTargetFromGeneral(ctx.db, target),
|
|
||||||
loadCurrentGameTime(ctx.db),
|
|
||||||
]);
|
|
||||||
const systemTarget: MessageDraft['src'] = {
|
|
||||||
generalId: 0,
|
|
||||||
generalName: '',
|
|
||||||
nationId: 0,
|
|
||||||
nationName: 'System',
|
|
||||||
color: '#000000',
|
|
||||||
icon: '',
|
|
||||||
};
|
|
||||||
const validUntil = new Date('9999-12-31T00:00:00.000Z');
|
|
||||||
const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise<void> => {
|
|
||||||
await sendMessage(
|
|
||||||
{
|
|
||||||
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
msgType: 'private',
|
|
||||||
src: systemTarget,
|
|
||||||
dest,
|
|
||||||
text,
|
|
||||||
time: gameTime.now,
|
|
||||||
validUntil,
|
|
||||||
option: {},
|
|
||||||
},
|
|
||||||
{ sendDestOnly: true }
|
|
||||||
);
|
|
||||||
ctx.changeJournal?.mark('messages.mailbox', dest.generalId);
|
|
||||||
};
|
|
||||||
|
|
||||||
await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`);
|
|
||||||
await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.');
|
|
||||||
return { ok: true, ownerName, targetName: target.name };
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/in
|
|||||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
|
import {
|
||||||
|
readCentennialRecordableDexterity,
|
||||||
|
type CentennialDexKey,
|
||||||
|
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
|
||||||
import {
|
import {
|
||||||
@@ -182,11 +186,17 @@ export const rankingRouter = router({
|
|||||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
...(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map(
|
||||||
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
(key, index) =>
|
||||||
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
[
|
||||||
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
['보 병 숙 련 도', '궁 병 숙 련 도', '기 병 숙 련 도', '귀 병 숙 련 도', '차 병 숙 련 도'][
|
||||||
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
index
|
||||||
|
]!,
|
||||||
|
'int',
|
||||||
|
(general: (typeof generals)[number]) =>
|
||||||
|
readCentennialRecordableDexterity(asRecord(general.meta), key as CentennialDexKey),
|
||||||
|
] as [string, 'int', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
|
||||||
|
),
|
||||||
[
|
[
|
||||||
'전 력 전 승 률',
|
'전 력 전 승 률',
|
||||||
'percent',
|
'percent',
|
||||||
@@ -415,7 +425,7 @@ export const rankingRouter = router({
|
|||||||
return Array.from(optionMap.values());
|
return Array.from(optionMap.values());
|
||||||
}
|
}
|
||||||
const rows = await ctx.db.gameHistory.findMany({
|
const rows = await ctx.db.gameHistory.findMany({
|
||||||
where: { status: 'COMPLETED' },
|
where: { status: { in: ['OPEN', 'COMPLETED'] } },
|
||||||
select: { season: true, scenario: true, scenarioName: true },
|
select: { season: true, scenario: true, scenarioName: true },
|
||||||
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
|
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,68 +1,258 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { SystemClock } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
import {
|
||||||
|
createDatabaseTurnHooks,
|
||||||
|
DatabaseTurnDaemonCommandQueue,
|
||||||
|
EngineStateManager,
|
||||||
|
InMemoryTurnStateStore,
|
||||||
|
InMemoryTurnWorld,
|
||||||
|
loadTurnWorldFromDatabase,
|
||||||
|
TurnDaemonLifecycle,
|
||||||
|
type TurnGeneral,
|
||||||
|
type TurnWorldSnapshot,
|
||||||
|
type TurnWorldState,
|
||||||
|
} from '@sammo-ts/game-engine';
|
||||||
|
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
|
||||||
|
import {
|
||||||
|
createGamePostgresConnector,
|
||||||
|
type GamePrisma,
|
||||||
|
type GamePrismaClient,
|
||||||
|
type RedisConnector,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
|
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const actorGeneralId = 8_701;
|
const worldId = 992_320;
|
||||||
const checkedGeneralId = 8_702;
|
const actorId = 7_320;
|
||||||
const actorNationId = 871;
|
const targetId = 7_321;
|
||||||
const checkedNationId = 872;
|
const actorNationId = 7_322;
|
||||||
|
const targetNationId = 7_323;
|
||||||
const actorUserId = 'inherit-owner-message-actor';
|
const actorUserId = 'inherit-owner-message-actor';
|
||||||
const checkedUserId = 'inherit-owner-message-checked';
|
const targetUserId = 'inherit-owner-message-target';
|
||||||
|
const requestId = 'integration:inherit-owner-message:success';
|
||||||
|
const engineRequestId = `${requestId}:inherit.checkOwner:engine:0:inheritanceAction`;
|
||||||
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
const scenarioConfig: ScenarioConfig = {
|
||||||
|
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: {},
|
||||||
|
const: { inheritCheckOwnerPoint: 1_000 },
|
||||||
|
environment: { mapName: 'che', unitSet: 'che' },
|
||||||
|
};
|
||||||
|
const scenarioMeta: ScenarioMeta = {
|
||||||
|
title: '소유자 확인 통합',
|
||||||
|
startYear: 200,
|
||||||
|
life: null,
|
||||||
|
fiction: null,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
};
|
||||||
|
const map: MapDefinition = { id: 'inherit-owner-message', name: scenarioMeta.title, cities: [] };
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: worldId,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
||||||
|
};
|
||||||
|
|
||||||
|
const general = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
||||||
|
id: actorId,
|
||||||
|
userId: actorUserId,
|
||||||
|
name: '확인장수',
|
||||||
|
nationId: actorNationId,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 70, strength: 45, intelligence: 85 },
|
||||||
|
turnTime: new Date('2026-08-19T00:10:00.000Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24, inherit_spent_dyn: 0 },
|
||||||
|
inheritancePoints: { previous: 1_500 },
|
||||||
|
penalty: {},
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const actor = general({});
|
||||||
|
const target = general({
|
||||||
|
id: targetId,
|
||||||
|
userId: targetUserId,
|
||||||
|
name: '피확인장수',
|
||||||
|
nationId: targetNationId,
|
||||||
|
meta: { killturn: 24, owner_name: '피확인 계정' },
|
||||||
|
inheritancePoints: { previous: 0 },
|
||||||
|
});
|
||||||
|
const nation = (id: number, name: string, chiefGeneralId: number) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color: id === actorNationId ? '#123456' : '#654321',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
const actorNation = nation(actorNationId, '확인국', actorId);
|
||||||
|
const targetNation = nation(targetNationId, '피확인국', targetId);
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: 'che:inherit-owner-message',
|
profile: 'che:inherit-owner-message',
|
||||||
issuedAt: '2026-08-19T00:00:00.000Z',
|
issuedAt: '2026-08-19T00:00:00.000Z',
|
||||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||||
sessionId: 'inherit-owner-message-session',
|
sessionId: 'inherit-owner-message-session',
|
||||||
user: {
|
user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] },
|
||||||
id: actorUserId,
|
|
||||||
username: actorUserId,
|
|
||||||
displayName: '확인자 계정',
|
|
||||||
roles: ['user'],
|
|
||||||
},
|
|
||||||
sanctions: {},
|
sanctions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMailboxChange = (payload: unknown): boolean => {
|
const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
|
||||||
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
id: entry.id,
|
||||||
const changes = (payload as { changes?: unknown }).changes;
|
userId: entry.userId,
|
||||||
if (!Array.isArray(changes)) return false;
|
name: entry.name,
|
||||||
const mailboxes = new Set([actorGeneralId, checkedGeneralId]);
|
nationId: entry.nationId,
|
||||||
return changes.some(
|
cityId: entry.cityId,
|
||||||
(change) =>
|
npcState: entry.npcState,
|
||||||
Array.isArray(change) &&
|
leadership: entry.stats.leadership,
|
||||||
change[0] === 'messages.mailbox' &&
|
strength: entry.stats.strength,
|
||||||
typeof change[1] === 'number' &&
|
intel: entry.stats.intelligence,
|
||||||
mailboxes.has(change[1])
|
turnTime: entry.turnTime,
|
||||||
);
|
meta: entry.meta as GamePrisma.InputJsonValue,
|
||||||
};
|
penalty: entry.penalty as GamePrisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
|
||||||
integration('inherit owner lookup private messages', () => {
|
integration('inherit owner lookup private messages', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
let worldStateId: number;
|
|
||||||
|
|
||||||
const buildContext = (requestId: string): GameApiContext => {
|
beforeAll(async () => {
|
||||||
const redisClient = {
|
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||||
get: async () => null,
|
if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`);
|
||||||
set: async () => null,
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: worldId,
|
||||||
|
scenarioCode: 'inherit-owner-message',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.nation.createMany({
|
||||||
|
data: [
|
||||||
|
{ id: actorNationId, name: actorNation.name, color: actorNation.color, level: 1 },
|
||||||
|
{ id: targetNationId, name: targetNation.name, color: targetNation.color, level: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.general.createMany({ data: [actor, target].map(toCreate) });
|
||||||
|
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 1_500 } });
|
||||||
|
await db.rankData.create({
|
||||||
|
data: { generalId: actorId, nationId: actorNationId, type: 'inherit_spent_dyn', value: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
|
}
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits once and returns the durable result without double charging on the same API retry', async () => {
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: [actor, target],
|
||||||
|
cities: [],
|
||||||
|
nations: [actorNation, targetNation],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map,
|
||||||
};
|
};
|
||||||
return {
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
|
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||||
|
await queue.initialize();
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
const stateManager = new EngineStateManager();
|
||||||
|
stateManager.register('world', {
|
||||||
|
capture: () => world.captureState(),
|
||||||
|
restore: (value) => world.restoreState(value),
|
||||||
|
});
|
||||||
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
|
{
|
||||||
|
clock: new SystemClock(),
|
||||||
|
controlQueue: queue,
|
||||||
|
commandResponder: queue,
|
||||||
|
getNextTickTime: () => new Date(Date.now() + 3_600_000),
|
||||||
|
stateStore: new InMemoryTurnStateStore(world),
|
||||||
|
processor: {
|
||||||
|
run: async () => {
|
||||||
|
throw new Error('scheduled turn must not run in inheritance API integration');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commandHandler: createTurnDaemonCommandHandler({ world }),
|
||||||
|
hooks: hooks.hooks,
|
||||||
|
stateManager,
|
||||||
|
},
|
||||||
|
{ profile: 'inherit-owner-message', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } }
|
||||||
|
);
|
||||||
|
const redisClient = { get: async () => null, set: async () => null };
|
||||||
|
const context: GameApiContext = {
|
||||||
requestId,
|
requestId,
|
||||||
db,
|
db,
|
||||||
redis: redisClient as unknown as RedisConnector['client'],
|
redis: redisClient as unknown as RedisConnector['client'],
|
||||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000),
|
||||||
battleSim: new InMemoryBattleSimTransport(),
|
battleSim: new InMemoryBattleSimTransport(),
|
||||||
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
|
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
@@ -72,157 +262,46 @@ integration('inherit owner lookup private messages', () => {
|
|||||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
|
||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
readModelOutbox: { wake: vi.fn() },
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
let loop: Promise<void> | undefined;
|
||||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
try {
|
||||||
await connector.connect();
|
loop = lifecycle.start();
|
||||||
db = connector.prisma;
|
const caller = appRouter.createCaller(context);
|
||||||
closeDb = () => connector.disconnect();
|
const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' };
|
||||||
|
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
|
||||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
} finally {
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
await lifecycle.stop('inherit owner integration finished');
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
await loop;
|
||||||
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
await hooks.close();
|
||||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
|
||||||
await db.readModelRevision.deleteMany({
|
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.nation.createMany({
|
|
||||||
data: [
|
|
||||||
{ id: actorNationId, name: '확인국', color: '#123456', level: 2 },
|
|
||||||
{ id: checkedNationId, name: '피확인국', color: '#654321', level: 3 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await db.general.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: actorGeneralId,
|
|
||||||
userId: actorUserId,
|
|
||||||
name: '확인장수',
|
|
||||||
nationId: actorNationId,
|
|
||||||
cityId: 1,
|
|
||||||
npcState: 0,
|
|
||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
meta: { ownerName: '확인자 계정' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: checkedGeneralId,
|
|
||||||
userId: checkedUserId,
|
|
||||||
name: '피확인장수',
|
|
||||||
nationId: checkedNationId,
|
|
||||||
cityId: 1,
|
|
||||||
npcState: 0,
|
|
||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
meta: { ownerName: '피확인 계정' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await db.inheritancePoint.create({
|
|
||||||
data: { userId: actorUserId, key: 'previous', value: 1_500 },
|
|
||||||
});
|
|
||||||
const world = await db.worldState.create({
|
|
||||||
data: {
|
|
||||||
scenarioCode: 'inherit-owner-message',
|
|
||||||
currentYear: 200,
|
|
||||||
currentMonth: 4,
|
|
||||||
tickSeconds: 600,
|
|
||||||
config: { const: { inheritCheckOwnerPoint: 1_000 } },
|
|
||||||
meta: { isUnited: 0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
worldStateId = world.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
|
||||||
const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id);
|
|
||||||
if (outboxIds.length > 0) {
|
|
||||||
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
|
|
||||||
}
|
}
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
|
||||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
|
||||||
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
|
||||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
|
||||||
await db.readModelRevision.deleteMany({
|
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
|
||||||
});
|
|
||||||
await db.worldState.delete({ where: { id: worldStateId } });
|
|
||||||
await closeDb?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('commits the point charge, log, and both Ref-compatible private messages', async () => {
|
|
||||||
const requestId = 'integration:inherit-owner-message:success';
|
|
||||||
await expect(
|
|
||||||
appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId })
|
|
||||||
).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
ownerName: '피확인 계정',
|
|
||||||
targetName: '피확인장수',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
db.inheritancePoint.findUniqueOrThrow({
|
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } })
|
||||||
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
|
||||||
})
|
|
||||||
).resolves.toMatchObject({ value: 500 });
|
).resolves.toMatchObject({ value: 500 });
|
||||||
await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
year: 200,
|
|
||||||
month: 4,
|
|
||||||
text: '1000 포인트로 장수 소유자 확인',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const messages = await db.message.findMany({
|
|
||||||
where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } },
|
|
||||||
orderBy: { mailbox: 'asc' },
|
|
||||||
});
|
|
||||||
expect(messages).toHaveLength(2);
|
|
||||||
expect(
|
|
||||||
messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message }))
|
|
||||||
).toEqual([
|
|
||||||
{
|
|
||||||
mailbox: actorGeneralId,
|
|
||||||
type: 'private',
|
|
||||||
src: 0,
|
|
||||||
dest: actorGeneralId,
|
|
||||||
message: expect.objectContaining({
|
|
||||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
|
||||||
dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }),
|
|
||||||
text: '피확인장수의 소유자는 피확인 계정 입니다.',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mailbox: checkedGeneralId,
|
|
||||||
type: 'private',
|
|
||||||
src: 0,
|
|
||||||
dest: checkedGeneralId,
|
|
||||||
message: expect.objectContaining({
|
|
||||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
|
||||||
dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }),
|
|
||||||
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } })
|
db.rankData.findUniqueOrThrow({
|
||||||
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId });
|
where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } },
|
||||||
await expect(
|
|
||||||
db.readModelRevision.findMany({
|
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
|
||||||
orderBy: { entityId: 'asc' },
|
|
||||||
})
|
})
|
||||||
).resolves.toEqual([
|
).resolves.toMatchObject({ value: 1_000 });
|
||||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }),
|
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1);
|
||||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }),
|
const messages = await db.message.findMany({
|
||||||
]);
|
where: { mailbox: { in: [actorId, targetId] } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { mailbox: true, message: true },
|
||||||
});
|
});
|
||||||
|
expect(messages.map(({ mailbox, message }) => [mailbox, (message as { text: string }).text])).toEqual([
|
||||||
|
[actorId, '피확인장수의 소유자는 피확인 계정 입니다.'],
|
||||||
|
[targetId, '소유자명이 누군가에 의해 확인되었습니다.'],
|
||||||
|
]);
|
||||||
|
await expect(db.inputEvent.findMany({ where: { requestId: engineRequestId } })).resolves.toEqual([
|
||||||
|
expect.objectContaining({ status: 'SUCCEEDED', attempts: 1, actorUserId }),
|
||||||
|
]);
|
||||||
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === actorId)).toMatchObject({
|
||||||
|
meta: { inherit_spent_dyn: 1_000 },
|
||||||
|
inheritancePoints: { previous: 500 },
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ChangeJournal } from '@sammo-ts/common';
|
import { ChangeJournal, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
import type { MessagePayload } from '@sammo-ts/logic';
|
import type { MessagePayload } from '@sammo-ts/logic';
|
||||||
@@ -111,6 +111,7 @@ const buildContext = (options: {
|
|||||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||||
configConst?: Record<string, unknown>;
|
configConst?: Record<string, unknown>;
|
||||||
configMap?: Record<string, unknown>;
|
configMap?: Record<string, unknown>;
|
||||||
|
daemonResult?: TurnDaemonCommandResult;
|
||||||
}) => {
|
}) => {
|
||||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
@@ -118,11 +119,19 @@ const buildContext = (options: {
|
|||||||
options.target === undefined
|
options.target === undefined
|
||||||
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
||||||
: options.target;
|
: options.target;
|
||||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
|
const requestCommand = vi.fn(async (command: TurnDaemonCommand): Promise<TurnDaemonCommandResult> => {
|
||||||
type: command.type,
|
if (options.daemonResult) return options.daemonResult;
|
||||||
|
if (command.type === 'inheritanceAction') {
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
ok: true,
|
ok: true,
|
||||||
generalId: command.generalId,
|
action: command.input.action,
|
||||||
}));
|
generalId: general?.id ?? 7,
|
||||||
|
remainPoint: options.inheritancePoint ?? 10_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'patchGeneral', ok: true, generalId: 7 };
|
||||||
|
});
|
||||||
const pointUpsert = vi.fn(async () => ({}));
|
const pointUpsert = vi.fn(async () => ({}));
|
||||||
const logCreate = vi.fn(async () => ({}));
|
const logCreate = vi.fn(async () => ({}));
|
||||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||||
@@ -272,10 +281,17 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
|
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||||
inheritancePoint: 0,
|
inheritancePoint: 0,
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: false,
|
||||||
|
action: 'resetStat',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
reason: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const caller = appRouter.createCaller(fixture.context);
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
@@ -291,7 +307,17 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||||
});
|
});
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
userId: 'user-1',
|
||||||
|
input: {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
inheritBonusStat: [2, 1, 1],
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -429,10 +455,17 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
|
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
auth: buildAuth('user-2'),
|
auth: buildAuth('user-2'),
|
||||||
general: buildGeneral({ userId: 'user-1' }),
|
general: buildGeneral({ userId: 'user-1' }),
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: false,
|
||||||
|
action: 'buyHiddenBuff',
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
reason: '장수가 존재하지 않습니다.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -444,12 +477,25 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: '장수가 존재하지 않습니다.',
|
message: '장수가 존재하지 않습니다.',
|
||||||
});
|
});
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
userId: 'user-2',
|
||||||
|
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
|
||||||
|
});
|
||||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mutates only the authenticated user general and inheritance balance', async () => {
|
it('mutates only the authenticated user general and inheritance balance', async () => {
|
||||||
const fixture = buildContext({ inheritancePoint: 1000 });
|
const fixture = buildContext({
|
||||||
|
inheritancePoint: 1000,
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action: 'buyHiddenBuff',
|
||||||
|
generalId: 7,
|
||||||
|
remainPoint: 800,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||||
@@ -458,29 +504,26 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
})
|
})
|
||||||
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
||||||
|
|
||||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({
|
type: 'inheritanceAction',
|
||||||
type: 'patchGeneral',
|
userId: 'user-1',
|
||||||
generalId: 7,
|
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
|
||||||
patch: expect.objectContaining({
|
});
|
||||||
meta: expect.objectContaining({
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
|
||||||
update: { value: 800 },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
|
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
inheritancePoint: 5_000,
|
inheritancePoint: 5_000,
|
||||||
configConst: { availableSpecialWar: ['che_의술'] },
|
configConst: { availableSpecialWar: ['che_의술'] },
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action: 'setNextSpecialWar',
|
||||||
|
generalId: 7,
|
||||||
|
remainPoint: 1_000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -488,19 +531,12 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
).resolves.toEqual({ ok: true });
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
type: 'patchGeneral',
|
type: 'inheritanceAction',
|
||||||
generalId: 7,
|
|
||||||
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
|
|
||||||
});
|
|
||||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
|
||||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
year: 200,
|
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' },
|
||||||
month: 4,
|
|
||||||
text: '4000 포인트로 다음 전투 특기로 의술 지정',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not dispatch or charge when a different war trait is already reserved', async () => {
|
it('does not dispatch or charge when a different war trait is already reserved', async () => {
|
||||||
@@ -508,12 +544,19 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
inheritancePoint: 5_000,
|
inheritancePoint: 5_000,
|
||||||
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
|
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
|
||||||
configConst: { availableSpecialWar: ['che_의술'] },
|
configConst: { availableSpecialWar: ['che_의술'] },
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: false,
|
||||||
|
action: 'setNextSpecialWar',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
reason: '이미 예약한 특기가 있습니다.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
|
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
|
||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
|
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
expect(fixture.requestCommand).toHaveBeenCalledOnce();
|
||||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -522,41 +565,44 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
inheritancePoint: 2_000,
|
inheritancePoint: 2_000,
|
||||||
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
|
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action: 'resetSpecialWar',
|
||||||
|
generalId: 7,
|
||||||
|
remainPoint: 1_000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
|
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
type: 'patchGeneral',
|
type: 'inheritanceAction',
|
||||||
generalId: 7,
|
|
||||||
patch: {
|
|
||||||
specialWar: null,
|
|
||||||
meta: {
|
|
||||||
prev_types_special2: ['che_돌격', 'che_선봉'],
|
|
||||||
marker: 3,
|
|
||||||
inheritResetSpecialWar: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
|
||||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
year: 200,
|
input: { action: 'resetSpecialWar' },
|
||||||
month: 4,
|
|
||||||
text: '1000 포인트로 전투 특기 초기화',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not dispatch or charge when the current war trait is already blank', async () => {
|
it('does not dispatch or charge when the current war trait is already blank', async () => {
|
||||||
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
|
const fixture = buildContext({
|
||||||
|
inheritancePoint: 2_000,
|
||||||
|
general: buildGeneral({ special2Code: 'None' }),
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: false,
|
||||||
|
action: 'resetSpecialWar',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
reason: '이미 전투 특기가 공란입니다.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
|
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: '이미 전투 특기가 공란입니다.',
|
message: '이미 전투 특기가 공란입니다.',
|
||||||
});
|
});
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
expect(fixture.requestCommand).toHaveBeenCalledOnce();
|
||||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -572,34 +618,41 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
previousTurnTimeBase: 123_456,
|
previousTurnTimeBase: 123_456,
|
||||||
tickSeconds: worldState.tickSeconds,
|
tickSeconds: worldState.tickSeconds,
|
||||||
});
|
});
|
||||||
|
fixture.requestCommand.mockResolvedValueOnce({
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action: 'resetTurnTime',
|
||||||
|
generalId: 7,
|
||||||
|
remainPoint: 1_000,
|
||||||
|
...expected,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
|
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
...expected,
|
...expected,
|
||||||
});
|
});
|
||||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
type: 'patchGeneral',
|
type: 'inheritanceAction',
|
||||||
generalId: 7,
|
|
||||||
patch: {
|
|
||||||
meta: {
|
|
||||||
nextTurnTimeBase: expected.nextTurnTimeBase,
|
|
||||||
inheritResetTurnTime: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
|
||||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
year: 200,
|
input: { action: 'resetTurnTime' },
|
||||||
month: 4,
|
|
||||||
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||||
const fixture = buildContext({ inheritancePoint: 1500 });
|
const fixture = buildContext({
|
||||||
|
inheritancePoint: 1500,
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action: 'checkOwner',
|
||||||
|
generalId: 7,
|
||||||
|
remainPoint: 500,
|
||||||
|
ownerName: '위유저',
|
||||||
|
targetName: '조조',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||||
@@ -608,64 +661,27 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
ownerName: '위유저',
|
ownerName: '위유저',
|
||||||
targetName: '조조',
|
targetName: '조조',
|
||||||
});
|
});
|
||||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({
|
type: 'inheritanceAction',
|
||||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
|
||||||
update: { value: 500 },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
year: 200,
|
input: { action: 'checkOwner', targetGeneralId: 8 },
|
||||||
month: 4,
|
|
||||||
text: '1000 포인트로 장수 소유자 확인',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(fixture.messageRows).toHaveLength(2);
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
expect(fixture.messageRows).toEqual([
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
expect.objectContaining({
|
expect(fixture.messageRows).toHaveLength(0);
|
||||||
mailbox: 7,
|
|
||||||
type: 'private',
|
|
||||||
src: 0,
|
|
||||||
dest: 7,
|
|
||||||
payload: expect.objectContaining({
|
|
||||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
|
||||||
dest: expect.objectContaining({ generalId: 7, generalName: '유비' }),
|
|
||||||
text: '조조의 소유자는 위유저 입니다.',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
mailbox: 8,
|
|
||||||
type: 'private',
|
|
||||||
src: 0,
|
|
||||||
dest: 8,
|
|
||||||
payload: expect.objectContaining({
|
|
||||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
|
||||||
dest: expect.objectContaining({ generalId: 8, generalName: '조조' }),
|
|
||||||
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
|
|
||||||
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
|
|
||||||
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
expect(fixture.changeJournal.snapshot()).toEqual([
|
|
||||||
{ domain: 'messages.mailbox', entityId: 7 },
|
|
||||||
{ domain: 'messages.mailbox', entityId: 8 },
|
|
||||||
]);
|
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not charge or send messages when the owner lookup target is the actor', async () => {
|
it('does not charge or send messages when the owner lookup target is the actor', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
inheritancePoint: 1_500,
|
inheritancePoint: 1_500,
|
||||||
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
|
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: false,
|
||||||
|
action: 'checkOwner',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
reason: '자신의 정보는 확인할 수 없습니다.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -681,13 +697,22 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not charge or send messages when inheritance points are insufficient', async () => {
|
it('does not charge or send messages when inheritance points are insufficient', async () => {
|
||||||
const fixture = buildContext({ inheritancePoint: 999 });
|
const fixture = buildContext({
|
||||||
|
inheritancePoint: 999,
|
||||||
|
daemonResult: {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: false,
|
||||||
|
action: 'checkOwner',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: '유산 포인트가 부족합니다.',
|
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
|
||||||
});
|
});
|
||||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||||
@@ -108,6 +108,7 @@ const buildContext = (options?: {
|
|||||||
profileId?: string;
|
profileId?: string;
|
||||||
generals?: RankingGeneralRow[];
|
generals?: RankingGeneralRow[];
|
||||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||||
|
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const selectedGeneralRows = options?.generals ?? generalRows;
|
const selectedGeneralRows = options?.generals ?? generalRows;
|
||||||
const selectedProfile = options?.profileId
|
const selectedProfile = options?.profileId
|
||||||
@@ -198,10 +199,12 @@ const buildContext = (options?: {
|
|||||||
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
|
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
|
||||||
},
|
},
|
||||||
gameHistory: {
|
gameHistory: {
|
||||||
findMany: async () => [
|
findMany:
|
||||||
|
options?.gameHistoryFindMany ??
|
||||||
|
(async () => [
|
||||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||||
],
|
]),
|
||||||
},
|
},
|
||||||
hallOfFame: {
|
hallOfFame: {
|
||||||
findMany: async (args: { where: { type: string } }) =>
|
findMany: async (args: { where: { type: string } }) =>
|
||||||
@@ -303,6 +306,28 @@ describe('ranking.getBestGeneral', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes 100th-season event mastery from current Best General values', async () => {
|
||||||
|
const generals = [
|
||||||
|
{
|
||||||
|
...generalRows[0]!,
|
||||||
|
meta: {
|
||||||
|
...generalRows[0]!.meta,
|
||||||
|
dex1: 120,
|
||||||
|
event100_allstar: { granted: { dex1: 70 } },
|
||||||
|
} as unknown as RankingGeneralRow['meta'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = await appRouter.createCaller(buildContext({ generals })).ranking.getBestGeneral({
|
||||||
|
view: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sections.find((section) => section.title === '보 병 숙 련 도')?.entries[0]).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
value: 50,
|
||||||
|
printValue: '50',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns positions one through ten for every populated ranking section', async () => {
|
it('returns positions one through ten for every populated ranking section', async () => {
|
||||||
const generals = Array.from({ length: 12 }, (_, index) => {
|
const generals = Array.from({ length: 12 }, (_, index) => {
|
||||||
const id = index + 1;
|
const id = index + 1;
|
||||||
@@ -386,6 +411,21 @@ describe('ranking hall of fame', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes the active OPEN game while excluding ABANDONED options', async () => {
|
||||||
|
const findMany = vi.fn(async () => [
|
||||||
|
{ season: 4, scenario: 23, scenarioName: '현재 시나리오' },
|
||||||
|
{ season: 3, scenario: 22, scenarioName: '완료 시나리오' },
|
||||||
|
]);
|
||||||
|
const options = await appRouter
|
||||||
|
.createCaller(buildContext({ authenticated: false, gameHistoryFindMany: findMany }))
|
||||||
|
.ranking.getHallOfFameOptions();
|
||||||
|
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { status: { in: ['OPEN', 'COMPLETED'] } } })
|
||||||
|
);
|
||||||
|
expect(options.map((entry) => entry.season)).toEqual([4, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes previous-server options and rankings to the request profile', async () => {
|
it('scopes previous-server options and rankings to the request profile', async () => {
|
||||||
const cheCaller = appRouter.createCaller(buildContext({ authenticated: false }));
|
const cheCaller = appRouter.createCaller(buildContext({ authenticated: false }));
|
||||||
await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
|
await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
|
||||||
|
|||||||
@@ -265,6 +265,42 @@ const zPatchGeneral = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zInheritanceAction = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('inheritanceAction'),
|
||||||
|
requestId: z.string().min(1).optional(),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
input: z.discriminatedUnion('action', [
|
||||||
|
z.object({
|
||||||
|
action: z.literal('buyHiddenBuff'),
|
||||||
|
buffType: z.enum([
|
||||||
|
'warAvoidRatio',
|
||||||
|
'warCriticalRatio',
|
||||||
|
'warMagicTrialProb',
|
||||||
|
'domesticSuccessProb',
|
||||||
|
'domesticFailProb',
|
||||||
|
'warAvoidRatioOppose',
|
||||||
|
'warCriticalRatioOppose',
|
||||||
|
'warMagicTrialProbOppose',
|
||||||
|
]),
|
||||||
|
level: z.number().int().min(1).max(5),
|
||||||
|
}),
|
||||||
|
z.object({ action: z.literal('setNextSpecialWar'), specialKey: z.string().min(1) }),
|
||||||
|
z.object({ action: z.literal('resetSpecialWar') }),
|
||||||
|
z.object({ action: z.literal('resetTurnTime') }),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('resetStat'),
|
||||||
|
leadership: z.number().int(),
|
||||||
|
strength: z.number().int(),
|
||||||
|
intel: z.number().int(),
|
||||||
|
inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(),
|
||||||
|
}),
|
||||||
|
z.object({ action: z.literal('buyRandomUnique') }),
|
||||||
|
z.object({ action: z.literal('checkOwner'), targetGeneralId: z.number().int().positive() }),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const zAdjustGeneralIcon = z
|
const zAdjustGeneralIcon = z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal('adjustGeneralIcon'),
|
type: z.literal('adjustGeneralIcon'),
|
||||||
@@ -643,6 +679,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeInheritanceAction: CommandNormalizer<'inheritanceAction'> = (envelope) => {
|
||||||
|
const command = parseWith(zInheritanceAction, envelope.command);
|
||||||
|
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...command, requestId: envelope.requestId };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
|
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
|
||||||
const command = parseWith(zAdjustGeneralIcon, envelope.command);
|
const command = parseWith(zAdjustGeneralIcon, envelope.command);
|
||||||
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
|
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
|
||||||
@@ -764,6 +808,7 @@ const normalizers: CommandNormalizerMap = {
|
|||||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||||
patchGeneral: normalizePatchGeneral,
|
patchGeneral: normalizePatchGeneral,
|
||||||
|
inheritanceAction: normalizeInheritanceAction,
|
||||||
adjustGeneralIcon: normalizeAdjustGeneralIcon,
|
adjustGeneralIcon: normalizeAdjustGeneralIcon,
|
||||||
joinCreateGeneral: normalizeJoinCreateGeneral,
|
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||||
npcPossessGeneral: normalizeNpcPossessGeneral,
|
npcPossessGeneral: normalizeNpcPossessGeneral,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
|
|||||||
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
|
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
|
||||||
import { buildDiplomacyMeta } from '@sammo-ts/logic';
|
import { buildDiplomacyMeta } from '@sammo-ts/logic';
|
||||||
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
|
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
|
||||||
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
|
import { persistGeneralLifecycleEvents, type GeneralLifecycleArchiveLog } from './generalTurnLifecyclePersistence.js';
|
||||||
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||||
@@ -1094,6 +1094,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
lifecycleEvents,
|
lifecycleEvents,
|
||||||
pendingNeutralAuctions,
|
pendingNeutralAuctions,
|
||||||
inheritancePointAdjustments,
|
inheritancePointAdjustments,
|
||||||
|
pendingInheritanceLogs,
|
||||||
pendingNationBettingOpens,
|
pendingNationBettingOpens,
|
||||||
pendingNationBettingFinishes,
|
pendingNationBettingFinishes,
|
||||||
pendingYearbookSnapshots,
|
pendingYearbookSnapshots,
|
||||||
@@ -1137,6 +1138,20 @@ export const createDatabaseTurnHooks = async (
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
)?.id ?? 0;
|
)?.id ?? 0;
|
||||||
|
const logContext = {
|
||||||
|
year: state.currentYear,
|
||||||
|
month: state.currentMonth,
|
||||||
|
at: state.lastTurnTime,
|
||||||
|
};
|
||||||
|
const pendingLogRows = logs
|
||||||
|
.map((entry) => buildLogCreateData(entry, logContext))
|
||||||
|
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
|
||||||
|
const pendingLifecycleArchiveLogs: GeneralLifecycleArchiveLog[] = pendingLogRows.flatMap((entry) =>
|
||||||
|
entry.generalId !== null &&
|
||||||
|
(entry.category === LogCategory.HISTORY || entry.category === LogCategory.BATTLE_BRIEF)
|
||||||
|
? [{ generalId: entry.generalId, category: entry.category, text: entry.text }]
|
||||||
|
: []
|
||||||
|
);
|
||||||
// Lock and validate the fencing row in the same transaction as every
|
// Lock and validate the fencing row in the same transaction as every
|
||||||
// world mutation. A stale daemon can finish calculating, but it can
|
// world mutation. A stale daemon can finish calculating, but it can
|
||||||
// never commit after another owner has advanced the epoch.
|
// never commit after another owner has advanced the epoch.
|
||||||
@@ -1257,15 +1272,24 @@ export const createDatabaseTurnHooks = async (
|
|||||||
const meta = asRecord(state.meta);
|
const meta = asRecord(state.meta);
|
||||||
const serverId =
|
const serverId =
|
||||||
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
|
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
|
||||||
if (inheritancePointAdjustments.length > 0) {
|
const persistInheritancePointAdjustments = async (
|
||||||
|
entries: typeof inheritancePointAdjustments
|
||||||
|
): Promise<void> => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
|
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
|
||||||
for (const entry of inheritancePointAdjustments) {
|
for (const entry of entries) {
|
||||||
const groupKey = `${entry.userId}\u0000${entry.key}`;
|
const groupKey = `${entry.userId}\u0000${entry.key}`;
|
||||||
const current = grouped.get(groupKey);
|
const current = grouped.get(groupKey);
|
||||||
if (current) {
|
if (current) {
|
||||||
current.amount += entry.amount;
|
current.amount += entry.amount;
|
||||||
} else {
|
} else {
|
||||||
grouped.set(groupKey, { ...entry });
|
grouped.set(groupKey, {
|
||||||
|
userId: entry.userId,
|
||||||
|
key: entry.key,
|
||||||
|
amount: entry.amount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of grouped.values()) {
|
for (const entry of grouped.values()) {
|
||||||
@@ -1275,14 +1299,41 @@ export const createDatabaseTurnHooks = async (
|
|||||||
create: { userId: entry.userId, key: entry.key, value: entry.amount },
|
create: { userId: entry.userId, key: entry.key, value: entry.amount },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await prisma.inheritanceLog.createMany({
|
||||||
|
data: entries.map((entry) => ({
|
||||||
|
userId: entry.userId,
|
||||||
|
year: entry.year,
|
||||||
|
month: entry.month,
|
||||||
|
text: entry.text,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const beforeLifecycleAdjustments = inheritancePointAdjustments.filter(
|
||||||
|
(entry) => entry.phase !== 'after_lifecycle'
|
||||||
|
);
|
||||||
|
const afterLifecycleAdjustments = inheritancePointAdjustments.filter(
|
||||||
|
(entry) => entry.phase === 'after_lifecycle'
|
||||||
|
);
|
||||||
|
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
|
||||||
|
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
|
||||||
|
|
||||||
|
await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
|
||||||
|
await persistInheritanceLogs(beforeLifecycleLogs);
|
||||||
await persistGeneralLifecycleEvents(
|
await persistGeneralLifecycleEvents(
|
||||||
prisma,
|
prisma,
|
||||||
lifecycleEvents,
|
lifecycleEvents,
|
||||||
meta,
|
meta,
|
||||||
asRecord(world.getScenarioConfig().const),
|
asRecord(world.getScenarioConfig().const),
|
||||||
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
|
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
|
||||||
|
pendingLifecycleArchiveLogs
|
||||||
);
|
);
|
||||||
|
await persistInheritancePointAdjustments(afterLifecycleAdjustments);
|
||||||
|
await persistInheritanceLogs(afterLifecycleLogs);
|
||||||
|
|
||||||
if (accessScoreResetGeneralIds.length > 0) {
|
if (accessScoreResetGeneralIds.length > 0) {
|
||||||
await prisma.generalAccessLog.updateMany({
|
await prisma.generalAccessLog.updateMany({
|
||||||
@@ -1611,21 +1662,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
await upsertRankRows(prisma, rankRows);
|
await upsertRankRows(prisma, rankRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logs.length > 0) {
|
if (pendingLogRows.length > 0) {
|
||||||
const logContext = {
|
|
||||||
year: state.currentYear,
|
|
||||||
month: state.currentMonth,
|
|
||||||
at: state.lastTurnTime,
|
|
||||||
};
|
|
||||||
const payload = logs
|
|
||||||
.map((entry) => buildLogCreateData(entry, logContext))
|
|
||||||
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
|
|
||||||
if (payload.length > 0) {
|
|
||||||
await prisma.logEntry.createMany({
|
await prisma.logEntry.createMany({
|
||||||
data: payload,
|
data: pendingLogRows,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const snapshot of pendingYearbookSnapshots) {
|
for (const snapshot of pendingYearbookSnapshots) {
|
||||||
await persistYearbookSnapshot(prisma, snapshot);
|
await persistYearbookSnapshot(prisma, snapshot);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
import {
|
||||||
|
asRecord,
|
||||||
|
HALL_OF_FAME_TYPES,
|
||||||
|
RANK_DATA_TYPES,
|
||||||
|
rankDataMetaKey,
|
||||||
|
resolveLegacyTextColor,
|
||||||
|
type HallOfFameType,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js';
|
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js';
|
||||||
|
import {
|
||||||
|
readCentennialRecordableDexterity,
|
||||||
|
type CentennialDexKey,
|
||||||
|
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||||
|
|
||||||
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
|
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
|
||||||
|
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
|
||||||
|
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
|
||||||
|
import { buildPersistedRankRows } from './rankData.js';
|
||||||
|
|
||||||
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
||||||
|
|
||||||
@@ -24,12 +38,59 @@ const readWorldNumber = (record: Record<string, unknown>, key: string, fallback:
|
|||||||
return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
|
return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LifecycleRankValues = Map<string, number>;
|
||||||
|
|
||||||
|
export interface GeneralLifecycleArchiveLog {
|
||||||
|
generalId: number;
|
||||||
|
category: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLifecycleRankValues = async (
|
||||||
|
prisma: GamePrisma.TransactionClient,
|
||||||
|
event: GeneralLifecycleEvent
|
||||||
|
): Promise<LifecycleRankValues> => {
|
||||||
|
const persisted = await prisma.rankData.findMany({
|
||||||
|
where: { generalId: event.generalId },
|
||||||
|
select: { type: true, value: true },
|
||||||
|
});
|
||||||
|
const values = new Map(persisted.map((row) => [row.type, row.value]));
|
||||||
|
const snapshotMeta = asRecord(event.before.meta);
|
||||||
|
for (const row of buildPersistedRankRows(event.before)) {
|
||||||
|
if (
|
||||||
|
row.type === 'experience' ||
|
||||||
|
row.type === 'dedication' ||
|
||||||
|
Object.prototype.hasOwnProperty.call(snapshotMeta, rankDataMetaKey(row.type))
|
||||||
|
) {
|
||||||
|
values.set(row.type, row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistPostRetirementRankValues = async (
|
||||||
|
prisma: GamePrisma.TransactionClient,
|
||||||
|
event: GeneralLifecycleEvent
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!event.after) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const row of buildPersistedRankRows(event.after)) {
|
||||||
|
await prisma.rankData.upsert({
|
||||||
|
where: { generalId_type: { generalId: row.generalId, type: row.type } },
|
||||||
|
update: { nationId: row.nationId, value: row.value },
|
||||||
|
create: row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const settleInheritance = async (
|
const settleInheritance = async (
|
||||||
prisma: GamePrisma.TransactionClient,
|
prisma: GamePrisma.TransactionClient,
|
||||||
event: GeneralLifecycleEvent,
|
event: GeneralLifecycleEvent,
|
||||||
worldMeta: Record<string, unknown>,
|
worldMeta: Record<string, unknown>,
|
||||||
isRebirth: boolean,
|
isRebirth: boolean,
|
||||||
configConst: Record<string, unknown>
|
configConst: Record<string, unknown>,
|
||||||
|
rankValues: ReadonlyMap<string, number>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const userId = event.before.userId;
|
const userId = event.before.userId;
|
||||||
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
|
if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) {
|
||||||
@@ -53,29 +114,23 @@ const settleInheritance = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, rankRows] = await Promise.all([
|
const rows = await prisma.inheritancePoint.findMany({
|
||||||
prisma.inheritancePoint.findMany({
|
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: { key: true, value: true },
|
select: { key: true, value: true },
|
||||||
}),
|
});
|
||||||
prisma.rankData.findMany({
|
|
||||||
where: { generalId: event.generalId },
|
|
||||||
select: { type: true, value: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const points = new Map(rows.map((row) => [row.key, row.value]));
|
const points = new Map(rows.map((row) => [row.key, row.value]));
|
||||||
const previous = points.get('previous') ?? 0;
|
const previous = points.get('previous') ?? 0;
|
||||||
const randomUniqueRefund = meta.inheritRandomUnique
|
const randomUniqueRefund =
|
||||||
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
|
!isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0;
|
||||||
: 0;
|
const specificSpecialRefund =
|
||||||
const specificSpecialRefund = meta.inheritSpecificSpecialWar
|
!isRebirth && meta.inheritSpecificSpecialWar
|
||||||
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
|
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
|
||||||
: 0;
|
: 0;
|
||||||
const refund = randomUniqueRefund + specificSpecialRefund;
|
const refund = randomUniqueRefund + specificSpecialRefund;
|
||||||
const calculationMeta = {
|
const calculationMeta = {
|
||||||
|
...Object.fromEntries(rankValues),
|
||||||
|
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
|
||||||
...meta,
|
...meta,
|
||||||
...Object.fromEntries(rankRows.map((row) => [row.type, row.value])),
|
|
||||||
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
|
|
||||||
};
|
};
|
||||||
const settlement = computeInheritanceSettlementBreakdown(
|
const settlement = computeInheritanceSettlementBreakdown(
|
||||||
{
|
{
|
||||||
@@ -143,14 +198,22 @@ const settleInheritance = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const text of buildInheritanceSettlementLogTexts({
|
||||||
|
previous: previous + refund,
|
||||||
|
points: settlement.earned,
|
||||||
|
storedKeys: new Set([...points.keys(), ...(refund > 0 ? (['previous'] as const) : [])]),
|
||||||
|
total,
|
||||||
|
isRebirth,
|
||||||
|
})) {
|
||||||
await prisma.inheritanceLog.create({
|
await prisma.inheritanceLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
year: event.year,
|
year: event.year,
|
||||||
month: event.month,
|
month: event.month,
|
||||||
text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`,
|
text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
|
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
|
||||||
@@ -159,26 +222,23 @@ const settleHall = async (
|
|||||||
prisma: GamePrisma.TransactionClient,
|
prisma: GamePrisma.TransactionClient,
|
||||||
event: GeneralLifecycleEvent,
|
event: GeneralLifecycleEvent,
|
||||||
worldMeta: Record<string, unknown>,
|
worldMeta: Record<string, unknown>,
|
||||||
gameNow: Date
|
gameNow: Date,
|
||||||
|
rank: ReadonlyMap<string, number>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
|
const isUnited =
|
||||||
|
event.isUnitedAtEvent ?? readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
|
||||||
if (isUnited !== 0) {
|
if (isUnited !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [ranks, nation, historyCount] = await Promise.all([
|
const [nation, serverIdx] = await Promise.all([
|
||||||
prisma.rankData.findMany({
|
|
||||||
where: { generalId: event.generalId },
|
|
||||||
select: { type: true, value: true },
|
|
||||||
}),
|
|
||||||
event.before.nationId > 0
|
event.before.nationId > 0
|
||||||
? prisma.nation.findUnique({
|
? prisma.nation.findUnique({
|
||||||
where: { id: event.before.nationId },
|
where: { id: event.before.nationId },
|
||||||
select: { name: true, color: true },
|
select: { name: true, color: true },
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
prisma.gameHistory.count(),
|
resolveOfficialGameIndex(prisma, worldMeta),
|
||||||
]);
|
]);
|
||||||
const rank = new Map(ranks.map((row) => [row.type, row.value]));
|
|
||||||
const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
|
const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key);
|
||||||
const warnum = value('warnum');
|
const warnum = value('warnum');
|
||||||
const tt = value('ttw') + value('ttd') + value('ttl');
|
const tt = value('ttw') + value('ttd') + value('ttl');
|
||||||
@@ -221,12 +281,13 @@ const settleHall = async (
|
|||||||
picture: event.before.picture ?? null,
|
picture: event.before.picture ?? null,
|
||||||
imgsvr: event.before.imageServer ?? 0,
|
imgsvr: event.before.imageServer ?? 0,
|
||||||
serverID: serverId,
|
serverID: serverId,
|
||||||
serverIdx: historyCount,
|
serverIdx,
|
||||||
scenarioName,
|
scenarioName,
|
||||||
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
|
serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '',
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const type of HALL_OF_FAME_TYPES) {
|
for (const type of HALL_OF_FAME_TYPES) {
|
||||||
|
const eventMeta = asRecord(event.before.meta);
|
||||||
let hallValue =
|
let hallValue =
|
||||||
type === 'experience'
|
type === 'experience'
|
||||||
? event.before.experience
|
? event.before.experience
|
||||||
@@ -234,6 +295,8 @@ const settleHall = async (
|
|||||||
? event.before.dedication
|
? event.before.dedication
|
||||||
: type.endsWith('rate')
|
: type.endsWith('rate')
|
||||||
? (calc[type] ?? 0)
|
? (calc[type] ?? 0)
|
||||||
|
: type.startsWith('dex')
|
||||||
|
? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey)
|
||||||
: value(type);
|
: value(type);
|
||||||
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
|
if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue;
|
||||||
if (type === 'ttrate' && tt < 50) continue;
|
if (type === 'ttrate' && tt < 50) continue;
|
||||||
@@ -244,51 +307,29 @@ const settleHall = async (
|
|||||||
if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
|
if (!Number.isFinite(hallValue) || hallValue <= 0) continue;
|
||||||
hallValue = Number(hallValue);
|
hallValue = Number(hallValue);
|
||||||
|
|
||||||
const existing = await prisma.hallOfFame.findUnique({
|
await persistHallOfFameCandidate(prisma, {
|
||||||
where: {
|
|
||||||
serverId_type_generalNo: {
|
|
||||||
serverId,
|
|
||||||
type: type as HallOfFameType,
|
|
||||||
generalNo: event.generalId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
if (hallValue > existing.value) {
|
|
||||||
await prisma.hallOfFame.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: { value: hallValue, aux: asJson(aux) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await prisma.hallOfFame.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
serverId,
|
serverId,
|
||||||
season,
|
season,
|
||||||
scenario,
|
scenario,
|
||||||
generalNo: event.generalId,
|
generalNo: event.generalId,
|
||||||
type,
|
type: type as HallOfFameType,
|
||||||
value: hallValue,
|
value: hallValue,
|
||||||
owner: event.before.userId ?? null,
|
owner: event.before.userId ?? null,
|
||||||
aux: asJson(aux),
|
aux,
|
||||||
},
|
|
||||||
],
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const archiveDeletedGeneral = async (
|
const archiveGeneral = async (
|
||||||
prisma: GamePrisma.TransactionClient,
|
prisma: GamePrisma.TransactionClient,
|
||||||
event: GeneralLifecycleEvent,
|
event: GeneralLifecycleEvent,
|
||||||
worldMeta: Record<string, unknown>
|
worldMeta: Record<string, unknown>,
|
||||||
|
rankValues: ReadonlyMap<string, number>,
|
||||||
|
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[]
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const serverId =
|
const serverId =
|
||||||
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
|
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
|
||||||
const [recordRows, rankRows] = await Promise.all([
|
const recordRows = await prisma.logEntry.findMany({
|
||||||
prisma.logEntry.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
generalId: event.generalId,
|
generalId: event.generalId,
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
@@ -296,20 +337,26 @@ const archiveDeletedGeneral = async (
|
|||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
select: { category: true, text: true },
|
select: { category: true, text: true },
|
||||||
}),
|
});
|
||||||
prisma.rankData.findMany({
|
|
||||||
where: { generalId: event.generalId },
|
|
||||||
select: { type: true, value: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const archivedMeta = {
|
const archivedMeta = {
|
||||||
...asRecord(event.before.meta),
|
...asRecord(event.before.meta),
|
||||||
...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
|
...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])),
|
||||||
};
|
};
|
||||||
delete archivedMeta.inheritRandomUnique;
|
const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId);
|
||||||
delete archivedMeta.inheritSpecificSpecialWar;
|
const history = [
|
||||||
const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text);
|
...pendingGeneralLogs
|
||||||
const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text);
|
.filter((row) => row.category === LogCategory.HISTORY)
|
||||||
|
.map((row) => row.text)
|
||||||
|
.reverse(),
|
||||||
|
...recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text),
|
||||||
|
];
|
||||||
|
const battleResults = [
|
||||||
|
...pendingGeneralLogs
|
||||||
|
.filter((row) => row.category === LogCategory.BATTLE_BRIEF)
|
||||||
|
.map((row) => row.text)
|
||||||
|
.reverse(),
|
||||||
|
...recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text),
|
||||||
|
];
|
||||||
const data = {
|
const data = {
|
||||||
...event.before,
|
...event.before,
|
||||||
meta: archivedMeta,
|
meta: archivedMeta,
|
||||||
@@ -345,7 +392,8 @@ export const persistGeneralLifecycleEvents = async (
|
|||||||
events: GeneralLifecycleEvent[],
|
events: GeneralLifecycleEvent[],
|
||||||
worldMeta: Record<string, unknown>,
|
worldMeta: Record<string, unknown>,
|
||||||
configConst: Record<string, unknown>,
|
configConst: Record<string, unknown>,
|
||||||
gameNow = new Date()
|
gameNow = new Date(),
|
||||||
|
pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = []
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -359,17 +407,18 @@ export const persistGeneralLifecycleEvents = async (
|
|||||||
if (event.outcome === 'detached' || event.outcome === 'deleted') {
|
if (event.outcome === 'detached' || event.outcome === 'deleted') {
|
||||||
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
|
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
|
||||||
}
|
}
|
||||||
|
if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rankValues = await loadLifecycleRankValues(prisma, event);
|
||||||
if (event.outcome === 'deleted') {
|
if (event.outcome === 'deleted') {
|
||||||
await archiveDeletedGeneral(prisma, event, worldMeta);
|
await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues);
|
||||||
await settleInheritance(prisma, event, worldMeta, false, configConst);
|
await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs);
|
||||||
}
|
}
|
||||||
if (event.outcome === 'retired') {
|
if (event.outcome === 'retired') {
|
||||||
await settleHall(prisma, event, worldMeta, gameNow);
|
await settleHall(prisma, event, worldMeta, gameNow, rankValues);
|
||||||
await settleInheritance(prisma, event, worldMeta, true, configConst);
|
await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues);
|
||||||
await prisma.rankData.updateMany({
|
await persistPostRetirementRankValues(prisma, event);
|
||||||
where: { generalId: event.generalId },
|
|
||||||
data: { value: 0 },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { HallOfFameType } from '@sammo-ts/common';
|
||||||
|
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
||||||
|
|
||||||
|
const readInteger = (value: unknown, fallback: number): number => {
|
||||||
|
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||||
|
return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `gameIdx` is fixed when RESET opens a game and deliberately excludes
|
||||||
|
* retained ABANDONED rows. Older fixtures may not carry it, so reconstruct the
|
||||||
|
* same sequence from completed games plus the configured first index.
|
||||||
|
*/
|
||||||
|
export const resolveOfficialGameIndex = async (
|
||||||
|
prisma: GamePrisma.TransactionClient,
|
||||||
|
worldMeta: Record<string, unknown>
|
||||||
|
): Promise<number> => {
|
||||||
|
if (worldMeta.gameIdx !== undefined) {
|
||||||
|
return readInteger(worldMeta.gameIdx, 0);
|
||||||
|
}
|
||||||
|
const completedGames = await prisma.gameHistory.count({ where: { status: 'COMPLETED' } });
|
||||||
|
return completedGames + readInteger(worldMeta.firstGameIdx, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HallOfFameCandidate {
|
||||||
|
serverId: string;
|
||||||
|
season: number;
|
||||||
|
scenario: number;
|
||||||
|
generalNo: number;
|
||||||
|
type: HallOfFameType;
|
||||||
|
value: number;
|
||||||
|
owner: string | null;
|
||||||
|
aux: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref insertIgnore treats an owner record belonging to another general as a
|
||||||
|
* complete winner: it does not reassign that row even when the new value is
|
||||||
|
* higher. Only an existing row for the same general and scenario may replace
|
||||||
|
* value+aux, keeping every identity column unchanged. This avoids the former
|
||||||
|
* Core state where an old general number was combined with a new general's aux.
|
||||||
|
*/
|
||||||
|
export const persistHallOfFameCandidate = async (
|
||||||
|
prisma: GamePrisma.TransactionClient,
|
||||||
|
candidate: HallOfFameCandidate
|
||||||
|
): Promise<'CREATED' | 'UPDATED' | 'PRESERVED'> => {
|
||||||
|
const matches = await prisma.hallOfFame.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ serverId: candidate.serverId, type: candidate.type, generalNo: candidate.generalNo },
|
||||||
|
...(candidate.owner
|
||||||
|
? [{ serverId: candidate.serverId, type: candidate.type, owner: candidate.owner }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sameGeneral = matches.find((entry) => entry.generalNo === candidate.generalNo);
|
||||||
|
if (!sameGeneral && matches.length > 0) {
|
||||||
|
return 'PRESERVED';
|
||||||
|
}
|
||||||
|
if (!sameGeneral) {
|
||||||
|
await prisma.hallOfFame.create({
|
||||||
|
data: {
|
||||||
|
...candidate,
|
||||||
|
aux: asJson(candidate.aux),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return 'CREATED';
|
||||||
|
}
|
||||||
|
if (sameGeneral.scenario !== candidate.scenario || candidate.value <= sameGeneral.value) {
|
||||||
|
return 'PRESERVED';
|
||||||
|
}
|
||||||
|
await prisma.hallOfFame.update({
|
||||||
|
where: { id: sameGeneral.id },
|
||||||
|
data: {
|
||||||
|
value: candidate.value,
|
||||||
|
aux: asJson(candidate.aux),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return 'UPDATED';
|
||||||
|
};
|
||||||
@@ -83,6 +83,8 @@ export interface GeneralLifecycleEvent {
|
|||||||
outcome: 'active' | 'detached' | 'deleted' | 'retired';
|
outcome: 'active' | 'detached' | 'deleted' | 'retired';
|
||||||
before: TurnGeneral;
|
before: TurnGeneral;
|
||||||
after?: TurnGeneral;
|
after?: TurnGeneral;
|
||||||
|
/** World unification state observed when this lifecycle transition occurred. */
|
||||||
|
isUnitedAtEvent?: number;
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,23 @@ export interface InMemoryGameClockState {
|
|||||||
lastTurnTick: number;
|
lastTurnTick: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
|
||||||
|
|
||||||
|
export interface PendingInheritancePointAdjustment {
|
||||||
|
userId: string;
|
||||||
|
key: string;
|
||||||
|
amount: number;
|
||||||
|
phase?: InheritancePersistencePhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingInheritanceLog {
|
||||||
|
userId: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
text: string;
|
||||||
|
phase?: InheritancePersistencePhase;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnWorldChanges {
|
export interface TurnWorldChanges {
|
||||||
realtimeBacklogShiftTicks: number;
|
realtimeBacklogShiftTicks: number;
|
||||||
accessScoreResetGeneralIds: number[];
|
accessScoreResetGeneralIds: number[];
|
||||||
@@ -145,7 +164,8 @@ export interface TurnWorldChanges {
|
|||||||
deletedEvents: number[];
|
deletedEvents: number[];
|
||||||
lifecycleEvents: GeneralLifecycleEvent[];
|
lifecycleEvents: GeneralLifecycleEvent[];
|
||||||
pendingNeutralAuctions: PendingNeutralAuction[];
|
pendingNeutralAuctions: PendingNeutralAuction[];
|
||||||
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
|
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
|
||||||
|
pendingInheritanceLogs: PendingInheritanceLog[];
|
||||||
pendingNationBettingOpens: PendingNationBettingOpen[];
|
pendingNationBettingOpens: PendingNationBettingOpen[];
|
||||||
pendingNationBettingFinishes: PendingNationBettingFinish[];
|
pendingNationBettingFinishes: PendingNationBettingFinish[];
|
||||||
pendingYearbookSnapshots: PendingYearbookSnapshot[];
|
pendingYearbookSnapshots: PendingYearbookSnapshot[];
|
||||||
@@ -184,7 +204,8 @@ export interface InMemoryTurnWorldStateSnapshot {
|
|||||||
messages: MessageDraft[];
|
messages: MessageDraft[];
|
||||||
lifecycleEvents: GeneralLifecycleEvent[];
|
lifecycleEvents: GeneralLifecycleEvent[];
|
||||||
pendingNeutralAuctions: PendingNeutralAuction[];
|
pendingNeutralAuctions: PendingNeutralAuction[];
|
||||||
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
|
inheritancePointAdjustments: PendingInheritancePointAdjustment[];
|
||||||
|
pendingInheritanceLogs: PendingInheritanceLog[];
|
||||||
pendingNationBettingOpens: PendingNationBettingOpen[];
|
pendingNationBettingOpens: PendingNationBettingOpen[];
|
||||||
pendingNationBettingFinishes: PendingNationBettingFinish[];
|
pendingNationBettingFinishes: PendingNationBettingFinish[];
|
||||||
pendingYearbookSnapshots: PendingYearbookSnapshot[];
|
pendingYearbookSnapshots: PendingYearbookSnapshot[];
|
||||||
@@ -487,7 +508,8 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly messages: MessageDraft[] = [];
|
private readonly messages: MessageDraft[] = [];
|
||||||
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
|
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
|
||||||
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
|
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
|
||||||
private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = [];
|
private readonly inheritancePointAdjustments: PendingInheritancePointAdjustment[] = [];
|
||||||
|
private readonly pendingInheritanceLogs: PendingInheritanceLog[] = [];
|
||||||
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
|
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
|
||||||
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
|
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
|
||||||
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
|
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
|
||||||
@@ -786,6 +808,7 @@ export class InMemoryTurnWorld {
|
|||||||
lifecycleEvents: this.lifecycleEvents,
|
lifecycleEvents: this.lifecycleEvents,
|
||||||
pendingNeutralAuctions: this.pendingNeutralAuctions,
|
pendingNeutralAuctions: this.pendingNeutralAuctions,
|
||||||
inheritancePointAdjustments: this.inheritancePointAdjustments,
|
inheritancePointAdjustments: this.inheritancePointAdjustments,
|
||||||
|
pendingInheritanceLogs: this.pendingInheritanceLogs,
|
||||||
pendingNationBettingOpens: this.pendingNationBettingOpens,
|
pendingNationBettingOpens: this.pendingNationBettingOpens,
|
||||||
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
|
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
|
||||||
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
|
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
|
||||||
@@ -831,6 +854,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
|
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
|
||||||
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
|
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
|
||||||
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
|
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
|
||||||
|
this.replaceArray(this.pendingInheritanceLogs, restored.pendingInheritanceLogs ?? []);
|
||||||
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
|
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
|
||||||
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
|
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
|
||||||
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
|
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
|
||||||
@@ -990,11 +1014,23 @@ export class InMemoryTurnWorld {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
queueInheritancePointAdjustment(userId: string, key: string, amount: number): void {
|
queueInheritancePointAdjustment(
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
amount: number,
|
||||||
|
phase?: InheritancePersistencePhase
|
||||||
|
): void {
|
||||||
if (!userId || !Number.isFinite(amount) || amount === 0) {
|
if (!userId || !Number.isFinite(amount) || amount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.inheritancePointAdjustments.push({ userId, key, amount });
|
this.inheritancePointAdjustments.push({ userId, key, amount, ...(phase ? { phase } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
queueInheritanceLog(log: PendingInheritanceLog): void {
|
||||||
|
if (!log.userId || !log.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingInheritanceLogs.push({ ...log });
|
||||||
}
|
}
|
||||||
|
|
||||||
queueNationBettingOpen(betting: PendingNationBettingOpen): void {
|
queueNationBettingOpen(betting: PendingNationBettingOpen): void {
|
||||||
@@ -1271,6 +1307,9 @@ export class InMemoryTurnWorld {
|
|||||||
generalId: id,
|
generalId: id,
|
||||||
outcome: 'deleted',
|
outcome: 'deleted',
|
||||||
before: structuredClone(general),
|
before: structuredClone(general),
|
||||||
|
isUnitedAtEvent: Math.floor(
|
||||||
|
readMetaNumber(this.state.meta, 'isunited') ?? readMetaNumber(this.state.meta, 'isUnited') ?? 0
|
||||||
|
),
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
});
|
});
|
||||||
@@ -1811,6 +1850,7 @@ export class InMemoryTurnWorld {
|
|||||||
closeAt: new Date(auction.closeAt.getTime()),
|
closeAt: new Date(auction.closeAt.getTime()),
|
||||||
}));
|
}));
|
||||||
const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry }));
|
const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry }));
|
||||||
|
const pendingInheritanceLogs = this.pendingInheritanceLogs.map((entry) => ({ ...entry }));
|
||||||
const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({
|
const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
candidates: entry.candidates.map((candidate) => ({
|
candidates: entry.candidates.map((candidate) => ({
|
||||||
@@ -1852,6 +1892,7 @@ export class InMemoryTurnWorld {
|
|||||||
lifecycleEvents,
|
lifecycleEvents,
|
||||||
pendingNeutralAuctions,
|
pendingNeutralAuctions,
|
||||||
inheritancePointAdjustments,
|
inheritancePointAdjustments,
|
||||||
|
pendingInheritanceLogs,
|
||||||
pendingNationBettingOpens,
|
pendingNationBettingOpens,
|
||||||
pendingNationBettingFinishes,
|
pendingNationBettingFinishes,
|
||||||
pendingYearbookSnapshots,
|
pendingYearbookSnapshots,
|
||||||
@@ -1889,6 +1930,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
|
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
|
||||||
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
|
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
|
||||||
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
|
this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length);
|
||||||
|
this.pendingInheritanceLogs.splice(0, changes.pendingInheritanceLogs.length);
|
||||||
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
|
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
|
||||||
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
|
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
|
||||||
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
|
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
|
||||||
|
|||||||
@@ -0,0 +1,670 @@
|
|||||||
|
import {
|
||||||
|
asNumber,
|
||||||
|
asRecord,
|
||||||
|
LiteHashDRBG,
|
||||||
|
parseJson,
|
||||||
|
RandUtil,
|
||||||
|
rankDataMetaKey,
|
||||||
|
type TurnDaemonCommand,
|
||||||
|
type TurnDaemonCommandResult,
|
||||||
|
type TurnDaemonInheritanceAction,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import {
|
||||||
|
isCentennialStatResetAllowed,
|
||||||
|
isWarTraitKey,
|
||||||
|
loadWarTraitModules,
|
||||||
|
resolveMessageTargetIcon,
|
||||||
|
WarTraitLoader,
|
||||||
|
type InheritBuffType,
|
||||||
|
type MessageDraft,
|
||||||
|
type MessageTarget,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
|
||||||
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
|
type InheritanceActionCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
|
||||||
|
type InheritanceActionResult = Extract<TurnDaemonCommandResult, { type: 'inheritanceAction' }>;
|
||||||
|
|
||||||
|
interface InheritConstants {
|
||||||
|
inheritBornStatPoint: number;
|
||||||
|
inheritItemRandomPoint: number;
|
||||||
|
inheritBuffPoints: number[];
|
||||||
|
inheritSpecificSpecialPoint: number;
|
||||||
|
inheritResetAttrPointBase: number[];
|
||||||
|
inheritCheckOwnerPoint: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INHERIT_CONST: InheritConstants = {
|
||||||
|
inheritBornStatPoint: 1_000,
|
||||||
|
inheritItemRandomPoint: 3_000,
|
||||||
|
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
|
||||||
|
inheritSpecificSpecialPoint: 4_000,
|
||||||
|
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
|
||||||
|
inheritCheckOwnerPoint: 1_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUFF_LABELS: Record<InheritBuffType, string> = {
|
||||||
|
warAvoidRatio: '회피 확률 증가',
|
||||||
|
warCriticalRatio: '필살 확률 증가',
|
||||||
|
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||||
|
domesticSuccessProb: '내정 성공률 증가',
|
||||||
|
domesticFailProb: '내정 실패율 감소',
|
||||||
|
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||||
|
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||||
|
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_TARGET: MessageTarget = {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 0,
|
||||||
|
nationName: 'System',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||||
|
|
||||||
|
const resolveNumberArray = (value: unknown, fallback: number[]): number[] => {
|
||||||
|
if (!Array.isArray(value)) return [...fallback];
|
||||||
|
const result = value
|
||||||
|
.map((entry) => (typeof entry === 'number' && Number.isFinite(entry) ? entry : null))
|
||||||
|
.filter((entry): entry is number => entry !== null);
|
||||||
|
return result.length > 0 ? result : [...fallback];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInheritConstants = (world: InMemoryTurnWorld): InheritConstants => {
|
||||||
|
const configConst = asRecord(world.getScenarioConfig().const);
|
||||||
|
return {
|
||||||
|
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
|
||||||
|
inheritItemRandomPoint: asNumber(
|
||||||
|
configConst.inheritItemRandomPoint,
|
||||||
|
DEFAULT_INHERIT_CONST.inheritItemRandomPoint
|
||||||
|
),
|
||||||
|
inheritBuffPoints: resolveNumberArray(configConst.inheritBuffPoints, DEFAULT_INHERIT_CONST.inheritBuffPoints),
|
||||||
|
inheritSpecificSpecialPoint: asNumber(
|
||||||
|
configConst.inheritSpecificSpecialPoint,
|
||||||
|
DEFAULT_INHERIT_CONST.inheritSpecificSpecialPoint
|
||||||
|
),
|
||||||
|
inheritResetAttrPointBase: resolveNumberArray(
|
||||||
|
configConst.inheritResetAttrPointBase,
|
||||||
|
DEFAULT_INHERIT_CONST.inheritResetAttrPointBase
|
||||||
|
),
|
||||||
|
inheritCheckOwnerPoint: asNumber(
|
||||||
|
configConst.inheritCheckOwnerPoint,
|
||||||
|
DEFAULT_INHERIT_CONST.inheritCheckOwnerPoint
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildResetCost = (baseCosts: number[], level: number): number => {
|
||||||
|
const costs = [...baseCosts];
|
||||||
|
while (costs.length <= level) {
|
||||||
|
const size = costs.length;
|
||||||
|
costs.push((costs[size - 1] ?? 0) + (costs[size - 2] ?? 0));
|
||||||
|
}
|
||||||
|
return costs[level] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBuffRecord = (raw: unknown): Record<string, number> => {
|
||||||
|
const source = typeof raw === 'string' ? (parseJson<Record<string, unknown>>(raw) ?? {}) : asRecord(raw);
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(source).filter((entry): entry is [string, number] => {
|
||||||
|
const value = entry[1];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||||
|
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||||
|
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readStringList = (raw: unknown): string[] => {
|
||||||
|
const value = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
|
||||||
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||||
|
const value = meta[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
|
||||||
|
const value = meta.season;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readResetSeasons = (meta: Record<string, unknown>): number[] =>
|
||||||
|
Array.isArray(meta.last_stat_reset)
|
||||||
|
? meta.last_stat_reset
|
||||||
|
.map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null))
|
||||||
|
.filter((value): value is number => value !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
export const buildResetStatRandomBonus = (
|
||||||
|
rng: RandUtil,
|
||||||
|
baseStats: [number, number, number]
|
||||||
|
): [number, number, number] => {
|
||||||
|
const bonusCount = rng.nextRangeInt(3, 5);
|
||||||
|
const bonus = [0, 0, 0] as [number, number, number];
|
||||||
|
for (let index = 0; index < bonusCount; index += 1) {
|
||||||
|
const selected = Number(
|
||||||
|
rng.choiceUsingWeight({
|
||||||
|
0: baseStats[0],
|
||||||
|
1: baseStats[1],
|
||||||
|
2: baseStats[2],
|
||||||
|
})
|
||||||
|
) as 0 | 1 | 2;
|
||||||
|
bonus[selected] += 1;
|
||||||
|
}
|
||||||
|
return bonus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTurnTimeBaseLabel = (value: number): string => {
|
||||||
|
const wholeSeconds = Math.trunc(value);
|
||||||
|
const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0');
|
||||||
|
const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveResetTurnTimeBase = (options: {
|
||||||
|
hiddenSeed: string | number;
|
||||||
|
userId: string;
|
||||||
|
previousTurnTimeBase: string | number;
|
||||||
|
tickSeconds: number;
|
||||||
|
}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => {
|
||||||
|
const rng = new LiteHashDRBG(
|
||||||
|
simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase)
|
||||||
|
);
|
||||||
|
const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds);
|
||||||
|
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const reject = (
|
||||||
|
action: TurnDaemonInheritanceAction['action'],
|
||||||
|
code: Extract<InheritanceActionResult, { ok: false }>['code'],
|
||||||
|
reason: string
|
||||||
|
): InheritanceActionResult => ({ type: 'inheritanceAction', ok: false, action, code, reason });
|
||||||
|
|
||||||
|
const lockPreviousPoint = async (db: GamePrisma.TransactionClient, userId: string): Promise<number> => {
|
||||||
|
const rows = await db.$queryRaw<Array<{ value: number }>>(GamePrisma.sql`
|
||||||
|
SELECT value
|
||||||
|
FROM inheritance_point
|
||||||
|
WHERE user_id = ${userId} AND key = 'previous'
|
||||||
|
FOR UPDATE
|
||||||
|
`);
|
||||||
|
return rows[0]?.value ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendInheritanceLog = async (
|
||||||
|
db: GamePrisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
text: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.inheritanceLog.create({ data: { userId, year, month, text } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMessageTarget = (world: InMemoryTurnWorld, general: TurnGeneral): MessageTarget => {
|
||||||
|
const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null;
|
||||||
|
return {
|
||||||
|
generalId: general.id,
|
||||||
|
generalName: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
nationName: nation?.name ?? '재야',
|
||||||
|
color: nation?.color ?? '#000000',
|
||||||
|
icon: resolveMessageTargetIcon(general),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueOwnerLookupMessages = (
|
||||||
|
world: InMemoryTurnWorld,
|
||||||
|
actor: TurnGeneral,
|
||||||
|
target: TurnGeneral,
|
||||||
|
ownerName: string,
|
||||||
|
gameNow: Date
|
||||||
|
): void => {
|
||||||
|
const validUntil = new Date('9999-12-31T00:00:00.000Z');
|
||||||
|
const messages: MessageDraft[] = [
|
||||||
|
{
|
||||||
|
msgType: 'private',
|
||||||
|
src: SYSTEM_TARGET,
|
||||||
|
dest: buildMessageTarget(world, actor),
|
||||||
|
text: `${target.name}의 소유자는 ${ownerName} 입니다.`,
|
||||||
|
time: gameNow,
|
||||||
|
validUntil,
|
||||||
|
option: {},
|
||||||
|
sendDestOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
msgType: 'private',
|
||||||
|
src: SYSTEM_TARGET,
|
||||||
|
dest: buildMessageTarget(world, target),
|
||||||
|
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||||
|
time: gameNow,
|
||||||
|
validUntil,
|
||||||
|
option: {},
|
||||||
|
sendDestOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const message of messages) world.queueMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCharge = (options: {
|
||||||
|
world: InMemoryTurnWorld;
|
||||||
|
general: TurnGeneral;
|
||||||
|
userId: string;
|
||||||
|
previousPoint: number;
|
||||||
|
cost: number;
|
||||||
|
patch: Partial<TurnGeneral>;
|
||||||
|
}): TurnGeneral => {
|
||||||
|
const { world, general, userId, previousPoint, cost, patch } = options;
|
||||||
|
const patchMeta = patch.meta ? asRecord(patch.meta) : general.meta;
|
||||||
|
const spentKey = rankDataMetaKey('inherit_spent_dyn');
|
||||||
|
const nextMeta = {
|
||||||
|
...patchMeta,
|
||||||
|
[spentKey]: readMetaNumber(general.meta, spentKey, 0) + cost,
|
||||||
|
} as TurnGeneral['meta'];
|
||||||
|
const next = world.updateGeneral(general.id, {
|
||||||
|
...patch,
|
||||||
|
meta: nextMeta,
|
||||||
|
inheritancePoints: {
|
||||||
|
...general.inheritancePoints,
|
||||||
|
previous: previousPoint - cost,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!next) throw new Error(`Inheritance action general ${general.id} disappeared during mutation.`);
|
||||||
|
world.queueInheritancePointAdjustment(userId, 'previous', -cost);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnited = (world: InMemoryTurnWorld): boolean => {
|
||||||
|
const meta = asRecord(world.getState().meta);
|
||||||
|
return asNumber(meta.isunited, 0) !== 0 || asNumber(meta.isUnited, 0) !== 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
|
||||||
|
const meta = asRecord(rawMeta);
|
||||||
|
for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) {
|
||||||
|
const value = meta[key];
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '알수없음';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeInheritanceAction = async (options: {
|
||||||
|
db: GamePrisma.TransactionClient;
|
||||||
|
world: InMemoryTurnWorld;
|
||||||
|
command: InheritanceActionCommand;
|
||||||
|
gameNow: Date;
|
||||||
|
}): Promise<InheritanceActionResult> => {
|
||||||
|
const { db, world, command, gameNow } = options;
|
||||||
|
const { input, userId } = command;
|
||||||
|
const action = input.action;
|
||||||
|
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
|
||||||
|
if (!general) return reject(action, 'PRECONDITION_FAILED', '장수가 존재하지 않습니다.');
|
||||||
|
|
||||||
|
const state = world.getState();
|
||||||
|
const worldMeta = asRecord(state.meta);
|
||||||
|
const config = world.getScenarioConfig();
|
||||||
|
const configRecord = asRecord(config);
|
||||||
|
const constants = resolveInheritConstants(world);
|
||||||
|
|
||||||
|
if (action === 'checkOwner') {
|
||||||
|
if (input.targetGeneralId === general.id) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '자신의 정보는 확인할 수 없습니다.');
|
||||||
|
}
|
||||||
|
const target = world.getGeneralById(input.targetGeneralId);
|
||||||
|
if (!target) return reject(action, 'BAD_REQUEST', '대상 장수가 존재하지 않습니다.');
|
||||||
|
if (!target.userId) return reject(action, 'BAD_REQUEST', '대상 장수는 NPC입니다.');
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
const cost = constants.inheritCheckOwnerPoint;
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
const ownerName = resolveOwnerDisplayName(target.meta);
|
||||||
|
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
`${cost} 포인트로 장수 소유자 확인`
|
||||||
|
);
|
||||||
|
queueOwnerLookupMessages(world, general, target, ownerName, gameNow);
|
||||||
|
applyCharge({ world, general, userId, previousPoint, cost, patch: {} });
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
generalId: general.id,
|
||||||
|
remainPoint: previousPoint - cost,
|
||||||
|
ownerName,
|
||||||
|
targetName: target.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'buyHiddenBuff') {
|
||||||
|
const buff = readBuffRecord(general.meta.inheritBuff);
|
||||||
|
const previousLevel = readBuffLevel(buff, input.buffType);
|
||||||
|
if (input.level === previousLevel) return reject(action, 'BAD_REQUEST', '이미 구입했습니다.');
|
||||||
|
if (input.level < previousLevel) return reject(action, 'BAD_REQUEST', '이미 더 높은 등급을 구입했습니다.');
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const cost =
|
||||||
|
(constants.inheritBuffPoints[input.level] ?? 0) - (constants.inheritBuffPoints[previousLevel] ?? 0);
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
const moreText = previousLevel > 0 ? '추가' : '';
|
||||||
|
buff[input.buffType] = input.level;
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
`${cost} 포인트로 ${BUFF_LABELS[input.buffType]} ${input.level} 단계 ${moreText}구입`
|
||||||
|
);
|
||||||
|
applyCharge({
|
||||||
|
world,
|
||||||
|
general,
|
||||||
|
userId,
|
||||||
|
previousPoint,
|
||||||
|
cost,
|
||||||
|
patch: { meta: { ...general.meta, inheritBuff: JSON.stringify(buff) } },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
generalId: general.id,
|
||||||
|
remainPoint: previousPoint - cost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'setNextSpecialWar') {
|
||||||
|
if (!isWarTraitKey(input.specialKey)) return reject(action, 'BAD_REQUEST', '잘못된 전투 특기입니다.');
|
||||||
|
const configConst = asRecord(config.const);
|
||||||
|
const allowed = Array.isArray(configConst.availableSpecialWar)
|
||||||
|
? configConst.availableSpecialWar.filter((key): key is string => typeof key === 'string')
|
||||||
|
: [];
|
||||||
|
if (allowed.length > 0 && !allowed.includes(input.specialKey)) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '허용되지 않은 전투 특기입니다.');
|
||||||
|
}
|
||||||
|
if (general.role.specialWar === input.specialKey) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '이미 그 특기를 보유하고 있습니다.');
|
||||||
|
}
|
||||||
|
const reserved =
|
||||||
|
typeof general.meta.inheritSpecificSpecialWar === 'string' ? general.meta.inheritSpecificSpecialWar : null;
|
||||||
|
if (reserved === input.specialKey) return reject(action, 'BAD_REQUEST', '이미 그 특기를 예약하였습니다.');
|
||||||
|
if (reserved) return reject(action, 'BAD_REQUEST', '이미 예약한 특기가 있습니다.');
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const cost = constants.inheritSpecificSpecialPoint;
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
|
||||||
|
const warName = warModule?.name ?? input.specialKey;
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
`${cost} 포인트로 다음 전투 특기로 ${warName} 지정`
|
||||||
|
);
|
||||||
|
applyCharge({
|
||||||
|
world,
|
||||||
|
general,
|
||||||
|
userId,
|
||||||
|
previousPoint,
|
||||||
|
cost,
|
||||||
|
patch: { meta: { ...general.meta, inheritSpecificSpecialWar: input.specialKey } },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
generalId: general.id,
|
||||||
|
remainPoint: previousPoint - cost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'resetSpecialWar') {
|
||||||
|
const currentSpecial = general.role.specialWar;
|
||||||
|
if (!currentSpecial || currentSpecial === 'None') {
|
||||||
|
return reject(action, 'BAD_REQUEST', '이미 전투 특기가 공란입니다.');
|
||||||
|
}
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const currentLevel = readMetaNumber(general.meta, 'inheritResetSpecialWar', -1);
|
||||||
|
const nextLevel = currentLevel + 1;
|
||||||
|
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
const previousTypes = readStringList(general.meta.prev_types_special2);
|
||||||
|
previousTypes.push(currentSpecial);
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
`${cost} 포인트로 전투 특기 초기화`
|
||||||
|
);
|
||||||
|
applyCharge({
|
||||||
|
world,
|
||||||
|
general,
|
||||||
|
userId,
|
||||||
|
previousPoint,
|
||||||
|
cost,
|
||||||
|
patch: {
|
||||||
|
role: { ...general.role, specialWar: null },
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
inheritResetSpecialWar: nextLevel,
|
||||||
|
prev_types_special2: previousTypes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
generalId: general.id,
|
||||||
|
remainPoint: previousPoint - cost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'resetTurnTime') {
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const currentLevel = readMetaNumber(general.meta, 'inheritResetTurnTime', -1);
|
||||||
|
const nextLevel = currentLevel + 1;
|
||||||
|
const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel);
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
const rawSeedTurnTime = general.meta.nextTurnTimeBase ?? general.turnTick ?? 0;
|
||||||
|
const seedTurnTime =
|
||||||
|
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
|
||||||
|
? rawSeedTurnTime
|
||||||
|
: typeof rawSeedTurnTime === 'bigint'
|
||||||
|
? Number(rawSeedTurnTime)
|
||||||
|
: 0;
|
||||||
|
const hiddenSeed =
|
||||||
|
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||||
|
? worldMeta.hiddenSeed
|
||||||
|
: 'inherit';
|
||||||
|
const timing = resolveResetTurnTimeBase({
|
||||||
|
hiddenSeed,
|
||||||
|
userId,
|
||||||
|
previousTurnTimeBase: seedTurnTime,
|
||||||
|
tickSeconds: state.tickSeconds,
|
||||||
|
});
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${timing.nextTurnTimeLabel} 적용`
|
||||||
|
);
|
||||||
|
applyCharge({
|
||||||
|
world,
|
||||||
|
general,
|
||||||
|
userId,
|
||||||
|
previousPoint,
|
||||||
|
cost,
|
||||||
|
patch: {
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
inheritResetTurnTime: nextLevel,
|
||||||
|
nextTurnTimeBase: timing.nextTurnTimeBase,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
generalId: general.id,
|
||||||
|
remainPoint: previousPoint - cost,
|
||||||
|
...timing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'resetStat') {
|
||||||
|
const statConfig = asRecord(configRecord.stat);
|
||||||
|
const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel);
|
||||||
|
const statMin = asNumber(statConfig.min, 1);
|
||||||
|
const statMax = asNumber(statConfig.max, 999);
|
||||||
|
if (input.leadership + input.strength + input.intel !== statTotal) {
|
||||||
|
return reject(action, 'BAD_REQUEST', `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.leadership < statMin ||
|
||||||
|
input.strength < statMin ||
|
||||||
|
input.intel < statMin ||
|
||||||
|
input.leadership > statMax ||
|
||||||
|
input.strength > statMax ||
|
||||||
|
input.intel > statMax
|
||||||
|
) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '능력치 범위를 벗어났습니다.');
|
||||||
|
}
|
||||||
|
const bonus = input.inheritBonusStat ?? [0, 0, 0];
|
||||||
|
const bonusSum = bonus.reduce((sum, value) => sum + value, 0);
|
||||||
|
if (bonus.some((value) => value < 0)) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 입력해주세요!');
|
||||||
|
}
|
||||||
|
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!');
|
||||||
|
}
|
||||||
|
if (general.npcState !== 0) return reject(action, 'BAD_REQUEST', 'NPC는 능력치 초기화를 할 수 없습니다.');
|
||||||
|
if (!isCentennialStatResetAllowed(config)) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const cost = bonusSum > 0 ? constants.inheritBornStatPoint : 0;
|
||||||
|
const season = resolveSeasonValue(worldMeta);
|
||||||
|
const userStateRow =
|
||||||
|
season === null
|
||||||
|
? null
|
||||||
|
: await db.inheritanceUserState.findUnique({ where: { userId }, select: { meta: true } });
|
||||||
|
const userState = asRecord(userStateRow?.meta);
|
||||||
|
const resetSeasons = readResetSeasons(userState);
|
||||||
|
if (season !== null && resetSeasons.includes(season)) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '이번 시즌에 이미 능력치를 초기화하셨습니다.');
|
||||||
|
}
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
const statHiddenSeed =
|
||||||
|
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||||
|
? worldMeta.hiddenSeed
|
||||||
|
: 'inherit';
|
||||||
|
const baseStats = [input.leadership, input.strength, input.intel] as [number, number, number];
|
||||||
|
const finalBonus =
|
||||||
|
bonusSum === 0
|
||||||
|
? buildResetStatRandomBonus(
|
||||||
|
new RandUtil(new LiteHashDRBG(simpleSerialize(statHiddenSeed, 'ResetStat', userId))),
|
||||||
|
baseStats
|
||||||
|
)
|
||||||
|
: (bonus as [number, number, number]);
|
||||||
|
const nextStats = {
|
||||||
|
leadership: input.leadership + finalBonus[0],
|
||||||
|
strength: input.strength + finalBonus[1],
|
||||||
|
intel: input.intel + finalBonus[2],
|
||||||
|
};
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
`통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정`
|
||||||
|
);
|
||||||
|
await appendInheritanceLog(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
state.currentYear,
|
||||||
|
state.currentMonth,
|
||||||
|
bonusSum > 0
|
||||||
|
? `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
|
||||||
|
: `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용`
|
||||||
|
);
|
||||||
|
if (season !== null) {
|
||||||
|
await db.inheritanceUserState.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: { meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
|
||||||
|
create: { userId, meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyCharge({
|
||||||
|
world,
|
||||||
|
general,
|
||||||
|
userId,
|
||||||
|
previousPoint,
|
||||||
|
cost,
|
||||||
|
patch: {
|
||||||
|
stats: {
|
||||||
|
leadership: nextStats.leadership,
|
||||||
|
strength: nextStats.strength,
|
||||||
|
intelligence: nextStats.intel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
generalId: general.id,
|
||||||
|
remainPoint: previousPoint - cost,
|
||||||
|
stats: nextStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (general.meta.inheritRandomUnique !== undefined && general.meta.inheritRandomUnique !== null) {
|
||||||
|
return reject(action, 'BAD_REQUEST', '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.');
|
||||||
|
}
|
||||||
|
if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.');
|
||||||
|
const previousPoint = await lockPreviousPoint(db, userId);
|
||||||
|
const cost = constants.inheritItemRandomPoint;
|
||||||
|
if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.');
|
||||||
|
await appendInheritanceLog(db, userId, state.currentYear, state.currentMonth, `${cost} 포인트로 랜덤 유니크 구입`);
|
||||||
|
applyCharge({
|
||||||
|
world,
|
||||||
|
general,
|
||||||
|
userId,
|
||||||
|
previousPoint,
|
||||||
|
cost,
|
||||||
|
patch: { meta: { ...general.meta, inheritRandomUnique: 1 } },
|
||||||
|
});
|
||||||
|
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
REBIRTH_INHERITANCE_COEFFICIENTS,
|
||||||
|
type MergedInheritanceKey,
|
||||||
|
} from '@sammo-ts/logic/inheritance/pointCalculation.js';
|
||||||
|
|
||||||
|
const LEGACY_KEY_ORDER = [
|
||||||
|
'lived_month',
|
||||||
|
'max_belong',
|
||||||
|
'max_domestic_critical',
|
||||||
|
'active_action',
|
||||||
|
'combat',
|
||||||
|
'sabotage',
|
||||||
|
'unifier',
|
||||||
|
'dex',
|
||||||
|
'tournament',
|
||||||
|
'betting',
|
||||||
|
] as const satisfies readonly MergedInheritanceKey[];
|
||||||
|
|
||||||
|
const LEGACY_CALCULATED_KEYS = new Set<MergedInheritanceKey>(['max_belong', 'combat', 'sabotage', 'dex', 'betting']);
|
||||||
|
|
||||||
|
const LEGACY_KEY_LABEL: Readonly<Record<'previous' | MergedInheritanceKey, string>> = {
|
||||||
|
previous: '기존 보유',
|
||||||
|
lived_month: '생존',
|
||||||
|
max_belong: '최대 임관년 수',
|
||||||
|
max_domestic_critical: '최대 연속 내정 성공',
|
||||||
|
active_action: '능동 행동 수',
|
||||||
|
combat: '전투 횟수',
|
||||||
|
sabotage: '계략 성공 횟수',
|
||||||
|
unifier: '천통 기여',
|
||||||
|
dex: '숙련도',
|
||||||
|
tournament: '토너먼트',
|
||||||
|
betting: '베팅 당첨',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLegacyPoint = (value: number): string => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
return String(Object.is(value, -0) ? 0 : value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildInheritanceSettlementLogTexts = (input: {
|
||||||
|
previous: number;
|
||||||
|
points: Readonly<Partial<Record<MergedInheritanceKey, number>>>;
|
||||||
|
storedKeys: ReadonlySet<string>;
|
||||||
|
total: number;
|
||||||
|
isRebirth: boolean;
|
||||||
|
}): string[] => {
|
||||||
|
const texts = input.storedKeys.has('previous')
|
||||||
|
? [`${LEGACY_KEY_LABEL.previous} 포인트 ${formatLegacyPoint(input.previous)} 증가`]
|
||||||
|
: [];
|
||||||
|
for (const key of LEGACY_KEY_ORDER) {
|
||||||
|
if (!LEGACY_CALCULATED_KEYS.has(key) && !input.storedKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (input.isRebirth && REBIRTH_INHERITANCE_COEFFICIENTS[key] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
texts.push(`${LEGACY_KEY_LABEL[key]} 포인트 ${formatLegacyPoint(input.points[key] ?? 0)} 증가`);
|
||||||
|
}
|
||||||
|
texts.push(`포인트 ${formatLegacyPoint(input.previous)} => ${formatLegacyPoint(input.total)}`);
|
||||||
|
return texts;
|
||||||
|
};
|
||||||
@@ -377,7 +377,11 @@ export const createUpdateNationLevelHandler = (options: {
|
|||||||
const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
|
const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
|
||||||
if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
|
if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
|
||||||
const amount = 250 * levelDiff;
|
const amount = 250 * levelDiff;
|
||||||
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount);
|
// General turns (including rebirth settlement) finish before
|
||||||
|
// monthly actions in the processor. Persist this award after
|
||||||
|
// lifecycle so the retirement result cannot claim a later
|
||||||
|
// promotion as pre-rebirth retained state.
|
||||||
|
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount, 'after_lifecycle');
|
||||||
world.updateGeneral(chief.id, {
|
world.updateGeneral(chief.id, {
|
||||||
inheritancePoints: {
|
inheritancePoints: {
|
||||||
...chief.inheritancePoints,
|
...chief.inheritancePoints,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
loadItemModules,
|
loadItemModules,
|
||||||
resolveUniqueConfig,
|
resolveUniqueConfig,
|
||||||
readScenarioGeneralPoolClaim,
|
readScenarioGeneralPoolClaim,
|
||||||
rollUniqueLottery,
|
rollUniqueLotteryDetailed,
|
||||||
getNextTurnAt,
|
getNextTurnAt,
|
||||||
getBillByLevel,
|
getBillByLevel,
|
||||||
LEGACY_DEFAULT_MAX_LEVEL,
|
LEGACY_DEFAULT_MAX_LEVEL,
|
||||||
@@ -479,6 +479,8 @@ const buildUniqueLotteryRunner = (options: {
|
|||||||
seedBase: string;
|
seedBase: string;
|
||||||
itemRegistry: Map<string, ItemModule>;
|
itemRegistry: Map<string, ItemModule>;
|
||||||
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
|
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
|
||||||
|
inheritItemRandomPoint: number;
|
||||||
|
inheritanceWorld?: InMemoryTurnWorld | null;
|
||||||
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
||||||
}): UniqueLotteryRunner => {
|
}): UniqueLotteryRunner => {
|
||||||
if (!options.worldView) {
|
if (!options.worldView) {
|
||||||
@@ -523,7 +525,7 @@ const buildUniqueLotteryRunner = (options: {
|
|||||||
const relMonthByInit =
|
const relMonthByInit =
|
||||||
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
|
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
|
||||||
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
|
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
|
||||||
const itemKey = rollUniqueLottery({
|
const outcome = rollUniqueLotteryDetailed({
|
||||||
rng,
|
rng,
|
||||||
config: options.uniqueConfig,
|
config: options.uniqueConfig,
|
||||||
itemRegistry: options.itemRegistry,
|
itemRegistry: options.itemRegistry,
|
||||||
@@ -539,13 +541,54 @@ const buildUniqueLotteryRunner = (options: {
|
|||||||
acquireType,
|
acquireType,
|
||||||
inheritRandomUnique,
|
inheritRandomUnique,
|
||||||
});
|
});
|
||||||
if (!itemKey) {
|
if (outcome.status === 'NO_SLOT' || outcome.status === 'NO_SUPPLY') {
|
||||||
|
if (inheritRandomUnique) {
|
||||||
|
const turnGeneral = general as TurnGeneral;
|
||||||
|
const cost = options.inheritItemRandomPoint;
|
||||||
|
const nextMeta = {
|
||||||
|
...turnGeneral.meta,
|
||||||
|
// Explicit retirement resets every rank before this lottery in Ref,
|
||||||
|
// so a failed pending purchase leaves the post-rebirth delta at -cost.
|
||||||
|
inherit_spent_dyn:
|
||||||
|
reason === '은퇴'
|
||||||
|
? -cost
|
||||||
|
: readMetaNumber(asRecord(turnGeneral.meta), 'inherit_spent_dyn', 0) - cost,
|
||||||
|
} as TurnGeneral['meta'];
|
||||||
|
delete nextMeta.inheritRandomUnique;
|
||||||
|
turnGeneral.meta = nextMeta;
|
||||||
|
turnGeneral.inheritancePoints = {
|
||||||
|
...turnGeneral.inheritancePoints,
|
||||||
|
previous: readInheritanceNumber(turnGeneral.inheritancePoints?.previous) + cost,
|
||||||
|
};
|
||||||
|
if (turnGeneral.userId) {
|
||||||
|
const persistencePhase = reason === '은퇴' ? 'after_lifecycle' : undefined;
|
||||||
|
options.inheritanceWorld?.queueInheritancePointAdjustment(
|
||||||
|
turnGeneral.userId,
|
||||||
|
'previous',
|
||||||
|
cost,
|
||||||
|
persistencePhase
|
||||||
|
);
|
||||||
|
options.inheritanceWorld?.queueInheritanceLog({
|
||||||
|
userId: turnGeneral.userId,
|
||||||
|
year: world.currentYear,
|
||||||
|
month: world.currentMonth,
|
||||||
|
text:
|
||||||
|
outcome.status === 'NO_SLOT'
|
||||||
|
? `유니크를 얻을 공간이 없어 ${cost} 포인트 반환`
|
||||||
|
: `얻을 유니크가 없어 ${cost} 포인트 반환`,
|
||||||
|
...(persistencePhase ? { phase: persistencePhase } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (outcome.status === 'ROLL_FAILED') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (inheritRandomUnique && availableBuyUnique) {
|
if (inheritRandomUnique && availableBuyUnique) {
|
||||||
delete asRecord(general.meta).inheritRandomUnique;
|
delete asRecord(general.meta).inheritRandomUnique;
|
||||||
}
|
}
|
||||||
return options.itemRegistry.get(itemKey) ?? null;
|
return options.itemRegistry.get(outcome.itemKey) ?? null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -885,6 +928,11 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
|
const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
|
||||||
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
|
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
|
||||||
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
|
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
|
||||||
|
const inheritItemRandomPoint = readMetaNumber(
|
||||||
|
asRecord(options.scenarioConfig.const),
|
||||||
|
'inheritItemRandomPoint',
|
||||||
|
3_000
|
||||||
|
);
|
||||||
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||||
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||||
}
|
}
|
||||||
@@ -1133,6 +1181,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
seedBase,
|
seedBase,
|
||||||
itemRegistry,
|
itemRegistry,
|
||||||
uniqueConfig,
|
uniqueConfig,
|
||||||
|
inheritItemRandomPoint,
|
||||||
|
inheritanceWorld: worldRef,
|
||||||
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
|
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
|
||||||
});
|
});
|
||||||
let actionRng = sharedActionRng ?? buildRng(actionKey);
|
let actionRng = sharedActionRng ?? buildRng(actionKey);
|
||||||
@@ -2086,6 +2136,10 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
}
|
}
|
||||||
generalAiState = ai.getDebugState();
|
generalAiState = ai.getDebugState();
|
||||||
}
|
}
|
||||||
|
// che_은퇴 performs the rebirth inside the action, as Ref does. Preserve
|
||||||
|
// the fully accumulated pre-command state so lifecycle persistence can
|
||||||
|
// settle Hall/inheritance before observing that reset.
|
||||||
|
const explicitRetirementSnapshot = cloneTurnGeneral(currentGeneral);
|
||||||
const generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
|
const generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
|
||||||
const generalResult = isBlocked
|
const generalResult = isBlocked
|
||||||
? {
|
? {
|
||||||
@@ -2176,7 +2230,10 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
delete currentGeneral.meta.nextTurnTimeBase;
|
delete currentGeneral.meta.nextTurnTimeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active';
|
const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed;
|
||||||
|
let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired
|
||||||
|
? 'retired'
|
||||||
|
: 'active';
|
||||||
let deleteGeneral = false;
|
let deleteGeneral = false;
|
||||||
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
||||||
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
||||||
@@ -2353,8 +2410,18 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
lifecycleEvent: {
|
lifecycleEvent: {
|
||||||
generalId: currentGeneral.id,
|
generalId: currentGeneral.id,
|
||||||
outcome: lifecycleOutcome,
|
outcome: lifecycleOutcome,
|
||||||
before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot,
|
before:
|
||||||
|
lifecycleOutcome === 'active'
|
||||||
|
? lifecycleBefore
|
||||||
|
: explicitlyRetired
|
||||||
|
? explicitRetirementSnapshot
|
||||||
|
: lifecycleSnapshot,
|
||||||
...(deleteGeneral ? {} : { after: currentGeneral }),
|
...(deleteGeneral ? {} : { after: currentGeneral }),
|
||||||
|
isUnitedAtEvent: readMetaNumber(
|
||||||
|
asRecord(context.world.meta),
|
||||||
|
'isunited',
|
||||||
|
readMetaNumber(asRecord(context.world.meta), 'isUnited', 0)
|
||||||
|
),
|
||||||
year: context.world.currentYear,
|
year: context.world.currentYear,
|
||||||
month: context.world.currentMonth,
|
month: context.world.currentMonth,
|
||||||
},
|
},
|
||||||
@@ -2418,6 +2485,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
|
|
||||||
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
|
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
|
||||||
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const));
|
const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const));
|
||||||
|
const inheritItemRandomPoint = readMetaNumber(
|
||||||
|
asRecord(options.world.getScenarioConfig().const),
|
||||||
|
'inheritItemRandomPoint',
|
||||||
|
3_000
|
||||||
|
);
|
||||||
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||||
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||||
}
|
}
|
||||||
@@ -2488,6 +2560,8 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
seedBase,
|
seedBase,
|
||||||
itemRegistry,
|
itemRegistry,
|
||||||
uniqueConfig,
|
uniqueConfig,
|
||||||
|
inheritItemRandomPoint,
|
||||||
|
inheritanceWorld: options.world,
|
||||||
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
|
getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys,
|
||||||
});
|
});
|
||||||
const startYear = resolveStartYear(state, options.scenarioMeta);
|
const startYear = resolveStartYear(state, options.scenarioMeta);
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameTy
|
|||||||
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
||||||
|
import {
|
||||||
|
readCentennialRecordableDexterity,
|
||||||
|
type CentennialDexKey,
|
||||||
|
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||||
|
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js';
|
||||||
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
|
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
|
||||||
|
import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js';
|
||||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||||
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
|
import type { PendingUnificationAuctionCancellation } from './types.js';
|
||||||
|
|
||||||
const UNIFIER_POINT = 2000;
|
const UNIFIER_POINT = 2000;
|
||||||
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
||||||
@@ -49,14 +55,13 @@ const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
|
|||||||
|
|
||||||
export const resolveStoredInheritancePoint = (
|
export const resolveStoredInheritancePoint = (
|
||||||
currentPoints: ReadonlyMap<string, number>,
|
currentPoints: ReadonlyMap<string, number>,
|
||||||
general: Pick<TurnGeneral, 'inheritancePoints'>,
|
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number]
|
||||||
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number],
|
): number => {
|
||||||
unifierAward: number
|
// All turn/month/auction mutations are persisted before finalization. A
|
||||||
): number =>
|
// missing row therefore means zero; the general snapshot can still contain
|
||||||
currentPoints.get(key) ??
|
// a rebirth-paid bucket that the lifecycle transaction deliberately deleted.
|
||||||
(key === 'unifier'
|
return currentPoints.get(key) ?? 0;
|
||||||
? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward)
|
};
|
||||||
: (general.inheritancePoints?.[key] ?? 0));
|
|
||||||
|
|
||||||
const formatHistogram = (value: unknown): string =>
|
const formatHistogram = (value: unknown): string =>
|
||||||
Object.entries(asRecord(value))
|
Object.entries(asRecord(value))
|
||||||
@@ -329,7 +334,7 @@ export const persistUnificationFinalization = async (
|
|||||||
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
|
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
|
||||||
const mergedPoints = Object.fromEntries(
|
const mergedPoints = Object.fromEntries(
|
||||||
ALL_MERGED_INHERITANCE_KEYS.map((key) => {
|
ALL_MERGED_INHERITANCE_KEYS.map((key) => {
|
||||||
const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward);
|
const stored = resolveStoredInheritancePoint(currentPoints, key);
|
||||||
const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
|
const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
|
||||||
return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
|
return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
|
||||||
})
|
})
|
||||||
@@ -359,16 +364,24 @@ export const persistUnificationFinalization = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
for (const text of buildInheritanceSettlementLogTexts({
|
||||||
|
previous,
|
||||||
|
points: mergedPoints,
|
||||||
|
storedKeys: new Set([...currentPoints.keys(), ...(unifierAward > 0 ? (['unifier'] as const) : [])]),
|
||||||
|
total,
|
||||||
|
isRebirth: false,
|
||||||
|
})) {
|
||||||
await transaction.inheritanceLog.create({
|
await transaction.inheritanceLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
serverId,
|
serverId,
|
||||||
year: input.year,
|
year: input.year,
|
||||||
month: input.month,
|
month: input.month,
|
||||||
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`,
|
text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rankRows = generals.length
|
const rankRows = generals.length
|
||||||
? await transaction.rankData.findMany({
|
? await transaction.rankData.findMany({
|
||||||
@@ -388,7 +401,7 @@ export const persistUnificationFinalization = async (
|
|||||||
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
|
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
|
||||||
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
||||||
const unitedTime = input.completedAt.toISOString();
|
const unitedTime = input.completedAt.toISOString();
|
||||||
const serverCount = await transaction.gameHistory.count();
|
const serverIdx = await resolveOfficialGameIndex(transaction, meta);
|
||||||
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
|
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
|
||||||
|
|
||||||
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
|
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
|
||||||
@@ -429,7 +442,7 @@ export const persistUnificationFinalization = async (
|
|||||||
unitedTime,
|
unitedTime,
|
||||||
ownerDisplayName: ownerDisplayName(generalMeta),
|
ownerDisplayName: ownerDisplayName(generalMeta),
|
||||||
serverID: serverId,
|
serverID: serverId,
|
||||||
serverIdx: serverCount,
|
serverIdx,
|
||||||
serverName,
|
serverName,
|
||||||
scenarioName,
|
scenarioName,
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
@@ -445,7 +458,7 @@ export const persistUnificationFinalization = async (
|
|||||||
? general.experience
|
? general.experience
|
||||||
: type === 'dedication'
|
: type === 'dedication'
|
||||||
? general.dedication
|
? general.dedication
|
||||||
: readNumber(generalMeta[type]);
|
: readCentennialRecordableDexterity(generalMeta, type as CentennialDexKey);
|
||||||
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
|
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
|
||||||
if (type === 'ttrate' && totals.tt < 50) continue;
|
if (type === 'ttrate' && totals.tt < 50) continue;
|
||||||
if (type === 'tlrate' && totals.tl < 50) continue;
|
if (type === 'tlrate' && totals.tl < 50) continue;
|
||||||
@@ -454,17 +467,7 @@ export const persistUnificationFinalization = async (
|
|||||||
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
|
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
|
||||||
if (value <= 0) continue;
|
if (value <= 0) continue;
|
||||||
|
|
||||||
const existing = await transaction.hallOfFame.findFirst({
|
await persistHallOfFameCandidate(transaction, {
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ serverId, type, generalNo: general.id },
|
|
||||||
{ serverId, type, owner: general.userId },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
await transaction.hallOfFame.create({
|
|
||||||
data: {
|
|
||||||
serverId,
|
serverId,
|
||||||
season,
|
season,
|
||||||
scenario,
|
scenario,
|
||||||
@@ -473,11 +476,7 @@ export const persistUnificationFinalization = async (
|
|||||||
value,
|
value,
|
||||||
owner: general.userId ?? null,
|
owner: general.userId ?? null,
|
||||||
aux,
|
aux,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else if (value > existing.value) {
|
|
||||||
await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +630,7 @@ export const persistUnificationFinalization = async (
|
|||||||
await transaction.emperor.create({
|
await transaction.emperor.create({
|
||||||
data: {
|
data: {
|
||||||
serverId,
|
serverId,
|
||||||
phase: `${serverName}${serverCount}기`,
|
phase: `${serverName}${serverIdx}기`,
|
||||||
nationCount,
|
nationCount,
|
||||||
nationName: statisticNationNames || archivedNationNames.join(', '),
|
nationName: statisticNationNames || archivedNationNames.join(', '),
|
||||||
nationHist: formatHistogram(statistics.maxNationHist),
|
nationHist: formatHistogram(statistics.maxNationHist),
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGener
|
|||||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
||||||
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
||||||
|
import { executeInheritanceAction } from './inheritanceActionService.js';
|
||||||
|
|
||||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||||
|
|
||||||
@@ -162,7 +163,8 @@ const resolveCommandAcceptedAt = async (
|
|||||||
| 'selectPoolReserve'
|
| 'selectPoolReserve'
|
||||||
| 'selectPoolCreate'
|
| 'selectPoolCreate'
|
||||||
| 'selectPoolReselect'
|
| 'selectPoolReselect'
|
||||||
| 'adjustGeneralIcon';
|
| 'adjustGeneralIcon'
|
||||||
|
| 'inheritanceAction';
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
): Promise<Date> => {
|
): Promise<Date> => {
|
||||||
@@ -802,6 +804,20 @@ async function handlePatchGeneral(
|
|||||||
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
|
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleInheritanceAction(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const db = requireCommandDatabase(ctx) as unknown as GamePrisma.TransactionClient;
|
||||||
|
const acceptedAt = await resolveCommandAcceptedAt(db as unknown as DatabaseClient, command);
|
||||||
|
return executeInheritanceAction({
|
||||||
|
db,
|
||||||
|
world: ctx.world,
|
||||||
|
command,
|
||||||
|
gameNow: ctx.world.getGameNow(acceptedAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdjustGeneralIcon(
|
async function handleAdjustGeneralIcon(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
||||||
@@ -2936,6 +2952,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
||||||
patchGeneral: (command) =>
|
patchGeneral: (command) =>
|
||||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||||
|
inheritanceAction: (command) =>
|
||||||
|
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
|
||||||
adjustGeneralIcon: (command) =>
|
adjustGeneralIcon: (command) =>
|
||||||
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
|
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
|
||||||
joinCreateGeneral: (command) =>
|
joinCreateGeneral: (command) =>
|
||||||
|
|||||||
@@ -399,4 +399,153 @@ describe('legacy general turn lifecycle', () => {
|
|||||||
}
|
}
|
||||||
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
|
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits an explicit retirement lifecycle event with the pre-rebirth snapshot', async () => {
|
||||||
|
const harness = await createTurnTestHarness({
|
||||||
|
snapshot: makeSnapshot([
|
||||||
|
makeGeneral({
|
||||||
|
age: 65,
|
||||||
|
experience: 1_001,
|
||||||
|
dedication: 801,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: {
|
||||||
|
horse: 'che_명마_07_백마',
|
||||||
|
weapon: 'che_무기_07_동추',
|
||||||
|
book: 'che_서적_07_위료자',
|
||||||
|
item: 'che_의술_정력견혈산',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
killturn: 24,
|
||||||
|
rank_warnum: 11,
|
||||||
|
firenum: 9,
|
||||||
|
inherit_earned: 4_321,
|
||||||
|
inherit_lived_month: 10,
|
||||||
|
inherit_active_action: 4,
|
||||||
|
inheritRandomUnique: 1,
|
||||||
|
inherit_spent_dyn: 3_000,
|
||||||
|
dex1: 101,
|
||||||
|
},
|
||||||
|
inheritancePoints: { previous: 50 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
state: makeState(),
|
||||||
|
schedule,
|
||||||
|
map,
|
||||||
|
});
|
||||||
|
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_은퇴', args: {} };
|
||||||
|
harness.reservedTurnStore.getGeneralTurns(1)[1] = { action: 'che_은퇴', args: {} };
|
||||||
|
|
||||||
|
await harness.runOneTick();
|
||||||
|
await harness.runOneTick();
|
||||||
|
|
||||||
|
const current = harness.world.getGeneralById(1)!;
|
||||||
|
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
|
||||||
|
expect(lifecycle).toMatchObject({
|
||||||
|
outcome: 'retired',
|
||||||
|
isUnitedAtEvent: 0,
|
||||||
|
before: {
|
||||||
|
age: 65,
|
||||||
|
experience: 1_001,
|
||||||
|
dedication: 801,
|
||||||
|
meta: {
|
||||||
|
rank_warnum: 11,
|
||||||
|
firenum: 9,
|
||||||
|
inherit_earned: 4_321,
|
||||||
|
inherit_lived_month: 12,
|
||||||
|
inherit_active_action: 4,
|
||||||
|
inheritRandomUnique: 1,
|
||||||
|
inherit_spent_dyn: 3_000,
|
||||||
|
dex1: 101,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
age: 20,
|
||||||
|
meta: { inherit_lived_month: 0, inherit_active_action: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(current).toMatchObject({
|
||||||
|
age: 20,
|
||||||
|
experience: 501,
|
||||||
|
dedication: 401,
|
||||||
|
inheritancePoints: { previous: 3_050 },
|
||||||
|
meta: {
|
||||||
|
rank_warnum: 0,
|
||||||
|
firenum: 0,
|
||||||
|
inherit_earned: 0,
|
||||||
|
inherit_lived_month: 0,
|
||||||
|
inherit_active_action: 0,
|
||||||
|
inherit_spent_dyn: -3_000,
|
||||||
|
dex1: 51,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
|
||||||
|
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
||||||
|
userId: 'user-1',
|
||||||
|
key: 'previous',
|
||||||
|
amount: 3_000,
|
||||||
|
phase: 'after_lifecycle',
|
||||||
|
});
|
||||||
|
expect(harness.world.peekDirtyState().pendingInheritanceLogs).toContainEqual({
|
||||||
|
userId: 'user-1',
|
||||||
|
year: 200,
|
||||||
|
month: 2,
|
||||||
|
text: '유니크를 얻을 공간이 없어 3000 포인트 반환',
|
||||||
|
phase: 'after_lifecycle',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refunds a failed pending lottery before automatic retirement and resets the spent rank to zero', async () => {
|
||||||
|
const harness = await createTurnTestHarness({
|
||||||
|
snapshot: makeSnapshot([
|
||||||
|
makeGeneral({
|
||||||
|
age: 80,
|
||||||
|
crew: 100,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: {
|
||||||
|
horse: 'che_명마_07_백마',
|
||||||
|
weapon: 'che_무기_07_동추',
|
||||||
|
book: 'che_서적_07_위료자',
|
||||||
|
item: 'che_의술_정력견혈산',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
killturn: 24,
|
||||||
|
inheritRandomUnique: true,
|
||||||
|
inherit_spent_dyn: 3_000,
|
||||||
|
inherit_lived_month: 10,
|
||||||
|
},
|
||||||
|
inheritancePoints: { previous: 70 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
state: makeState(),
|
||||||
|
schedule,
|
||||||
|
map,
|
||||||
|
});
|
||||||
|
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
|
||||||
|
|
||||||
|
await harness.runOneTick();
|
||||||
|
|
||||||
|
const current = harness.world.getGeneralById(1)!;
|
||||||
|
expect(current).toMatchObject({
|
||||||
|
age: 20,
|
||||||
|
inheritancePoints: { previous: 3_070 },
|
||||||
|
meta: { inherit_spent_dyn: 0, inherit_lived_month: 0 },
|
||||||
|
});
|
||||||
|
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
|
||||||
|
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
||||||
|
userId: 'user-1',
|
||||||
|
key: 'previous',
|
||||||
|
amount: 3_000,
|
||||||
|
});
|
||||||
|
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
|
||||||
|
expect(lifecycle?.before.meta).toMatchObject({ inherit_spent_dyn: 0 });
|
||||||
|
expect(lifecycle?.before.meta).not.toHaveProperty('inheritRandomUnique');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
|
import { asRecord, normalizeArchivedGeneral, RANK_DATA_TYPES, type ArchivedJsonValue } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
|
||||||
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||||
import type { TurnGeneral } from '../src/turn/types.js';
|
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
|
||||||
|
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
|
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const generalIds = [990_001, 990_002, 990_003];
|
const generalIds = [990_001, 990_002, 990_003, 990_004, 990_005, 990_006, 990_007];
|
||||||
const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
|
const userIds = [
|
||||||
|
'integration-lifecycle-dead',
|
||||||
|
'integration-lifecycle-retired',
|
||||||
|
'integration-lifecycle-possessed',
|
||||||
|
'integration-lifecycle-explicit-retired',
|
||||||
|
'integration-lifecycle-automatic-retired',
|
||||||
|
'integration-lifecycle-death-archive',
|
||||||
|
'integration-lifecycle-retire-before-unification',
|
||||||
|
];
|
||||||
const serverId = 'lifecycle-int';
|
const serverId = 'lifecycle-int';
|
||||||
|
const sameFlushServerId = `${serverId}-retire-before-unification`;
|
||||||
|
const worldId = 990_004;
|
||||||
|
const deathArchiveWorldId = 990_006;
|
||||||
|
const sameFlushWorldId = 990_007;
|
||||||
|
const nationId = 990_004;
|
||||||
|
const cityId = 990_004;
|
||||||
|
const sameFlushNationId = 990_007;
|
||||||
|
const sameFlushCityId = 990_007;
|
||||||
|
const archiveServerIds = [serverId, sameFlushServerId];
|
||||||
|
const historyServerIds = [serverId, `${serverId}-completed`, `${serverId}-abandoned`, sameFlushServerId];
|
||||||
|
|
||||||
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||||
id,
|
id,
|
||||||
@@ -69,12 +93,30 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
|
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
|
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
|
await db.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
await db.unificationFinalization.deleteMany({ where: { serverId: sameFlushServerId } });
|
||||||
await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
await db.emperor.deleteMany({ where: { serverId: sameFlushServerId } });
|
||||||
await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } });
|
await db.oldNation.deleteMany({ where: { serverId: sameFlushServerId } });
|
||||||
|
await db.oldGeneral.deleteMany({
|
||||||
|
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
|
||||||
|
});
|
||||||
|
await db.hallOfFame.deleteMany({
|
||||||
|
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
|
||||||
|
});
|
||||||
|
await db.inheritanceResult.deleteMany({
|
||||||
|
where: { serverId: { in: archiveServerIds }, owner: { in: userIds } },
|
||||||
|
});
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: generalIds } } });
|
||||||
|
await db.city.deleteMany({ where: { id: { in: [cityId, sameFlushCityId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: { in: [nationId, sameFlushNationId] } } });
|
||||||
|
await db.worldState.deleteMany({
|
||||||
|
where: { id: { in: [worldId, deathArchiveWorldId, sameFlushWorldId] } },
|
||||||
|
});
|
||||||
|
await db.gameHistory.deleteMany({ where: { serverId: { in: historyServerIds } } });
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -189,8 +231,7 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
});
|
});
|
||||||
const archivedData = asRecord(archived.data);
|
const archivedData = asRecord(archived.data);
|
||||||
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
||||||
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
|
expect(asRecord(archivedData.meta)).toMatchObject({ inheritRandomUnique: true });
|
||||||
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
|
|
||||||
const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot;
|
const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot;
|
||||||
expect(snapshot).toMatchObject({
|
expect(snapshot).toMatchObject({
|
||||||
mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 },
|
mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 },
|
||||||
@@ -220,7 +261,19 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
select: { text: true },
|
select: { text: true },
|
||||||
})
|
})
|
||||||
).map(({ text }) => text)
|
).map(({ text }) => text)
|
||||||
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']);
|
).toEqual([
|
||||||
|
'사망으로 랜덤 유니크 구입 3000 포인트 반환',
|
||||||
|
'기존 보유 포인트 3100 증가',
|
||||||
|
'최대 임관년 수 포인트 90 증가',
|
||||||
|
'최대 연속 내정 성공 포인트 80 증가',
|
||||||
|
'전투 횟수 포인트 10 증가',
|
||||||
|
'계략 성공 횟수 포인트 20 증가',
|
||||||
|
'천통 기여 포인트 250 증가',
|
||||||
|
'숙련도 포인트 1.004 증가',
|
||||||
|
'토너먼트 포인트 50 증가',
|
||||||
|
'베팅 당첨 포인트 5 증가',
|
||||||
|
'포인트 3100 => 3622',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
|
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
|
||||||
@@ -255,10 +308,15 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
data: { generalId: general.id, nationId: 0, type: 'inherit_earned', value: 4_321 },
|
data: { generalId: general.id, nationId: 0, type: 'inherit_earned', value: 4_321 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retirementEvent = event(general, 'retired');
|
||||||
|
retirementEvent.after = {
|
||||||
|
...general,
|
||||||
|
meta: { ...general.meta, rank_warnum: 0, inherit_earned: 0 },
|
||||||
|
};
|
||||||
await db.$transaction((tx) =>
|
await db.$transaction((tx) =>
|
||||||
persistGeneralLifecycleEvents(
|
persistGeneralLifecycleEvents(
|
||||||
tx,
|
tx,
|
||||||
[event(general, 'retired')],
|
[retirementEvent],
|
||||||
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
|
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
@@ -311,6 +369,533 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('archives the death turn history and battle brief through execute and database flush', async () => {
|
||||||
|
const general = makeGeneral(generalIds[5]!, userIds[5]!, {
|
||||||
|
npcState: 2,
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const scenarioConfig = {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: {},
|
||||||
|
const: { killturn: 0 },
|
||||||
|
environment: { mapName: 'che', unitSet: 'che' },
|
||||||
|
};
|
||||||
|
const scenarioMeta = {
|
||||||
|
title: '사망 기록 archive integration',
|
||||||
|
startYear: 200,
|
||||||
|
life: null,
|
||||||
|
fiction: 0,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
};
|
||||||
|
const worldMeta = { serverId, killturn: 0, scenarioMeta };
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: deathArchiveWorldId,
|
||||||
|
scenarioCode: 'death-archive-integration',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: scenarioConfig as GamePrisma.InputJsonValue,
|
||||||
|
meta: worldMeta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.general.create({
|
||||||
|
data: {
|
||||||
|
id: general.id,
|
||||||
|
userId: general.userId,
|
||||||
|
name: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
cityId: general.cityId,
|
||||||
|
npcState: general.npcState,
|
||||||
|
leadership: general.stats.leadership,
|
||||||
|
strength: general.stats.strength,
|
||||||
|
intel: general.stats.intelligence,
|
||||||
|
experience: general.experience,
|
||||||
|
dedication: general.dedication,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
injury: general.injury,
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
crew: general.crew,
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
turnTime: general.turnTime,
|
||||||
|
age: general.age,
|
||||||
|
bornYear: general.bornYear,
|
||||||
|
deadYear: general.deadYear,
|
||||||
|
meta: general.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.logEntry.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
year: 199,
|
||||||
|
month: 12,
|
||||||
|
generalId: general.id,
|
||||||
|
text: '이전 열전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.BATTLE_BRIEF,
|
||||||
|
year: 199,
|
||||||
|
month: 12,
|
||||||
|
generalId: general.id,
|
||||||
|
text: '이전 전투 결과',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: deathArchiveWorldId,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: general.turnTime,
|
||||||
|
meta: worldMeta,
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map: {
|
||||||
|
id: 'death-archive',
|
||||||
|
name: '사망 기록 archive',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
generals: [general],
|
||||||
|
nations: [],
|
||||||
|
cities: [],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
generalTurnHandler: {
|
||||||
|
execute: ({ general: currentGeneral, world: currentWorld }) => ({
|
||||||
|
deleted: { general: true },
|
||||||
|
lifecycleEvent: {
|
||||||
|
generalId: currentGeneral.id,
|
||||||
|
outcome: 'deleted',
|
||||||
|
before: currentGeneral,
|
||||||
|
year: currentWorld.currentYear,
|
||||||
|
month: currentWorld.currentMonth,
|
||||||
|
},
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: currentGeneral.id,
|
||||||
|
text: '마지막 열전 첫째',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.BATTLE_BRIEF,
|
||||||
|
generalId: currentGeneral.id,
|
||||||
|
text: '마지막 전투 결과 첫째',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: currentGeneral.id,
|
||||||
|
text: '마지막 열전 둘째',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.BATTLE_BRIEF,
|
||||||
|
generalId: currentGeneral.id,
|
||||||
|
text: '마지막 전투 결과 둘째',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
world.executeGeneralTurn(world.getGeneralById(general.id)!);
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
try {
|
||||||
|
await hooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 1,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||||
|
where: { by_no: { serverId, generalNo: general.id } },
|
||||||
|
});
|
||||||
|
const archivedData = asRecord(archived.data);
|
||||||
|
expect(archivedData.history).toEqual(['마지막 열전 둘째', '마지막 열전 첫째', '이전 열전']);
|
||||||
|
expect(asRecord(archivedData.records).battleResult).toEqual([
|
||||||
|
'마지막 전투 결과 둘째',
|
||||||
|
'마지막 전투 결과 첫째',
|
||||||
|
'이전 전투 결과',
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
db.logEntry.count({
|
||||||
|
where: {
|
||||||
|
generalId: general.id,
|
||||||
|
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles a pre-month retirement before same-flush unification without losing Hall or repaying stored points', async () => {
|
||||||
|
const turnTime = new Date('0200-01-01T00:05:00.000Z');
|
||||||
|
const monthBoundary = new Date('0200-01-01T00:10:00.000Z');
|
||||||
|
const general = makeGeneral(generalIds[6]!, userIds[6]!, {
|
||||||
|
nationId: sameFlushNationId,
|
||||||
|
cityId: sameFlushCityId,
|
||||||
|
age: 80,
|
||||||
|
officerLevel: 1,
|
||||||
|
turnTime,
|
||||||
|
meta: {
|
||||||
|
killturn: 24,
|
||||||
|
owner_name: '월경계 은퇴 사용자',
|
||||||
|
rank_warnum: 11,
|
||||||
|
firenum: 2,
|
||||||
|
inherit_lived_month: 10,
|
||||||
|
inherit_active_action: 4,
|
||||||
|
dex1: 200,
|
||||||
|
dex2: 0,
|
||||||
|
dex3: 0,
|
||||||
|
dex4: 0,
|
||||||
|
dex5: 0,
|
||||||
|
event100_allstar: { granted: { dex1: 80 } },
|
||||||
|
},
|
||||||
|
inheritancePoints: {
|
||||||
|
previous: 100,
|
||||||
|
lived_month: 10,
|
||||||
|
active_action: 4,
|
||||||
|
tournament: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scenarioConfig = {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: {},
|
||||||
|
const: {
|
||||||
|
retirementYear: 80,
|
||||||
|
minPushHallAge: 30,
|
||||||
|
incDefSettingChange: 3,
|
||||||
|
maxDefSettingChange: 9,
|
||||||
|
},
|
||||||
|
environment: { mapName: 'che', unitSet: 'che' },
|
||||||
|
};
|
||||||
|
const scenarioMeta = {
|
||||||
|
title: '월경계 은퇴 후 통일 integration',
|
||||||
|
startYear: 200,
|
||||||
|
life: null,
|
||||||
|
fiction: 0,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
};
|
||||||
|
const worldMeta = {
|
||||||
|
serverId: sameFlushServerId,
|
||||||
|
serverName: '월경계 서버',
|
||||||
|
season: 9,
|
||||||
|
scenarioId: 77,
|
||||||
|
gameIdx: 12,
|
||||||
|
isUnited: 0,
|
||||||
|
isunited: 0,
|
||||||
|
killturn: 24,
|
||||||
|
scenarioMeta,
|
||||||
|
};
|
||||||
|
const nation = {
|
||||||
|
id: sameFlushNationId,
|
||||||
|
name: '월경계국',
|
||||||
|
color: '#224466',
|
||||||
|
capitalCityId: sameFlushCityId,
|
||||||
|
chiefGeneralId: general.id,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 10_000,
|
||||||
|
power: 1_000,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
meta: { gennum: 1, tech: 0 },
|
||||||
|
};
|
||||||
|
const city = {
|
||||||
|
id: sameFlushCityId,
|
||||||
|
name: '월경계성',
|
||||||
|
nationId: sameFlushNationId,
|
||||||
|
level: 5,
|
||||||
|
state: 0,
|
||||||
|
population: 10_000,
|
||||||
|
populationMax: 20_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
meta: { trust: 50, trade: 100, region: 1 },
|
||||||
|
};
|
||||||
|
const map = {
|
||||||
|
id: 'retire-before-unification',
|
||||||
|
name: '월경계 은퇴 후 통일',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
};
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: sameFlushWorldId,
|
||||||
|
scenarioCode: 'retire-before-unification-integration',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: scenarioConfig as GamePrisma.InputJsonValue,
|
||||||
|
meta: worldMeta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.nation.create({
|
||||||
|
data: {
|
||||||
|
id: nation.id,
|
||||||
|
name: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
capitalCityId: nation.capitalCityId,
|
||||||
|
chiefGeneralId: nation.chiefGeneralId,
|
||||||
|
gold: nation.gold,
|
||||||
|
rice: nation.rice,
|
||||||
|
tech: 0,
|
||||||
|
level: nation.level,
|
||||||
|
typeCode: nation.typeCode,
|
||||||
|
meta: nation.meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.city.create({
|
||||||
|
data: {
|
||||||
|
id: city.id,
|
||||||
|
name: city.name,
|
||||||
|
nationId: city.nationId,
|
||||||
|
level: city.level,
|
||||||
|
population: city.population,
|
||||||
|
populationMax: city.populationMax,
|
||||||
|
agriculture: city.agriculture,
|
||||||
|
agricultureMax: city.agricultureMax,
|
||||||
|
commerce: city.commerce,
|
||||||
|
commerceMax: city.commerceMax,
|
||||||
|
security: city.security,
|
||||||
|
securityMax: city.securityMax,
|
||||||
|
defence: city.defence,
|
||||||
|
defenceMax: city.defenceMax,
|
||||||
|
wall: city.wall,
|
||||||
|
wallMax: city.wallMax,
|
||||||
|
supplyState: city.supplyState,
|
||||||
|
frontState: city.frontState,
|
||||||
|
region: 1,
|
||||||
|
meta: city.meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.general.create({
|
||||||
|
data: {
|
||||||
|
id: general.id,
|
||||||
|
userId: general.userId,
|
||||||
|
name: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
cityId: general.cityId,
|
||||||
|
npcState: general.npcState,
|
||||||
|
leadership: general.stats.leadership,
|
||||||
|
strength: general.stats.strength,
|
||||||
|
intel: general.stats.intelligence,
|
||||||
|
experience: general.experience,
|
||||||
|
dedication: general.dedication,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
injury: general.injury,
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
crew: general.crew,
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
turnTime: general.turnTime,
|
||||||
|
age: general.age,
|
||||||
|
bornYear: general.bornYear,
|
||||||
|
deadYear: general.deadYear,
|
||||||
|
meta: general.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.rankData.createMany({
|
||||||
|
data: RANK_DATA_TYPES.map((type) => ({
|
||||||
|
generalId: general.id,
|
||||||
|
nationId: general.nationId,
|
||||||
|
type,
|
||||||
|
value: type === 'warnum' ? 11 : type === 'firenum' ? 2 : 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await db.inheritancePoint.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: general.userId!, key: 'previous', value: 100 },
|
||||||
|
{ userId: general.userId!, key: 'lived_month', value: 10 },
|
||||||
|
{ userId: general.userId!, key: 'active_action', value: 4 },
|
||||||
|
{ userId: general.userId!, key: 'tournament', value: 11 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.gameHistory.create({
|
||||||
|
data: {
|
||||||
|
serverId: sameFlushServerId,
|
||||||
|
date: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
season: 9,
|
||||||
|
scenario: 77,
|
||||||
|
scenarioName: scenarioMeta.title,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
|
||||||
|
await reservedTurns.loadAll();
|
||||||
|
reservedTurns.setGeneralTurn(general.id, 0, { action: '휴식', args: {} });
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: sameFlushWorldId,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
meta: worldMeta,
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map,
|
||||||
|
generals: [general],
|
||||||
|
nations: [nation],
|
||||||
|
cities: [city],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
const handler = await createReservedTurnHandler({
|
||||||
|
reservedTurns,
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map,
|
||||||
|
getWorld: () => world,
|
||||||
|
});
|
||||||
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
generalTurnHandler: handler,
|
||||||
|
calendarHandler: {
|
||||||
|
onMonthChanged: (context) => {
|
||||||
|
if (!world) throw new Error('world is unavailable');
|
||||||
|
const reborn = world.getGeneralById(general.id);
|
||||||
|
if (!reborn?.userId) throw new Error('reborn general is unavailable');
|
||||||
|
world.queueInheritancePointAdjustment(reborn.userId, 'unifier', 250, 'after_lifecycle');
|
||||||
|
world.updateGeneral(reborn.id, {
|
||||||
|
inheritancePoints: {
|
||||||
|
...reborn.inheritancePoints,
|
||||||
|
unifier: (reborn.inheritancePoints?.unifier ?? 0) + 250,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
world.updateWorldMeta({ isUnited: 2, isunited: 2 });
|
||||||
|
world.queueUnificationFinalization({
|
||||||
|
generationKey: `unification:${sameFlushServerId}`,
|
||||||
|
serverId: sameFlushServerId,
|
||||||
|
profileName: 'che',
|
||||||
|
winnerNationId: sameFlushNationId,
|
||||||
|
year: context.currentYear,
|
||||||
|
month: context.currentMonth,
|
||||||
|
completedAt: new Date(context.turnTime.getTime()),
|
||||||
|
auctionCancellations: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
world.advanceGameClockTo(monthBoundary, monthBoundary);
|
||||||
|
const processor = new InMemoryTurnProcessor(world);
|
||||||
|
const result = await processor.run(monthBoundary, {
|
||||||
|
budgetMs: 10_000,
|
||||||
|
maxGenerals: 10,
|
||||||
|
catchUpCap: 1,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ processedGenerals: 1, processedTurns: 1, partial: false });
|
||||||
|
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2 });
|
||||||
|
expect(world.peekDirtyState().lifecycleEvents).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
generalId: general.id,
|
||||||
|
outcome: 'retired',
|
||||||
|
isUnitedAtEvent: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(world.getGeneralById(general.id)).toMatchObject({
|
||||||
|
age: 20,
|
||||||
|
inheritancePoints: { tournament: 11, lived_month: 11, active_action: 4 },
|
||||||
|
meta: { rank_warnum: 0, inherit_lived_month: 0, inherit_active_action: 0, dex1: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns, profileName: 'che' });
|
||||||
|
try {
|
||||||
|
await hooks.hooks.flushChanges?.(result);
|
||||||
|
} finally {
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.hallOfFame.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
serverId_type_generalNo: {
|
||||||
|
serverId: sameFlushServerId,
|
||||||
|
type: 'warnum',
|
||||||
|
generalNo: general.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 11 });
|
||||||
|
const dexHall = await db.hallOfFame.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
serverId_type_generalNo: {
|
||||||
|
serverId: sameFlushServerId,
|
||||||
|
type: 'dex1',
|
||||||
|
generalNo: general.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(dexHall).toMatchObject({ value: 120 });
|
||||||
|
expect(asRecord(dexHall.aux)).toMatchObject({ unitedTime: monthBoundary.toISOString() });
|
||||||
|
|
||||||
|
const results = await db.inheritanceResult.findMany({
|
||||||
|
where: { serverId: sameFlushServerId, owner: general.userId! },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { value: true },
|
||||||
|
});
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
const rebirth = asRecord(results[0]!.value);
|
||||||
|
const unification = asRecord(results[1]!.value);
|
||||||
|
expect(rebirth).toMatchObject({ rebirth: true, tournament: 11 });
|
||||||
|
expect(asRecord(rebirth.retained)).toMatchObject({ unifier: 0 });
|
||||||
|
expect(unification).toMatchObject({
|
||||||
|
generationKey: `unification:${sameFlushServerId}`,
|
||||||
|
previous: rebirth.total,
|
||||||
|
lived_month: 0,
|
||||||
|
active_action: 0,
|
||||||
|
tournament: 0,
|
||||||
|
unifier: 250,
|
||||||
|
unifierBeforeAward: 250,
|
||||||
|
unifierAward: 0,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
db.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: Math.floor(Number(unification.total)) });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
|
it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
|
||||||
const general = makeGeneral(generalIds[2]!, userIds[2]!, {
|
const general = makeGeneral(generalIds[2]!, userIds[2]!, {
|
||||||
npcState: 1,
|
npcState: 1,
|
||||||
@@ -340,4 +925,429 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
})
|
})
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('executes explicit retirement, flushes pre-reset settlement values, and reloads only the reborn state', async () => {
|
||||||
|
const general = makeGeneral(generalIds[3]!, userIds[3]!, {
|
||||||
|
nationId,
|
||||||
|
cityId,
|
||||||
|
age: 65,
|
||||||
|
experience: 1_001,
|
||||||
|
dedication: 801,
|
||||||
|
turnTime: new Date('0200-01-01T00:10:00.000Z'),
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
killturn: 24,
|
||||||
|
rank_warnum: 11,
|
||||||
|
firenum: 9,
|
||||||
|
inherit_earned: 4_321,
|
||||||
|
inherit_lived_month: 10,
|
||||||
|
inherit_active_action: 4,
|
||||||
|
inheritRandomUnique: 1,
|
||||||
|
inherit_spent_dyn: 3_000,
|
||||||
|
dex1: 200,
|
||||||
|
dex2: 0,
|
||||||
|
dex3: 0,
|
||||||
|
dex4: 0,
|
||||||
|
dex5: 0,
|
||||||
|
event100_allstar: { granted: { dex1: 80 } },
|
||||||
|
},
|
||||||
|
inheritancePoints: { previous: 50, lived_month: 10, active_action: 4 },
|
||||||
|
});
|
||||||
|
const automaticGeneral = makeGeneral(generalIds[4]!, userIds[4]!, {
|
||||||
|
nationId,
|
||||||
|
cityId,
|
||||||
|
age: 80,
|
||||||
|
crew: 100,
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
killturn: 24,
|
||||||
|
rank_warnum: 6,
|
||||||
|
inherit_lived_month: 10,
|
||||||
|
inherit_active_action: 4,
|
||||||
|
inheritRandomUnique: 1,
|
||||||
|
inherit_spent_dyn: 3_000,
|
||||||
|
dex1: 40,
|
||||||
|
},
|
||||||
|
inheritancePoints: { previous: 70, lived_month: 10, active_action: 4 },
|
||||||
|
});
|
||||||
|
const scenarioConfig = {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: {},
|
||||||
|
const: {
|
||||||
|
retirementYear: 80,
|
||||||
|
incDefSettingChange: 3,
|
||||||
|
maxDefSettingChange: 9,
|
||||||
|
inheritItemRandomPoint: 3_000,
|
||||||
|
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||||
|
},
|
||||||
|
environment: { mapName: 'che', unitSet: 'che' },
|
||||||
|
};
|
||||||
|
const scenarioMeta = {
|
||||||
|
title: '명시적 은퇴 integration',
|
||||||
|
startYear: 200,
|
||||||
|
life: null,
|
||||||
|
fiction: 0,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
};
|
||||||
|
const worldMeta = {
|
||||||
|
serverId,
|
||||||
|
season: 4,
|
||||||
|
scenarioId: 22,
|
||||||
|
gameIdx: 7,
|
||||||
|
isUnited: 0,
|
||||||
|
killturn: 24,
|
||||||
|
scenarioMeta,
|
||||||
|
};
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: worldId,
|
||||||
|
scenarioCode: 'explicit-retirement-integration',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: scenarioConfig as GamePrisma.InputJsonValue,
|
||||||
|
meta: worldMeta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.nation.create({
|
||||||
|
data: { id: nationId, name: '은퇴국', color: '#330000', level: 1, capitalCityId: cityId },
|
||||||
|
});
|
||||||
|
await db.city.create({
|
||||||
|
data: {
|
||||||
|
id: cityId,
|
||||||
|
name: '은퇴성',
|
||||||
|
level: 5,
|
||||||
|
nationId,
|
||||||
|
population: 10_000,
|
||||||
|
populationMax: 20_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
region: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.general.create({
|
||||||
|
data: {
|
||||||
|
id: general.id,
|
||||||
|
userId: general.userId,
|
||||||
|
name: general.name,
|
||||||
|
nationId,
|
||||||
|
cityId,
|
||||||
|
npcState: 0,
|
||||||
|
leadership: general.stats.leadership,
|
||||||
|
strength: general.stats.strength,
|
||||||
|
intel: general.stats.intelligence,
|
||||||
|
experience: general.experience,
|
||||||
|
dedication: general.dedication,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
injury: general.injury,
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
crew: general.crew,
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
turnTime: general.turnTime,
|
||||||
|
age: general.age,
|
||||||
|
bornYear: general.bornYear,
|
||||||
|
deadYear: general.deadYear,
|
||||||
|
meta: general.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.general.create({
|
||||||
|
data: {
|
||||||
|
id: automaticGeneral.id,
|
||||||
|
userId: automaticGeneral.userId,
|
||||||
|
name: automaticGeneral.name,
|
||||||
|
nationId,
|
||||||
|
cityId,
|
||||||
|
npcState: 0,
|
||||||
|
leadership: automaticGeneral.stats.leadership,
|
||||||
|
strength: automaticGeneral.stats.strength,
|
||||||
|
intel: automaticGeneral.stats.intelligence,
|
||||||
|
experience: automaticGeneral.experience,
|
||||||
|
dedication: automaticGeneral.dedication,
|
||||||
|
officerLevel: automaticGeneral.officerLevel,
|
||||||
|
injury: automaticGeneral.injury,
|
||||||
|
gold: automaticGeneral.gold,
|
||||||
|
rice: automaticGeneral.rice,
|
||||||
|
crew: automaticGeneral.crew,
|
||||||
|
crewTypeId: automaticGeneral.crewTypeId,
|
||||||
|
train: automaticGeneral.train,
|
||||||
|
atmos: automaticGeneral.atmos,
|
||||||
|
turnTime: automaticGeneral.turnTime,
|
||||||
|
age: automaticGeneral.age,
|
||||||
|
bornYear: automaticGeneral.bornYear,
|
||||||
|
deadYear: automaticGeneral.deadYear,
|
||||||
|
meta: automaticGeneral.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.rankData.createMany({
|
||||||
|
data: [
|
||||||
|
...RANK_DATA_TYPES.map((type) => ({
|
||||||
|
generalId: general.id,
|
||||||
|
nationId,
|
||||||
|
type,
|
||||||
|
value: type === 'warnum' ? 10 : type === 'firenum' ? 8 : type === 'inherit_earned' ? 123 : 0,
|
||||||
|
})),
|
||||||
|
...RANK_DATA_TYPES.map((type) => ({
|
||||||
|
generalId: automaticGeneral.id,
|
||||||
|
nationId,
|
||||||
|
type,
|
||||||
|
value: type === 'warnum' ? 5 : type === 'inherit_spent_dyn' ? 3_000 : 0,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.inheritancePoint.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId: general.userId!, key: 'previous', value: 50 },
|
||||||
|
{ userId: general.userId!, key: 'lived_month', value: 10 },
|
||||||
|
{ userId: general.userId!, key: 'active_action', value: 4 },
|
||||||
|
{ userId: automaticGeneral.userId!, key: 'previous', value: 70 },
|
||||||
|
{ userId: automaticGeneral.userId!, key: 'lived_month', value: 10 },
|
||||||
|
{ userId: automaticGeneral.userId!, key: 'active_action', value: 4 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.gameHistory.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
date: new Date('2026-08-24T00:00:00.000Z'),
|
||||||
|
season: 4,
|
||||||
|
scenario: 22,
|
||||||
|
scenarioName: scenarioMeta.title,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: `${serverId}-completed`,
|
||||||
|
date: new Date('2026-08-23T00:00:00.000Z'),
|
||||||
|
season: 3,
|
||||||
|
scenario: 22,
|
||||||
|
scenarioName: '완료',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: `${serverId}-abandoned`,
|
||||||
|
date: new Date('2026-08-22T00:00:00.000Z'),
|
||||||
|
season: 3,
|
||||||
|
scenario: 22,
|
||||||
|
scenarioName: '취소',
|
||||||
|
status: 'ABANDONED',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
|
||||||
|
await reservedTurns.loadAll();
|
||||||
|
reservedTurns.setGeneralTurn(general.id, 0, { action: 'che_은퇴', args: {} });
|
||||||
|
reservedTurns.setGeneralTurn(general.id, 1, { action: 'che_은퇴', args: {} });
|
||||||
|
reservedTurns.setGeneralTurn(automaticGeneral.id, 0, { action: 'che_훈련', args: {} });
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: worldId,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
meta: worldMeta,
|
||||||
|
};
|
||||||
|
const nation = {
|
||||||
|
id: nationId,
|
||||||
|
name: '은퇴국',
|
||||||
|
color: '#330000',
|
||||||
|
capitalCityId: cityId,
|
||||||
|
chiefGeneralId: general.id,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 10_000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
meta: { gennum: 2, tech: 0 },
|
||||||
|
};
|
||||||
|
const city = {
|
||||||
|
id: cityId,
|
||||||
|
name: '은퇴성',
|
||||||
|
nationId,
|
||||||
|
level: 5,
|
||||||
|
state: 0,
|
||||||
|
population: 10_000,
|
||||||
|
populationMax: 20_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
meta: { trust: 50, trade: 100, region: 1 },
|
||||||
|
};
|
||||||
|
const map = {
|
||||||
|
id: 'explicit-retirement',
|
||||||
|
name: '명시적 은퇴',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
};
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
const handler = await createReservedTurnHandler({
|
||||||
|
reservedTurns,
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map,
|
||||||
|
getWorld: () => world,
|
||||||
|
});
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map,
|
||||||
|
generals: [general, automaticGeneral],
|
||||||
|
nations: [nation],
|
||||||
|
cities: [city],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
generalTurnHandler: handler,
|
||||||
|
});
|
||||||
|
world.executeGeneralTurn(world.getGeneralById(automaticGeneral.id)!);
|
||||||
|
world.executeGeneralTurn(world.getGeneralById(general.id)!);
|
||||||
|
world.executeGeneralTurn(world.getGeneralById(general.id)!);
|
||||||
|
expect(world.peekDirtyState().lifecycleEvents.some((entry) => entry.outcome === 'retired')).toBe(true);
|
||||||
|
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
||||||
|
userId: general.userId,
|
||||||
|
key: 'previous',
|
||||||
|
amount: 3_000,
|
||||||
|
phase: 'after_lifecycle',
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
||||||
|
userId: automaticGeneral.userId,
|
||||||
|
key: 'previous',
|
||||||
|
amount: 3_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
||||||
|
try {
|
||||||
|
await hooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 2,
|
||||||
|
processedTurns: 3,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)).toMatchObject({
|
||||||
|
age: 20,
|
||||||
|
experience: 501,
|
||||||
|
dedication: 401,
|
||||||
|
meta: {
|
||||||
|
rank_warnum: 0,
|
||||||
|
firenum: 0,
|
||||||
|
inherit_earned: 0,
|
||||||
|
inherit_lived_month: 0,
|
||||||
|
inherit_active_action: 0,
|
||||||
|
inherit_spent_dyn: -3_000,
|
||||||
|
dex1: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)?.meta).not.toHaveProperty(
|
||||||
|
'inheritRandomUnique'
|
||||||
|
);
|
||||||
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)).toMatchObject({
|
||||||
|
age: 20,
|
||||||
|
meta: {
|
||||||
|
rank_warnum: 0,
|
||||||
|
inherit_lived_month: 0,
|
||||||
|
inherit_active_action: 0,
|
||||||
|
inherit_spent_dyn: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)?.meta).not.toHaveProperty(
|
||||||
|
'inheritRandomUnique'
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
db.hallOfFame.findUniqueOrThrow({
|
||||||
|
where: { serverId_type_generalNo: { serverId, type: 'warnum', generalNo: general.id } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 11, aux: expect.objectContaining({ serverIdx: 7 }) });
|
||||||
|
await expect(
|
||||||
|
db.hallOfFame.findUniqueOrThrow({
|
||||||
|
where: { serverId_type_generalNo: { serverId, type: 'firenum', generalNo: general.id } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 9 });
|
||||||
|
await expect(
|
||||||
|
db.hallOfFame.findUniqueOrThrow({
|
||||||
|
where: { serverId_type_generalNo: { serverId, type: 'inherit_earned', generalNo: general.id } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 4_321 });
|
||||||
|
await expect(
|
||||||
|
db.hallOfFame.findUniqueOrThrow({
|
||||||
|
where: { serverId_type_generalNo: { serverId, type: 'dex1', generalNo: general.id } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 120 });
|
||||||
|
const result = await db.inheritanceResult.findFirstOrThrow({
|
||||||
|
where: { serverId, owner: general.userId! },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
expect(result.value).toMatchObject({ combat: 55, sabotage: 180, dex: 0.06, rebirth: true });
|
||||||
|
const inheritanceLogs = await db.inheritanceLog.findMany({
|
||||||
|
where: { userId: general.userId! },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { text: true },
|
||||||
|
});
|
||||||
|
const settlementLogIndex = inheritanceLogs.findIndex(({ text }) => text.startsWith('포인트 '));
|
||||||
|
const refundLogIndex = inheritanceLogs.findIndex(
|
||||||
|
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
|
||||||
|
);
|
||||||
|
expect(settlementLogIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(refundLogIndex).toBeGreaterThan(settlementLogIndex);
|
||||||
|
const persistedPrevious = await db.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||||
|
});
|
||||||
|
expect(persistedPrevious.value).toBeGreaterThan(3_000);
|
||||||
|
const automaticInheritanceLogs = await db.inheritanceLog.findMany({
|
||||||
|
where: { userId: automaticGeneral.userId! },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { text: true },
|
||||||
|
});
|
||||||
|
const automaticRefundLogIndex = automaticInheritanceLogs.findIndex(
|
||||||
|
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
|
||||||
|
);
|
||||||
|
const automaticSettlementLogIndex = automaticInheritanceLogs.findIndex(({ text }) =>
|
||||||
|
text.startsWith('포인트 ')
|
||||||
|
);
|
||||||
|
expect(automaticRefundLogIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(automaticSettlementLogIndex).toBeGreaterThan(automaticRefundLogIndex);
|
||||||
|
await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0);
|
||||||
|
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,14 @@ describe('general lifecycle archive history', () => {
|
|||||||
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
||||||
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
|
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
|
||||||
availability: { battleResultLogs: true },
|
availability: { battleResultLogs: true },
|
||||||
meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 },
|
meta: expect.objectContaining({
|
||||||
|
killturn: 0,
|
||||||
|
dex1: 1_000,
|
||||||
|
rank_warnum: 2,
|
||||||
|
rank_killnum: 1,
|
||||||
|
inheritRandomUnique: true,
|
||||||
|
inheritSpecificSpecialWar: true,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -106,32 +113,60 @@ describe('general lifecycle archive history', () => {
|
|||||||
|
|
||||||
it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => {
|
it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => {
|
||||||
const general = archivedGeneral();
|
const general = archivedGeneral();
|
||||||
const hallCreateMany = vi.fn(async () => ({ count: 1 }));
|
const hallCreate = vi.fn(async () => undefined);
|
||||||
|
general.userId = 'hall-owner';
|
||||||
|
general.meta = {
|
||||||
|
...general.meta,
|
||||||
|
rank_warnum: 11,
|
||||||
|
inherit_earned: 4_321,
|
||||||
|
dex1: 200,
|
||||||
|
event100_allstar: { granted: { dex1: 80 } },
|
||||||
|
};
|
||||||
|
const postRetirement = {
|
||||||
|
...general,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
rank_warnum: 0,
|
||||||
|
inherit_earned: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rankUpsert = vi.fn(async () => undefined);
|
||||||
const prisma = {
|
const prisma = {
|
||||||
generalAccessLog: {
|
generalAccessLog: {
|
||||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||||
},
|
},
|
||||||
rankData: {
|
rankData: {
|
||||||
findMany: vi.fn(async () => [{ type: 'inherit_earned', value: 4_321 }]),
|
findMany: vi.fn(async () => [
|
||||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
{ type: 'warnum', value: 10 },
|
||||||
|
{ type: 'inherit_earned', value: 123 },
|
||||||
|
]),
|
||||||
|
upsert: rankUpsert,
|
||||||
},
|
},
|
||||||
nation: {
|
nation: {
|
||||||
findUnique: vi.fn(async () => null),
|
findUnique: vi.fn(async () => null),
|
||||||
},
|
},
|
||||||
gameHistory: {
|
gameHistory: {
|
||||||
count: vi.fn(async () => 2),
|
count: vi.fn(async () => 99),
|
||||||
},
|
},
|
||||||
hallOfFame: {
|
hallOfFame: {
|
||||||
findUnique: vi.fn(async () => null),
|
findMany: vi.fn(async () => []),
|
||||||
createMany: hallCreateMany,
|
create: hallCreate,
|
||||||
update: vi.fn(async () => undefined),
|
update: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
|
inheritancePoint: {
|
||||||
|
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
|
||||||
|
upsert: vi.fn(async () => undefined),
|
||||||
|
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||||
|
},
|
||||||
|
inheritanceResult: { create: vi.fn(async () => undefined) },
|
||||||
|
inheritanceLog: { create: vi.fn(async () => undefined) },
|
||||||
} as unknown as GamePrisma.TransactionClient;
|
} as unknown as GamePrisma.TransactionClient;
|
||||||
const event: GeneralLifecycleEvent = {
|
const event: GeneralLifecycleEvent = {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
outcome: 'retired',
|
outcome: 'retired',
|
||||||
before: general,
|
before: general,
|
||||||
after: general,
|
after: postRetirement,
|
||||||
|
isUnitedAtEvent: 0,
|
||||||
year: 200,
|
year: 200,
|
||||||
month: 1,
|
month: 1,
|
||||||
};
|
};
|
||||||
@@ -139,13 +174,13 @@ describe('general lifecycle archive history', () => {
|
|||||||
await persistGeneralLifecycleEvents(
|
await persistGeneralLifecycleEvents(
|
||||||
prisma,
|
prisma,
|
||||||
[event],
|
[event],
|
||||||
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 0 },
|
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 2, gameIdx: 7 },
|
||||||
{}
|
{},
|
||||||
|
new Date('0200-02-01T00:00:00.000Z')
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(hallCreateMany).toHaveBeenCalledWith({
|
expect(hallCreate).toHaveBeenCalledWith({
|
||||||
data: [
|
data: expect.objectContaining({
|
||||||
expect.objectContaining({
|
|
||||||
serverId: 'hall-fixture',
|
serverId: 'hall-fixture',
|
||||||
season: 4,
|
season: 4,
|
||||||
scenario: 22,
|
scenario: 22,
|
||||||
@@ -153,12 +188,25 @@ describe('general lifecycle archive history', () => {
|
|||||||
type: 'inherit_earned',
|
type: 'inherit_earned',
|
||||||
value: 4_321,
|
value: 4_321,
|
||||||
}),
|
}),
|
||||||
],
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
});
|
||||||
expect(prisma.rankData.updateMany).toHaveBeenCalledWith({
|
expect(hallCreate).toHaveBeenCalledWith({
|
||||||
where: { generalId: general.id },
|
data: expect.objectContaining({ type: 'warnum', value: 11 }),
|
||||||
data: { value: 0 },
|
});
|
||||||
|
expect(hallCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
type: 'dex1',
|
||||||
|
value: 120,
|
||||||
|
aux: expect.objectContaining({ serverIdx: 7, unitedTime: '0200-02-01T00:00:00.000Z' }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(prisma.gameHistory.count).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.inheritanceLog.create).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ text: expect.stringContaining('반환') }) })
|
||||||
|
);
|
||||||
|
expect(rankUpsert).toHaveBeenCalledWith({
|
||||||
|
where: { generalId_type: { generalId: general.id, type: 'warnum' } },
|
||||||
|
update: { nationId: general.nationId, value: 0 },
|
||||||
|
create: { generalId: general.id, nationId: general.nationId, type: 'warnum', value: 0 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from '../src/turn/hallOfFamePersistence.js';
|
||||||
|
|
||||||
|
const candidate = {
|
||||||
|
serverId: 'hall-server',
|
||||||
|
season: 3,
|
||||||
|
scenario: 22,
|
||||||
|
generalNo: 20,
|
||||||
|
type: 'experience' as const,
|
||||||
|
value: 2_000,
|
||||||
|
owner: 'same-owner',
|
||||||
|
aux: { name: '새장수' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Hall of Fame persistence policy', () => {
|
||||||
|
it('preserves an owner record belonging to another general for both higher and lower new values', async () => {
|
||||||
|
const update = vi.fn(async () => undefined);
|
||||||
|
const prisma = {
|
||||||
|
hallOfFame: {
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
serverId: candidate.serverId,
|
||||||
|
season: 3,
|
||||||
|
scenario: 22,
|
||||||
|
generalNo: 10,
|
||||||
|
type: candidate.type,
|
||||||
|
value: 1_000,
|
||||||
|
owner: candidate.owner,
|
||||||
|
aux: { name: '기존장수' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
create: vi.fn(async () => undefined),
|
||||||
|
update,
|
||||||
|
},
|
||||||
|
} as unknown as GamePrisma.TransactionClient;
|
||||||
|
|
||||||
|
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('PRESERVED');
|
||||||
|
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 500 })).resolves.toBe('PRESERVED');
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.hallOfFame.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates only value and aux for a higher same-general record, and preserves a lower value', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 2,
|
||||||
|
serverId: candidate.serverId,
|
||||||
|
season: 3,
|
||||||
|
scenario: 22,
|
||||||
|
generalNo: candidate.generalNo,
|
||||||
|
type: candidate.type,
|
||||||
|
value: 1_500,
|
||||||
|
owner: 'old-owner',
|
||||||
|
aux: { name: '기존장수' },
|
||||||
|
};
|
||||||
|
const update = vi.fn(async () => undefined);
|
||||||
|
const prisma = {
|
||||||
|
hallOfFame: {
|
||||||
|
findMany: vi.fn(async () => [existing]),
|
||||||
|
create: vi.fn(async () => undefined),
|
||||||
|
update,
|
||||||
|
},
|
||||||
|
} as unknown as GamePrisma.TransactionClient;
|
||||||
|
|
||||||
|
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('UPDATED');
|
||||||
|
expect(update).toHaveBeenCalledWith({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { value: candidate.value, aux: candidate.aux },
|
||||||
|
});
|
||||||
|
|
||||||
|
update.mockClear();
|
||||||
|
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 1_000 })).resolves.toBe('PRESERVED');
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses persisted gameIdx and reconstructs fallback from COMPLETED games only', async () => {
|
||||||
|
const count = vi.fn(async () => 4);
|
||||||
|
const prisma = { gameHistory: { count } } as unknown as GamePrisma.TransactionClient;
|
||||||
|
|
||||||
|
await expect(resolveOfficialGameIndex(prisma, { gameIdx: 0 })).resolves.toBe(0);
|
||||||
|
expect(count).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect(resolveOfficialGameIndex(prisma, { firstGameIdx: 0 })).resolves.toBe(4);
|
||||||
|
expect(count).toHaveBeenCalledWith({ where: { status: 'COMPLETED' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
|
import { EngineStateManager } from '../src/turn/engineStateManager.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const worldId = 992_310;
|
||||||
|
const actorGeneralId = 7_310;
|
||||||
|
const targetGeneralId = 7_311;
|
||||||
|
const nationId = 7_312;
|
||||||
|
const actorUserId = 'inheritance-atomic-actor';
|
||||||
|
const targetUserId = 'inheritance-atomic-target';
|
||||||
|
const requestPrefix = 'integration:inheritance-atomic';
|
||||||
|
const pointConstraint = 'inheritance_atomic_point_failure';
|
||||||
|
const rankConstraint = 'inheritance_atomic_rank_failure';
|
||||||
|
const logConstraint = 'inheritance_atomic_log_failure';
|
||||||
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
|
||||||
|
const scenarioConfig: ScenarioConfig = {
|
||||||
|
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: {},
|
||||||
|
const: {
|
||||||
|
inheritBornStatPoint: 1_000,
|
||||||
|
inheritItemRandomPoint: 3_000,
|
||||||
|
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
|
||||||
|
inheritSpecificSpecialPoint: 4_000,
|
||||||
|
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
|
||||||
|
inheritCheckOwnerPoint: 1_000,
|
||||||
|
availableSpecialWar: ['che_의술'],
|
||||||
|
},
|
||||||
|
environment: { mapName: 'che', unitSet: 'che' },
|
||||||
|
};
|
||||||
|
const scenarioMeta: ScenarioMeta = {
|
||||||
|
title: '유산 원자성 통합',
|
||||||
|
startYear: 200,
|
||||||
|
life: null,
|
||||||
|
fiction: null,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
};
|
||||||
|
const map: MapDefinition = { id: 'inheritance-atomic', name: scenarioMeta.title, cities: [] };
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: worldId,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-08-24T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta },
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
||||||
|
id: actorGeneralId,
|
||||||
|
userId: actorUserId,
|
||||||
|
name: '확인장수',
|
||||||
|
nationId,
|
||||||
|
cityId: 0,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 70, strength: 45, intelligence: 85 },
|
||||||
|
turnTime: new Date('2026-08-24T00:10:00.000Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24, inherit_spent_dyn: 17 },
|
||||||
|
inheritancePoints: { previous: 10_000 },
|
||||||
|
penalty: {},
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const actorGeneral = buildGeneral({});
|
||||||
|
const targetGeneral = buildGeneral({
|
||||||
|
id: targetGeneralId,
|
||||||
|
userId: targetUserId,
|
||||||
|
name: '피확인장수',
|
||||||
|
meta: { killturn: 24, owner_name: '레거시 소유자' },
|
||||||
|
inheritancePoints: { previous: 0 },
|
||||||
|
});
|
||||||
|
const generals = [actorGeneral, targetGeneral];
|
||||||
|
|
||||||
|
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||||
|
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||||
|
if (!schema?.endsWith('immediate_action_integration')) {
|
||||||
|
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
|
||||||
|
id: general.id,
|
||||||
|
userId: general.userId,
|
||||||
|
name: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
cityId: general.cityId,
|
||||||
|
troopId: general.troopId,
|
||||||
|
npcState: general.npcState,
|
||||||
|
leadership: general.stats.leadership,
|
||||||
|
strength: general.stats.strength,
|
||||||
|
intel: general.stats.intelligence,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
experience: general.experience,
|
||||||
|
dedication: general.dedication,
|
||||||
|
injury: general.injury,
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
crew: general.crew,
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
turnTime: general.turnTime,
|
||||||
|
recentWarTime: general.recentWarTime,
|
||||||
|
age: general.age,
|
||||||
|
meta: general.meta as GamePrisma.InputJsonValue,
|
||||||
|
penalty: general.penalty as GamePrisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCommand = (
|
||||||
|
suffix: string,
|
||||||
|
input: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>['input']
|
||||||
|
): Extract<TurnDaemonCommand, { type: 'inheritanceAction' }> => ({
|
||||||
|
type: 'inheritanceAction',
|
||||||
|
requestId: `${requestPrefix}:${suffix}`,
|
||||||
|
userId: actorUserId,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
|
||||||
|
integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let disconnect: (() => Promise<void>) | undefined;
|
||||||
|
let hooks: DatabaseTurnHooks | undefined;
|
||||||
|
|
||||||
|
const dropFailureConstraints = async (): Promise<void> => {
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT IF EXISTS ${pointConstraint}`);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT IF EXISTS ${rankConstraint}`);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT IF EXISTS ${logConstraint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertDedicatedDatabase(databaseUrl!);
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
disconnect = () => connector.disconnect();
|
||||||
|
await dropFailureConstraints();
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: nationId } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
|
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: worldId,
|
||||||
|
scenarioCode: 'inheritance-atomic',
|
||||||
|
currentYear: state.currentYear,
|
||||||
|
currentMonth: state.currentMonth,
|
||||||
|
tickSeconds: state.tickSeconds,
|
||||||
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.nation.create({ data: { id: nationId, name: '통합국', color: '#123456', level: 1 } });
|
||||||
|
await db.general.createMany({ data: generals.map(toGeneralCreate) });
|
||||||
|
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 10_000 } });
|
||||||
|
await db.rankData.create({
|
||||||
|
data: { generalId: actorGeneralId, nationId, type: 'inherit_spent_dyn', value: 17 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await hooks?.close();
|
||||||
|
if (db) {
|
||||||
|
await dropFailureConstraints();
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
|
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: nationId } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
|
}
|
||||||
|
await disconnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls patch/point/rank/log/messages back at each injected failure and reloads one committed mutation', async () => {
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals,
|
||||||
|
cities: [],
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: nationId,
|
||||||
|
name: '통합국',
|
||||||
|
color: '#123456',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: actorGeneralId,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
scenarioConfig,
|
||||||
|
scenarioMeta,
|
||||||
|
map,
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
|
const handler = createTurnDaemonCommandHandler({ world });
|
||||||
|
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
const stateManager = new EngineStateManager();
|
||||||
|
stateManager.register('world', {
|
||||||
|
capture: () => world.captureState(),
|
||||||
|
restore: (captured) => world.restoreState(captured),
|
||||||
|
});
|
||||||
|
const execute = async (
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> => {
|
||||||
|
if (!hooks?.hooks.executeCommand || !command.requestId) {
|
||||||
|
throw new Error('Database command execution hook is unavailable.');
|
||||||
|
}
|
||||||
|
return stateManager.transaction(() =>
|
||||||
|
hooks!.hooks.executeCommand!(command.requestId!, async (context) => {
|
||||||
|
const result = await handler.handle(command, context);
|
||||||
|
if (!result) throw new Error('inheritanceAction command was not handled.');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const createInputEvent = async (
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.inputEvent.create({
|
||||||
|
data: {
|
||||||
|
requestId: command.requestId!,
|
||||||
|
target: 'ENGINE',
|
||||||
|
eventType: command.type,
|
||||||
|
actorUserId: command.userId,
|
||||||
|
status: 'PROCESSING',
|
||||||
|
lockedBy: 'inheritance-atomic-worker',
|
||||||
|
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
|
||||||
|
attempts: 1,
|
||||||
|
payload: command as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
|
||||||
|
await expect(
|
||||||
|
db.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: point });
|
||||||
|
await expect(
|
||||||
|
db.rankData.findUniqueOrThrow({
|
||||||
|
where: { generalId_type: { generalId: actorGeneralId, type: 'inherit_spent_dyn' } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: spent });
|
||||||
|
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(logCount);
|
||||||
|
await expect(
|
||||||
|
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
|
||||||
|
).resolves.toBe(messageCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointCommand = buildCommand('point', {
|
||||||
|
action: 'buyHiddenBuff',
|
||||||
|
buffType: 'warAvoidRatio',
|
||||||
|
level: 1,
|
||||||
|
});
|
||||||
|
await createInputEvent(pointCommand);
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
ALTER TABLE inheritance_point
|
||||||
|
ADD CONSTRAINT ${pointConstraint}
|
||||||
|
CHECK (user_id <> '${actorUserId}' OR key <> 'previous' OR value = 10000)
|
||||||
|
`);
|
||||||
|
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
|
||||||
|
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
|
||||||
|
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
|
||||||
|
await assertStored(10_000, 17, 0, 0);
|
||||||
|
await expect(
|
||||||
|
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
status: 'PROCESSING',
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
|
||||||
|
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
|
||||||
|
await assertStored(9_800, 217, 1, 0);
|
||||||
|
|
||||||
|
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
|
||||||
|
await createInputEvent(rankCommand);
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
ALTER TABLE rank_data
|
||||||
|
ADD CONSTRAINT ${rankConstraint}
|
||||||
|
CHECK (general_id <> ${actorGeneralId} OR type <> 'inherit_spent_dyn' OR value = 217)
|
||||||
|
`);
|
||||||
|
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
|
||||||
|
expect(world.peekDirtyState().messages).toEqual([]);
|
||||||
|
await assertStored(9_800, 217, 1, 0);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
|
||||||
|
await expect(execute(rankCommand)).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
remainPoint: 8_800,
|
||||||
|
ownerName: '레거시 소유자',
|
||||||
|
});
|
||||||
|
await assertStored(8_800, 1_217, 2, 2);
|
||||||
|
|
||||||
|
const currentLog = await db.inheritanceLog.findFirstOrThrow({
|
||||||
|
where: { userId: actorUserId },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const logCommand = buildCommand('log', { action: 'buyRandomUnique' });
|
||||||
|
await createInputEvent(logCommand);
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
ALTER TABLE inheritance_log
|
||||||
|
ADD CONSTRAINT ${logConstraint}
|
||||||
|
CHECK (user_id <> '${actorUserId}' OR id <= ${currentLog.id})
|
||||||
|
`);
|
||||||
|
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
|
||||||
|
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
|
||||||
|
await assertStored(8_800, 1_217, 2, 2);
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
|
||||||
|
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
||||||
|
await assertStored(5_800, 4_217, 3, 2);
|
||||||
|
|
||||||
|
const freeStatCommand = buildCommand('free-stat', {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
inheritBonusStat: [0, 0, 0],
|
||||||
|
});
|
||||||
|
await createInputEvent(freeStatCommand);
|
||||||
|
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
||||||
|
await assertStored(5_800, 4_217, 5, 2);
|
||||||
|
|
||||||
|
const messages = await db.message.findMany({
|
||||||
|
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { mailbox: true, message: true },
|
||||||
|
});
|
||||||
|
expect(messages.map((entry) => [entry.mailbox, (entry.message as { text: string }).text])).toEqual([
|
||||||
|
[actorGeneralId, '피확인장수의 소유자는 레거시 소유자 입니다.'],
|
||||||
|
[targetGeneralId, '소유자명이 누군가에 의해 확인되었습니다.'],
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
db.inputEvent.findUniqueOrThrow({ where: { requestId: rankCommand.requestId! } })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
attempts: 1,
|
||||||
|
result: expect.objectContaining({ type: 'inheritanceAction', ok: true, action: 'checkOwner' }),
|
||||||
|
lockedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
expect(reloaded.snapshot.generals.find((general) => general.id === actorGeneralId)).toMatchObject({
|
||||||
|
stats: { leadership: 71, strength: 47, intelligence: 86 },
|
||||||
|
meta: {
|
||||||
|
inherit_spent_dyn: 4_217,
|
||||||
|
inheritRandomUnique: 1,
|
||||||
|
inheritBuff: JSON.stringify({ warAvoidRatio: 1 }),
|
||||||
|
},
|
||||||
|
inheritancePoints: { previous: 5_800 },
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { LiteHashDRBG, RandUtil, type TurnDaemonCommand } from '@sammo-ts/common';
|
||||||
|
import type { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildResetStatRandomBonus,
|
||||||
|
executeInheritanceAction,
|
||||||
|
resolveOwnerDisplayName,
|
||||||
|
} from '../src/turn/inheritanceActionService.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
type InheritanceCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||||
|
id: 1,
|
||||||
|
userId: 'user-1',
|
||||||
|
name: '유비',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 70, strength: 45, intelligence: 85 },
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 0,
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24, inherit_spent_dyn: 17 },
|
||||||
|
inheritancePoints: { previous: 10_000 },
|
||||||
|
turnTime: new Date('0200-04-01T00:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildWorld = (options: {
|
||||||
|
general?: TurnGeneral;
|
||||||
|
target?: TurnGeneral;
|
||||||
|
worldMeta?: Record<string, unknown>;
|
||||||
|
configConst?: Record<string, unknown>;
|
||||||
|
configMap?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 3_600,
|
||||||
|
lastTurnTime: new Date('0200-04-01T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'test-seed', season: 7, isunited: 0, ...(options.worldMeta ?? {}) },
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: options.configMap ?? {},
|
||||||
|
const: {
|
||||||
|
availableSpecialWar: ['che_의술'],
|
||||||
|
inheritBornStatPoint: 1_000,
|
||||||
|
inheritItemRandomPoint: 3_000,
|
||||||
|
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
|
||||||
|
inheritSpecificSpecialPoint: 4_000,
|
||||||
|
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
|
||||||
|
inheritCheckOwnerPoint: 1_000,
|
||||||
|
...(options.configConst ?? {}),
|
||||||
|
},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
map: { id: 'test', name: 'test', cities: [] },
|
||||||
|
generals: [options.general ?? buildGeneral(), ...(options.target ? [options.target] : [])],
|
||||||
|
cities: [],
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '촉',
|
||||||
|
color: '#ff0000',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
return new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDatabase = (options: { point?: number; resetSeasons?: number[] } = {}) => {
|
||||||
|
const createLog = vi.fn(async () => ({}));
|
||||||
|
const findUserState = vi.fn(async () =>
|
||||||
|
options.resetSeasons ? { meta: { last_stat_reset: options.resetSeasons } } : null
|
||||||
|
);
|
||||||
|
const upsertUserState = vi.fn(async () => ({}));
|
||||||
|
const queryRaw = vi.fn(async () => [{ value: options.point ?? 10_000 }]);
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
inheritanceLog: { create: createLog },
|
||||||
|
inheritanceUserState: { findUnique: findUserState, upsert: upsertUserState },
|
||||||
|
} as unknown as GamePrisma.TransactionClient,
|
||||||
|
createLog,
|
||||||
|
findUserState,
|
||||||
|
upsertUserState,
|
||||||
|
queryRaw,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const execute = async (
|
||||||
|
world: InMemoryTurnWorld,
|
||||||
|
db: GamePrisma.TransactionClient,
|
||||||
|
input: InheritanceCommand['input']
|
||||||
|
) =>
|
||||||
|
executeInheritanceAction({
|
||||||
|
db,
|
||||||
|
world,
|
||||||
|
command: { type: 'inheritanceAction', userId: 'user-1', input },
|
||||||
|
gameNow: new Date('0200-04-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inheritance action service', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'hidden buff',
|
||||||
|
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
|
||||||
|
cost: 200,
|
||||||
|
general: buildGeneral(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'specific special',
|
||||||
|
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
|
||||||
|
cost: 4_000,
|
||||||
|
general: buildGeneral(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'special reset',
|
||||||
|
input: { action: 'resetSpecialWar' } as const,
|
||||||
|
cost: 1_000,
|
||||||
|
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_선봉' } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'turn-time reset',
|
||||||
|
input: { action: 'resetTurnTime' } as const,
|
||||||
|
cost: 1_000,
|
||||||
|
general: buildGeneral(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'paid stat reset',
|
||||||
|
input: {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
inheritBonusStat: [2, 1, 1] as [number, number, number],
|
||||||
|
} as const,
|
||||||
|
cost: 1_000,
|
||||||
|
general: buildGeneral(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'random unique reservation',
|
||||||
|
input: { action: 'buyRandomUnique' } as const,
|
||||||
|
cost: 3_000,
|
||||||
|
general: buildGeneral(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'owner lookup',
|
||||||
|
input: { action: 'checkOwner', targetGeneralId: 2 } as const,
|
||||||
|
cost: 1_000,
|
||||||
|
general: buildGeneral(),
|
||||||
|
},
|
||||||
|
])('charges $name in runtime rank, points, and the same dirty flush', async ({ input, cost, general }) => {
|
||||||
|
const target =
|
||||||
|
input.action === 'checkOwner'
|
||||||
|
? buildGeneral({
|
||||||
|
id: 2,
|
||||||
|
userId: 'user-2',
|
||||||
|
name: '조조',
|
||||||
|
meta: { killturn: 24, owner_name: '위유저' },
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const world = buildWorld({ general, target });
|
||||||
|
const { db, createLog } = buildDatabase();
|
||||||
|
|
||||||
|
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: true, remainPoint: 10_000 - cost });
|
||||||
|
|
||||||
|
expect(world.getGeneralById(1)).toMatchObject({
|
||||||
|
meta: { inherit_spent_dyn: 17 + cost },
|
||||||
|
inheritancePoints: { previous: 10_000 - cost },
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual(
|
||||||
|
cost === 0 ? [] : [{ userId: 'user-1', key: 'previous', amount: -cost }]
|
||||||
|
);
|
||||||
|
expect(createLog).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => {
|
||||||
|
const world = buildWorld({});
|
||||||
|
const { db } = buildDatabase({ point: 0 });
|
||||||
|
|
||||||
|
const result = await execute(world, db, {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
inheritBonusStat: [0, 0, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: true, remainPoint: 0 });
|
||||||
|
expect(world.getGeneralById(1)?.meta.inherit_spent_dyn).toBe(17);
|
||||||
|
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]);
|
||||||
|
expect(result.ok && result.stats).toEqual({ leadership: 73, strength: 45, intel: 87 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the Ref ResetStat DRBG seed, inclusive 3..5 count, and weighted choices', () => {
|
||||||
|
const bonus = buildResetStatRandomBonus(
|
||||||
|
new RandUtil(new LiteHashDRBG(simpleSerialize('test-seed', 'ResetStat', 'user-1'))),
|
||||||
|
[70, 45, 85]
|
||||||
|
);
|
||||||
|
expect(bonus).toEqual([3, 0, 2]);
|
||||||
|
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2])('rejects npcState=%s ResetStat exactly like Ref npc != 0', async (npcState) => {
|
||||||
|
const world = buildWorld({ general: buildGeneral({ npcState }) });
|
||||||
|
const { db, queryRaw } = buildDatabase();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
execute(world, db, {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
inheritBonusStat: [2, 1, 1],
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: 'NPC는 능력치 초기화를 할 수 없습니다.' });
|
||||||
|
expect(queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'purchased buff before unification',
|
||||||
|
general: buildGeneral({ meta: { killturn: 24, inheritBuff: JSON.stringify({ warAvoidRatio: 1 }) } }),
|
||||||
|
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
|
||||||
|
reason: '이미 구입했습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'owned special before unification',
|
||||||
|
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_의술' } }),
|
||||||
|
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
|
||||||
|
reason: '이미 그 특기를 보유하고 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'blank special before unification',
|
||||||
|
general: buildGeneral(),
|
||||||
|
input: { action: 'resetSpecialWar' } as const,
|
||||||
|
reason: '이미 전투 특기가 공란입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'random reservation before unification',
|
||||||
|
general: buildGeneral({ meta: { killturn: 24, inheritRandomUnique: true } }),
|
||||||
|
input: { action: 'buyRandomUnique' } as const,
|
||||||
|
reason: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
|
||||||
|
},
|
||||||
|
])('preserves Ref combined-invalid precedence: $name', async ({ general, input, reason }) => {
|
||||||
|
const world = buildWorld({ general, worldMeta: { isunited: 1 } });
|
||||||
|
const { db, queryRaw } = buildDatabase();
|
||||||
|
|
||||||
|
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: false, reason });
|
||||||
|
expect(queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks ResetStat shape, npc/S100, unification, season duplicate, then points', async () => {
|
||||||
|
const npcWorld = buildWorld({ general: buildGeneral({ npcState: 1 }), worldMeta: { isunited: 1 } });
|
||||||
|
const npcDb = buildDatabase({ point: 0 });
|
||||||
|
await expect(
|
||||||
|
execute(npcWorld, npcDb.db, {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 84,
|
||||||
|
inheritBonusStat: [2, 1, 1],
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: '능력치 총합이 200이 아닙니다. 다시 입력해주세요!' });
|
||||||
|
|
||||||
|
const seasonWorld = buildWorld({});
|
||||||
|
const seasonDb = buildDatabase({ point: 0, resetSeasons: [7] });
|
||||||
|
await expect(
|
||||||
|
execute(seasonWorld, seasonDb.db, {
|
||||||
|
action: 'resetStat',
|
||||||
|
leadership: 70,
|
||||||
|
strength: 45,
|
||||||
|
intel: 85,
|
||||||
|
inheritBonusStat: [2, 1, 1],
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: '이번 시즌에 이미 능력치를 초기화하셨습니다.' });
|
||||||
|
expect(seasonDb.queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses both unification keys and preserves owner display-name compatibility order', async () => {
|
||||||
|
const world = buildWorld({ worldMeta: { isUnited: 1, isunited: 0 } });
|
||||||
|
const { db } = buildDatabase();
|
||||||
|
await expect(execute(world, db, { action: 'resetTurnTime' })).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: '이미 천하가 통일되었습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveOwnerDisplayName({ ownerDisplayName: '현재', owner_name: '레거시', ownerName: '호환' })).toBe(
|
||||||
|
'현재'
|
||||||
|
);
|
||||||
|
expect(resolveOwnerDisplayName({ owner_name: '레거시', ownerName: '호환' })).toBe('레거시');
|
||||||
|
expect(resolveOwnerDisplayName({ ownerName: '호환' })).toBe('호환');
|
||||||
|
expect(resolveOwnerDisplayName({})).toBe('알수없음');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues CheckOwner messages in Ref requester-then-target order', async () => {
|
||||||
|
const world = buildWorld({
|
||||||
|
target: buildGeneral({
|
||||||
|
id: 2,
|
||||||
|
userId: 'user-2',
|
||||||
|
name: '조조',
|
||||||
|
meta: { killturn: 24, owner_name: '위유저' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { db } = buildDatabase();
|
||||||
|
|
||||||
|
await expect(execute(world, db, { action: 'checkOwner', targetGeneralId: 2 })).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
ownerName: '위유저',
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().messages.map((message) => [message.dest.generalId, message.text])).toEqual([
|
||||||
|
[1, '조조의 소유자는 위유저 입니다.'],
|
||||||
|
[2, '소유자명이 누군가에 의해 확인되었습니다.'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildInheritanceSettlementLogTexts } from '../src/turn/inheritanceSettlementLogs.js';
|
||||||
|
|
||||||
|
describe('legacy inheritance settlement logs', () => {
|
||||||
|
it('logs calculated keys plus only direct stored keys that are present, in Ref order', () => {
|
||||||
|
expect(
|
||||||
|
buildInheritanceSettlementLogTexts({
|
||||||
|
previous: 100,
|
||||||
|
points: {
|
||||||
|
lived_month: 12,
|
||||||
|
max_belong: 90,
|
||||||
|
max_domestic_critical: 80,
|
||||||
|
active_action: 3,
|
||||||
|
combat: 15,
|
||||||
|
sabotage: 40,
|
||||||
|
unifier: 250,
|
||||||
|
dex: 1.25,
|
||||||
|
tournament: 50,
|
||||||
|
betting: 5,
|
||||||
|
},
|
||||||
|
storedKeys: new Set(['previous', 'lived_month', 'unifier']),
|
||||||
|
total: 521,
|
||||||
|
isRebirth: false,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
'기존 보유 포인트 100 증가',
|
||||||
|
'생존 포인트 12 증가',
|
||||||
|
'최대 임관년 수 포인트 90 증가',
|
||||||
|
'전투 횟수 포인트 15 증가',
|
||||||
|
'계략 성공 횟수 포인트 40 증가',
|
||||||
|
'천통 기여 포인트 250 증가',
|
||||||
|
'숙련도 포인트 1.25 증가',
|
||||||
|
'베팅 당첨 포인트 5 증가',
|
||||||
|
'포인트 100 => 521',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips delayed rebirth keys and logs coefficient-adjusted values', () => {
|
||||||
|
expect(
|
||||||
|
buildInheritanceSettlementLogTexts({
|
||||||
|
previous: 50,
|
||||||
|
points: {
|
||||||
|
lived_month: 12,
|
||||||
|
max_belong: 0,
|
||||||
|
max_domestic_critical: 0,
|
||||||
|
active_action: 3,
|
||||||
|
combat: 15,
|
||||||
|
sabotage: 40,
|
||||||
|
unifier: 0,
|
||||||
|
dex: 0.5,
|
||||||
|
tournament: 7,
|
||||||
|
betting: 5,
|
||||||
|
},
|
||||||
|
storedKeys: new Set([
|
||||||
|
'previous',
|
||||||
|
'lived_month',
|
||||||
|
'max_domestic_critical',
|
||||||
|
'active_action',
|
||||||
|
'unifier',
|
||||||
|
'tournament',
|
||||||
|
]),
|
||||||
|
total: 132,
|
||||||
|
isRebirth: true,
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
'기존 보유 포인트 50 증가',
|
||||||
|
'생존 포인트 12 증가',
|
||||||
|
'능동 행동 수 포인트 3 증가',
|
||||||
|
'전투 횟수 포인트 15 증가',
|
||||||
|
'계략 성공 횟수 포인트 40 증가',
|
||||||
|
'숙련도 포인트 0.5 증가',
|
||||||
|
'토너먼트 포인트 7 증가',
|
||||||
|
'베팅 당첨 포인트 5 증가',
|
||||||
|
'포인트 50 => 132',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -214,7 +214,7 @@ describe('UpdateNationLevel monthly action', () => {
|
|||||||
expect(world.getGeneralById(1)?.role.items.horse).toBe(uniqueHorse.key);
|
expect(world.getGeneralById(1)?.role.items.horse).toBe(uniqueHorse.key);
|
||||||
expect(world.getGeneralById(2)?.role.items.horse).toBeNull();
|
expect(world.getGeneralById(2)?.role.items.horse).toBeNull();
|
||||||
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
|
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
|
||||||
{ userId: 'user-1', key: 'unifier', amount: 500 },
|
{ userId: 'user-1', key: 'unifier', amount: 500, phase: 'after_lifecycle' },
|
||||||
]);
|
]);
|
||||||
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
|
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
|
||||||
expect(world.peekDirtyState().logs).toEqual(
|
expect(world.peekDirtyState().logs).toEqual(
|
||||||
@@ -291,7 +291,7 @@ describe('UpdateNationLevel monthly action', () => {
|
|||||||
meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 },
|
meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 },
|
||||||
});
|
});
|
||||||
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
|
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
|
||||||
{ userId: 'user-1', key: 'unifier', amount: 250 },
|
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
|
||||||
]);
|
]);
|
||||||
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
|
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
|
||||||
});
|
});
|
||||||
@@ -306,7 +306,7 @@ describe('UpdateNationLevel monthly action', () => {
|
|||||||
expect(world.getGeneralById(1)?.role.items.horse).toBeNull();
|
expect(world.getGeneralById(1)?.role.items.horse).toBeNull();
|
||||||
expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false);
|
expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false);
|
||||||
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
|
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
|
||||||
{ userId: 'user-1', key: 'unifier', amount: 250 },
|
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
|
||||||
]);
|
]);
|
||||||
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
|
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ describe('durable read-model change journal mapping', () => {
|
|||||||
lifecycleEvents: [],
|
lifecycleEvents: [],
|
||||||
pendingNeutralAuctions: [],
|
pendingNeutralAuctions: [],
|
||||||
inheritancePointAdjustments: [],
|
inheritancePointAdjustments: [],
|
||||||
|
pendingInheritanceLogs: [],
|
||||||
pendingNationBettingOpens: [],
|
pendingNationBettingOpens: [],
|
||||||
pendingNationBettingFinishes: [],
|
pendingNationBettingFinishes: [],
|
||||||
pendingYearbookSnapshots: [],
|
pendingYearbookSnapshots: [],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const buildWorld = (): InMemoryTurnWorld => {
|
|||||||
rank_warnum: 4,
|
rank_warnum: 4,
|
||||||
firenum: 2,
|
firenum: 2,
|
||||||
dex1: 100,
|
dex1: 100,
|
||||||
|
event100_allstar: { granted: { dex1: 40 } },
|
||||||
},
|
},
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
experience: 10,
|
experience: 10,
|
||||||
@@ -141,18 +142,12 @@ const input = {
|
|||||||
|
|
||||||
describe('persistUnificationFinalization', () => {
|
describe('persistUnificationFinalization', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 },
|
{ label: 'missing unifier row', rows: [], key: 'unifier' as const, expected: 0 },
|
||||||
{ label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 },
|
{ label: 'missing resettable row', rows: [], key: 'tournament' as const, expected: 0 },
|
||||||
{ label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 },
|
{ label: 'zero row', rows: [['unifier', 0] as const], key: 'unifier' as const, expected: 0 },
|
||||||
])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => {
|
{ label: 'positive row', rows: [['unifier', 7] as const], key: 'unifier' as const, expected: 7 },
|
||||||
expect(
|
])('uses only transaction-visible inheritance storage for $label', ({ rows, key, expected }) => {
|
||||||
resolveStoredInheritancePoint(
|
expect(resolveStoredInheritancePoint(new Map<string, number>(rows), key)).toBe(expected);
|
||||||
new Map<string, number>(rows),
|
|
||||||
{ inheritancePoints: { unifier: memoryValue } },
|
|
||||||
'unifier',
|
|
||||||
2_000
|
|
||||||
)
|
|
||||||
).toBe(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not write when the transaction-scoped generation was already applied', async () => {
|
it('does not write when the transaction-scoped generation was already applied', async () => {
|
||||||
@@ -222,7 +217,7 @@ describe('persistUnificationFinalization', () => {
|
|||||||
},
|
},
|
||||||
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
|
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
|
||||||
hallOfFame: {
|
hallOfFame: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
create: hallCreate,
|
create: hallCreate,
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
@@ -274,6 +269,11 @@ describe('persistUnificationFinalization', () => {
|
|||||||
data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }),
|
data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(hallCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ type: 'dex1', value: 60 }),
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(gameHistoryUpdate).toHaveBeenCalledWith(
|
expect(gameHistoryUpdate).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
|
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -186,9 +186,13 @@ describe('unique lottery on general commands', () => {
|
|||||||
expect(dedicationIndex).toBeLessThan(uniqueIndex);
|
expect(dedicationIndex).toBeLessThan(uniqueIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not award a unique item reserved by an active auction', async () => {
|
it('refunds a pending inheritance purchase when active auctions exhaust the supply', async () => {
|
||||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
const generals = [buildGeneral(1)];
|
const lotteryGeneral = buildGeneral(1);
|
||||||
|
lotteryGeneral.userId = 'inherit-user';
|
||||||
|
lotteryGeneral.meta = { killturn: 24, inheritRandomUnique: true, inherit_spent_dyn: 3_000 };
|
||||||
|
lotteryGeneral.inheritancePoints = { previous: 200 };
|
||||||
|
const generals = [lotteryGeneral];
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: generals as any,
|
generals: generals as any,
|
||||||
cities: [
|
cities: [
|
||||||
@@ -267,6 +271,7 @@ describe('unique lottery on general commands', () => {
|
|||||||
uniqueTrialCoef: 10,
|
uniqueTrialCoef: 10,
|
||||||
maxUniqueTrialProb: 10,
|
maxUniqueTrialProb: 10,
|
||||||
minMonthToAllowInheritItem: 0,
|
minMonthToAllowInheritItem: 0,
|
||||||
|
inheritItemRandomPoint: 3_000,
|
||||||
},
|
},
|
||||||
environment: { mapName: 'test_map', unitSet: 'default' },
|
environment: { mapName: 'test_map', unitSet: 'default' },
|
||||||
},
|
},
|
||||||
@@ -317,6 +322,22 @@ describe('unique lottery on general commands', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.general?.role.items.weapon).toBeNull();
|
expect(result.general?.role.items.weapon).toBeNull();
|
||||||
|
expect(result.general?.meta).toMatchObject({ inherit_spent_dyn: 0 });
|
||||||
|
expect(result.general?.meta).not.toHaveProperty('inheritRandomUnique');
|
||||||
|
expect(result.general?.inheritancePoints?.previous).toBe(3_200);
|
||||||
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
|
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
|
||||||
|
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
||||||
|
userId: 'inherit-user',
|
||||||
|
key: 'previous',
|
||||||
|
amount: 3_000,
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().pendingInheritanceLogs).toEqual([
|
||||||
|
{
|
||||||
|
userId: 'inherit-user',
|
||||||
|
year: 180,
|
||||||
|
month: 1,
|
||||||
|
text: '얻을 유니크가 없어 3000 포인트 반환',
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ export interface TurnDaemonSelectPoolReservation {
|
|||||||
candidates: TurnDaemonSelectPoolCandidate[];
|
candidates: TurnDaemonSelectPoolCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TurnDaemonInheritanceAction =
|
||||||
|
| {
|
||||||
|
action: 'buyHiddenBuff';
|
||||||
|
buffType:
|
||||||
|
| 'warAvoidRatio'
|
||||||
|
| 'warCriticalRatio'
|
||||||
|
| 'warMagicTrialProb'
|
||||||
|
| 'domesticSuccessProb'
|
||||||
|
| 'domesticFailProb'
|
||||||
|
| 'warAvoidRatioOppose'
|
||||||
|
| 'warCriticalRatioOppose'
|
||||||
|
| 'warMagicTrialProbOppose';
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
| { action: 'setNextSpecialWar'; specialKey: string }
|
||||||
|
| { action: 'resetSpecialWar' }
|
||||||
|
| { action: 'resetTurnTime' }
|
||||||
|
| {
|
||||||
|
action: 'resetStat';
|
||||||
|
leadership: number;
|
||||||
|
strength: number;
|
||||||
|
intel: number;
|
||||||
|
inheritBonusStat?: [number, number, number];
|
||||||
|
}
|
||||||
|
| { action: 'buyRandomUnique' }
|
||||||
|
| { action: 'checkOwner'; targetGeneralId: number };
|
||||||
|
|
||||||
export type TurnDaemonCommand =
|
export type TurnDaemonCommand =
|
||||||
| {
|
| {
|
||||||
type: 'run';
|
type: 'run';
|
||||||
@@ -266,6 +293,12 @@ export type TurnDaemonCommand =
|
|||||||
specialWar?: string | null;
|
specialWar?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'inheritanceAction';
|
||||||
|
requestId?: string;
|
||||||
|
userId: string;
|
||||||
|
input: TurnDaemonInheritanceAction;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralIcon';
|
type: 'adjustGeneralIcon';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@@ -635,6 +668,25 @@ export type TurnDaemonCommandResult =
|
|||||||
generalId: number;
|
generalId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'inheritanceAction';
|
||||||
|
ok: true;
|
||||||
|
action: TurnDaemonInheritanceAction['action'];
|
||||||
|
generalId: number;
|
||||||
|
remainPoint: number;
|
||||||
|
nextTurnTimeBase?: number;
|
||||||
|
nextTurnTimeLabel?: string;
|
||||||
|
stats?: { leadership: number; strength: number; intel: number };
|
||||||
|
ownerName?: string;
|
||||||
|
targetName?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'inheritanceAction';
|
||||||
|
ok: false;
|
||||||
|
action: TurnDaemonInheritanceAction['action'];
|
||||||
|
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'INTERNAL_SERVER_ERROR';
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralIcon';
|
type: 'adjustGeneralIcon';
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const ACTION_NAME = '은퇴';
|
|||||||
const ACTION_KEY = 'che_은퇴';
|
const ACTION_KEY = 'che_은퇴';
|
||||||
|
|
||||||
const REQ_AGE = 60;
|
const REQ_AGE = 60;
|
||||||
|
const hasPendingRandomUnique = (value: unknown): boolean =>
|
||||||
|
value === true || value === 1 || (typeof value === 'string' && (value === '1' || value.toLowerCase() === 'true'));
|
||||||
|
|
||||||
const reqGeneralValue = (): Constraint => ({
|
const reqGeneralValue = (): Constraint => ({
|
||||||
name: 'reqGeneralValue',
|
name: 'reqGeneralValue',
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
@@ -43,18 +46,6 @@ export class ActionResolver<
|
|||||||
const general = context.general;
|
const general = context.general;
|
||||||
|
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
const nextMeta = { ...general.meta };
|
|
||||||
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
|
|
||||||
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
|
|
||||||
nextMeta[key] = Math.round(value * 0.5);
|
|
||||||
}
|
|
||||||
delete nextMeta.specAge;
|
|
||||||
delete nextMeta.specAge2;
|
|
||||||
nextMeta.specage = 0;
|
|
||||||
nextMeta.specage2 = 0;
|
|
||||||
for (const type of LEGACY_RANK_DATA_TYPES) {
|
|
||||||
nextMeta[rankDataMetaKey(type)] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const josaYi = JosaUtil.pick(general.name, '이');
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
|
||||||
@@ -75,7 +66,32 @@ export class ActionResolver<
|
|||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
const hadPendingRandomUnique = hasPendingRandomUnique(general.meta.inheritRandomUnique);
|
||||||
|
const acquiredUnique = tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
const refundedPendingRandomUnique =
|
||||||
|
hadPendingRandomUnique && !acquiredUnique && !hasPendingRandomUnique(general.meta.inheritRandomUnique);
|
||||||
|
const postLotterySpentDynamic = general.meta.inherit_spent_dyn;
|
||||||
|
|
||||||
|
// The lottery can consume a pending inheritance reservation and mutate meta.
|
||||||
|
// Build the reborn projection afterwards so the consumed flag is not restored
|
||||||
|
// by the action patch while still applying the retirement resets atomically.
|
||||||
|
const nextMeta = { ...general.meta };
|
||||||
|
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
|
||||||
|
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
|
||||||
|
nextMeta[key] = Math.round(value * 0.5);
|
||||||
|
}
|
||||||
|
delete nextMeta.specAge;
|
||||||
|
delete nextMeta.specAge2;
|
||||||
|
nextMeta.specage = 0;
|
||||||
|
nextMeta.specage2 = 0;
|
||||||
|
nextMeta.inherit_lived_month = 0;
|
||||||
|
nextMeta.inherit_active_action = 0;
|
||||||
|
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||||
|
nextMeta[rankDataMetaKey(type)] = 0;
|
||||||
|
}
|
||||||
|
if (refundedPendingRandomUnique && typeof postLotterySpentDynamic === 'number') {
|
||||||
|
nextMeta.inherit_spent_dyn = postLotterySpentDynamic;
|
||||||
|
}
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { compileCrewTypeCatalog } from '../crewType/index.js';
|
|||||||
import type { City, General, Nation } from '../domain/entities.js';
|
import type { City, General, Nation } from '../domain/entities.js';
|
||||||
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
|
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
|
||||||
import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.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 {
|
import {
|
||||||
createCrewTypeWarTriggerRegistry,
|
createCrewTypeWarTriggerRegistry,
|
||||||
resolveDefenderOrder,
|
resolveDefenderOrder,
|
||||||
@@ -344,6 +344,10 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
|||||||
export interface BattleSimProcessorOptions {
|
export interface BattleSimProcessorOptions {
|
||||||
trace?: (event: WarBattleTraceEvent) => void;
|
trace?: (event: WarBattleTraceEvent) => void;
|
||||||
rngFactory?: (seed: string) => RandUtil;
|
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 = (
|
export const processBattleSimJob = (
|
||||||
@@ -416,9 +420,12 @@ export const processBattleSimJob = (
|
|||||||
})),
|
})),
|
||||||
defenderCity,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
|
...(options.loggerFactory ? { loggerFactory: options.loggerFactory } : {}),
|
||||||
...(options.trace ? { trace: options.trace } : {}),
|
...(options.trace ? { trace: options.trace } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
options.onBattleResolved?.(outcome);
|
||||||
|
|
||||||
lastBattle = outcome;
|
lastBattle = outcome;
|
||||||
const attackerReport = outcome.reports.find(
|
const attackerReport = outcome.reports.find(
|
||||||
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
|
(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 LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975;
|
||||||
|
|
||||||
export const ALL_MERGED_INHERITANCE_KEYS = [
|
export const ALL_MERGED_INHERITANCE_KEYS = [
|
||||||
@@ -38,9 +40,6 @@ export const REBIRTH_INHERITANCE_COEFFICIENTS: Readonly<Record<MergedInheritance
|
|||||||
betting: 1,
|
betting: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
||||||
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
||||||
|
|
||||||
const readNumber = (source: Record<string, unknown>, ...keys: string[]): number => {
|
const readNumber = (source: Record<string, unknown>, ...keys: string[]): number => {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = source[key];
|
const value = source[key];
|
||||||
@@ -59,17 +58,10 @@ const readStoredPoint = (
|
|||||||
storedOverride?: number
|
storedOverride?: number
|
||||||
): number => storedOverride ?? general.inheritancePoints?.[key] ?? 0;
|
): number => storedOverride ?? general.inheritancePoints?.[key] ?? 0;
|
||||||
|
|
||||||
const readRecordableDexterity = (general: InheritancePointGeneral, key: string): number => {
|
|
||||||
const value = readNumber(general.meta, key);
|
|
||||||
const allStar = asRecord(general.meta.event100_allstar);
|
|
||||||
const granted = readNumber(asRecord(allStar.granted), key);
|
|
||||||
return Math.max(0, value - Math.min(Math.max(0, value), Math.max(0, granted)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeDexInheritancePoint = (general: InheritancePointGeneral): number => {
|
export const computeDexInheritancePoint = (general: InheritancePointGeneral): number => {
|
||||||
let totalDexterity = 0;
|
let totalDexterity = 0;
|
||||||
for (let index = 1; index <= 5; index += 1) {
|
for (let index = 1; index <= 5; index += 1) {
|
||||||
let dexterity = readRecordableDexterity(general, `dex${index}`);
|
let dexterity = readCentennialRecordableDexterity(general.meta, `dex${index}` as CentennialDexKey);
|
||||||
if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) {
|
if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) {
|
||||||
totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3;
|
totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3;
|
||||||
dexterity = LEGACY_DEX_INHERITANCE_LIMIT;
|
dexterity = LEGACY_DEX_INHERITANCE_LIMIT;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
||||||
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
|
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
|
||||||
import type { WarActionContext } from '@sammo-ts/logic/war/actions.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 { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const ITEM_KEY = 'che_부적_태현청생부';
|
const ITEM_KEY = 'che_부적_태현청생부';
|
||||||
|
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 303;
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule: ItemModule = {
|
||||||
key: ITEM_KEY,
|
key: ITEM_KEY,
|
||||||
@@ -28,8 +30,12 @@ export const itemModule: ItemModule = {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
} as NonNullable<ItemModule['onCalcStat']>,
|
} 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) => {
|
getBattlePhaseTriggerList: (context) => {
|
||||||
if (!context.unit) return null;
|
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 { 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';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const ITEM_KEY = 'che_진압_박혁론';
|
const ITEM_KEY = 'che_진압_박혁론';
|
||||||
|
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_NONE;
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule: ItemModule = {
|
||||||
key: ITEM_KEY,
|
key: ITEM_KEY,
|
||||||
@@ -18,6 +19,6 @@ export const itemModule: ItemModule = {
|
|||||||
unique: false,
|
unique: false,
|
||||||
getBattlePhaseTriggerList: (context) => {
|
getBattlePhaseTriggerList: (context) => {
|
||||||
if (!context.unit) return null;
|
if (!context.unit) return null;
|
||||||
return new WarTriggerCaller(new che_진압(context.unit));
|
return new WarTriggerCaller(new che_진압(context.unit, RAISE_TYPE));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
||||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.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';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const INFO =
|
const INFO =
|
||||||
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)';
|
'[군사] 매 턴마다 자신(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 => ({
|
export const createMedicalItem = (key: string, rawName: string): ItemModule => ({
|
||||||
key,
|
key,
|
||||||
rawName,
|
rawName,
|
||||||
@@ -18,6 +26,15 @@ export const createMedicalItem = (key: string, rawName: string): ItemModule => (
|
|||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: true,
|
unique: true,
|
||||||
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
||||||
getBattlePhaseTriggerList: (context) =>
|
getBattlePhaseTriggerList: (context) => {
|
||||||
context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null,
|
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)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
|
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';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
export const createEventBattleTraitItemModule = (
|
export const createEventBattleTraitItemModule = (
|
||||||
@@ -45,8 +48,26 @@ export const createEventBattleTraitItemModule = (
|
|||||||
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
|
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
|
||||||
}
|
}
|
||||||
if (traitModule.getBattlePhaseTriggerList) {
|
if (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;
|
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (traitModule.getWarPowerMultiplier) {
|
if (traitModule.getWarPowerMultiplier) {
|
||||||
itemModule.getWarPowerMultiplier =
|
itemModule.getWarPowerMultiplier =
|
||||||
traitModule.key === 'che_무쌍'
|
traitModule.key === 'che_무쌍'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
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 { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
@@ -17,6 +17,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
|||||||
_selfEnv: Record<string, unknown>,
|
_selfEnv: Record<string, unknown>,
|
||||||
_opposeEnv: Record<string, unknown>
|
_opposeEnv: Record<string, unknown>
|
||||||
): boolean {
|
): 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)) {
|
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -28,9 +37,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.activateSkill('충차공격', '아이템사용');
|
|
||||||
self.getLogger().pushGeneralBattleDetailLog('<C>충차</>로 성벽을 공격합니다.');
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { ItemModule, ItemModuleExport } from './types.js';
|
import type { ItemModule, ItemModuleExport } from './types.js';
|
||||||
import { listEquippedItemKeys } from './utils.js';
|
import { listEquippedItemKeys } from './utils.js';
|
||||||
import { removeEquippedItem } from './inventory.js';
|
import { registerLegacyBattleItemIdentity, removeEquippedItem } from './inventory.js';
|
||||||
|
|
||||||
export const ITEM_KEYS = [
|
export const ITEM_KEYS = [
|
||||||
'che_간파_노군입산부',
|
'che_간파_노군입산부',
|
||||||
@@ -664,12 +664,17 @@ class ItemWarActionRouter<
|
|||||||
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
|
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
|
||||||
const keys = listEquippedItemKeys(context.general);
|
const keys = listEquippedItemKeys(context.general);
|
||||||
const modules: Array<ItemModule<TriggerState>> = [];
|
const modules: Array<ItemModule<TriggerState>> = [];
|
||||||
|
let itemIdentity = { name: '-', rawName: '-' };
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const module = this.registry.get(key);
|
const module = this.registry.get(key);
|
||||||
if (module) {
|
if (module) {
|
||||||
modules.push(module);
|
modules.push(module);
|
||||||
|
if (module.slot === 'item') {
|
||||||
|
itemIdentity = { name: module.name, rawName: module.rawName };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
registerLegacyBattleItemIdentity(context.general, itemIdentity);
|
||||||
return modules;
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ import type {
|
|||||||
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||||
const INVENTORY_META_KEY = 'itemInventory';
|
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 emptyState = (): GeneralItemInstanceState => ({ values: {} });
|
||||||
|
|
||||||
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
|
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
|
||||||
@@ -57,9 +78,7 @@ const readState = (value: unknown): GeneralItemInstanceState | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const charges =
|
const charges =
|
||||||
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0
|
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) ? record['charges'] : undefined;
|
||||||
? record['charges']
|
|
||||||
: undefined;
|
|
||||||
const valuesRecord = asRecord(record['values']) ?? {};
|
const valuesRecord = asRecord(record['values']) ?? {};
|
||||||
const values: Record<string, TriggerValue> = {};
|
const values: Record<string, TriggerValue> = {};
|
||||||
for (const [key, entry] of Object.entries(valuesRecord)) {
|
for (const [key, entry] of Object.entries(valuesRecord)) {
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export type UniqueLotteryInput = {
|
|||||||
inheritRandomUnique?: boolean;
|
inheritRandomUnique?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UniqueLotteryOutcome =
|
||||||
|
| { status: 'NO_SLOT' }
|
||||||
|
| { status: 'ROLL_FAILED' }
|
||||||
|
| { status: 'NO_SUPPLY' }
|
||||||
|
| { status: 'ACQUIRED'; itemKey: string };
|
||||||
|
|
||||||
const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [
|
const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [
|
||||||
[-1, 1],
|
[-1, 1],
|
||||||
[3, 2],
|
[3, 2],
|
||||||
@@ -183,7 +189,7 @@ export const buildGenericUniqueSeed = (
|
|||||||
export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string =>
|
export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string =>
|
||||||
serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId);
|
serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId);
|
||||||
|
|
||||||
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
export const rollUniqueLotteryDetailed = (input: UniqueLotteryInput): UniqueLotteryOutcome => {
|
||||||
const {
|
const {
|
||||||
rng,
|
rng,
|
||||||
config,
|
config,
|
||||||
@@ -203,13 +209,13 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
|||||||
const resolvedAcquireType = acquireType ?? '아이템';
|
const resolvedAcquireType = acquireType ?? '아이템';
|
||||||
|
|
||||||
if (userCount <= 0) {
|
if (userCount <= 0) {
|
||||||
return null;
|
return { status: 'ROLL_FAILED' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemTypes = Object.keys(config.allItems);
|
const itemTypes = Object.keys(config.allItems);
|
||||||
const itemTypeCnt = itemTypes.length;
|
const itemTypeCnt = itemTypes.length;
|
||||||
if (itemTypeCnt <= 0) {
|
if (itemTypeCnt <= 0) {
|
||||||
return null;
|
return { status: 'NO_SLOT' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const relYear = currentYear - startYear;
|
const relYear = currentYear - startYear;
|
||||||
@@ -246,7 +252,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trialCnt <= 0 || maxCnt <= 0) {
|
if (trialCnt <= 0 || maxCnt <= 0) {
|
||||||
return null;
|
return { status: 'NO_SLOT' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth);
|
const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth);
|
||||||
@@ -289,7 +295,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return null;
|
return { status: 'ROLL_FAILED' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableUnique: Array<[string, number]> = [];
|
const availableUnique: Array<[string, number]> = [];
|
||||||
@@ -315,10 +321,15 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (availableUnique.length === 0) {
|
if (availableUnique.length === 0) {
|
||||||
return null;
|
return { status: 'NO_SUPPLY' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return rng.choiceUsingWeightPair(availableUnique);
|
return { status: 'ACQUIRED', itemKey: rng.choiceUsingWeightPair(availableUnique) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
||||||
|
const outcome = rollUniqueLotteryDetailed(input);
|
||||||
|
return outcome.status === 'ACQUIRED' ? outcome.itemKey : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const STAT_KEYS = ['leadership', 'strength', 'intel'] as const;
|
|||||||
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
|
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
|
||||||
|
|
||||||
type CentennialStatKey = (typeof STAT_KEYS)[number];
|
type CentennialStatKey = (typeof STAT_KEYS)[number];
|
||||||
type CentennialDexKey = (typeof DEX_KEYS)[number];
|
export type CentennialDexKey = (typeof DEX_KEYS)[number];
|
||||||
|
|
||||||
export interface CentennialAllStarTarget {
|
export interface CentennialAllStarTarget {
|
||||||
uniqueName: string;
|
uniqueName: string;
|
||||||
@@ -670,3 +670,14 @@ export const reconcileCentennialDexConversion = (
|
|||||||
|
|
||||||
export const centennialRecordableValue = (current: number, granted: number): number =>
|
export const centennialRecordableValue = (current: number, granted: number): number =>
|
||||||
Math.max(0, current - Math.max(0, granted));
|
Math.max(0, current - Math.max(0, granted));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref CentennialAllStarGrowthService::recordableRawValue. Event-provided
|
||||||
|
* mastery is useful during the season, but must not enter permanent ranking,
|
||||||
|
* Hall of Fame, or inheritance records.
|
||||||
|
*/
|
||||||
|
export const readCentennialRecordableDexterity = (meta: Record<string, unknown>, key: CentennialDexKey): number => {
|
||||||
|
const current = asNumber(meta[key], 0);
|
||||||
|
const granted = asNumber(asRecord(asRecord(meta[CENTENNIAL_ALL_STAR_AUX_KEY]).granted)[key], 0);
|
||||||
|
return centennialRecordableValue(current, granted);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
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 { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||||
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.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;
|
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>(
|
export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
|
||||||
defender: WarUnit<TriggerState>,
|
defender: WarUnit<TriggerState>,
|
||||||
attacker: WarUnitGeneral<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 attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||||
const attackerName = attackerUnit.getName();
|
const attackerName = attackerUnit.getName();
|
||||||
const cityName = cityUnit.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 josaRo = JosaUtil.pick(cityName, '로');
|
||||||
const josaYi = JosaUtil.pick(attackerName, '이');
|
const josaYi = JosaUtil.pick(attackerName, '이');
|
||||||
@@ -400,7 +412,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
LogFormat.MONTH
|
LogFormat.MONTH
|
||||||
);
|
);
|
||||||
attackerLogger.pushGeneralActionLog(
|
attackerLogger.pushGeneralActionLog(
|
||||||
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText}`,
|
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText} <1>${turnHourMinute}</>`,
|
||||||
LogFormat.MONTH
|
LogFormat.MONTH
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
|
||||||
import type { WarUnit } from './units.js';
|
import type { WarUnit } from './units.js';
|
||||||
import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
|
|
||||||
|
|
||||||
export interface WarTriggerContext {
|
export interface WarTriggerContext {
|
||||||
rng: RandUtil;
|
rng: RandUtil;
|
||||||
@@ -97,12 +98,6 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.unit.activateSkill('아이템사용');
|
this.unit.activateSkill('아이템사용');
|
||||||
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.unit.hasActivatedSkill('아이템소모')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const unit = this.unit as WarUnit & {
|
const unit = this.unit as WarUnit & {
|
||||||
getGeneral?: () => General;
|
getGeneral?: () => General;
|
||||||
};
|
};
|
||||||
@@ -110,7 +105,18 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
|||||||
if (!general) {
|
if (!general) {
|
||||||
return false;
|
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('아이템소모');
|
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 { BaseWarUnitTrigger } from '../triggers.js';
|
||||||
import type { WarUnit } from '../units.js';
|
import type { WarUnit } from '../units.js';
|
||||||
|
|
||||||
export class che_부적 extends BaseWarUnitTrigger {
|
export class che_부적 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||||
super(unit, 0, raiseType);
|
super(unit, TriggerPriority.Begin, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(self: WarUnit): boolean {
|
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
|
||||||
self.activateSkill('저격불가', '부상무효');
|
// Ref WarActivateSkills(..., isSelf=false): the talisman's owner is
|
||||||
|
// injury-proof, while the opposing unit is prevented from sniping.
|
||||||
|
oppose.activateSkill('저격불가');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { WarTriggerModule } from './types.js';
|
|||||||
|
|
||||||
// 의술: 치료 시도
|
// 의술: 치료 시도
|
||||||
export class che_의술시도 extends BaseWarUnitTrigger {
|
export class che_의술시도 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit) {
|
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||||
super(unit, TriggerPriority.Pre + 350);
|
super(unit, TriggerPriority.Pre + 350, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(
|
protected actionWar(
|
||||||
@@ -36,8 +36,8 @@ export class che_의술시도 extends BaseWarUnitTrigger {
|
|||||||
|
|
||||||
// 의술: 치료 발동
|
// 의술: 치료 발동
|
||||||
export class che_의술발동 extends BaseWarUnitTrigger {
|
export class che_의술발동 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit) {
|
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||||
super(unit, TriggerPriority.Post + 550);
|
super(unit, TriggerPriority.Post + 550, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(
|
protected actionWar(
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export class che_저지 extends BaseWarUnitTrigger {
|
|||||||
self.addDex(self.getCrewType(), calcDamage);
|
self.addDex(self.getCrewType(), calcDamage);
|
||||||
|
|
||||||
self.addLevelExp(calcDamage / 50);
|
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;
|
rice *= 0.25;
|
||||||
const general = self.getGeneral();
|
const general = self.getGeneral();
|
||||||
general.rice = Math.max(0, general.rice - rice);
|
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 { BaseWarUnitTrigger } from '../triggers.js';
|
||||||
import type { WarUnit } from '../units.js';
|
import type { WarUnit } from '../units.js';
|
||||||
|
|
||||||
export class che_진압 extends BaseWarUnitTrigger {
|
export class che_진압 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||||
super(unit, 0, raiseType);
|
super(unit, TriggerPriority.Begin, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(self: WarUnit): boolean {
|
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
|
||||||
self.activateSkill('반계불가', '격노불가');
|
// Ref's 진압 is an opposing-unit restriction, not a self debuff.
|
||||||
|
oppose.activateSkill('반계불가', '격노불가');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,10 +192,16 @@ export class WarUnitGeneral<
|
|||||||
return truncate ? Math.trunc(clamped) : clamped;
|
return truncate ? Math.trunc(clamped) : clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveMainStat(armType: number, withInjury = true): number {
|
private resolveMainStat(armType: number, withInjury = true, truncate = true): number {
|
||||||
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury });
|
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, {
|
||||||
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury });
|
withInjury,
|
||||||
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { 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) {
|
if (armType === this.config.armTypes.wizard) {
|
||||||
return intelligence;
|
return intelligence;
|
||||||
@@ -279,7 +285,10 @@ export class WarUnitGeneral<
|
|||||||
return 0;
|
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 =
|
const coef =
|
||||||
armType === this.config.armTypes.wizard ||
|
armType === this.config.armTypes.wizard ||
|
||||||
armType === this.config.armTypes.siege ||
|
armType === this.config.armTypes.siege ||
|
||||||
|
|||||||
@@ -101,4 +101,13 @@ describe('GeneralItemInventory', () => {
|
|||||||
values: { source: 'shop' },
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
countOccupiedUniqueItems,
|
countOccupiedUniqueItems,
|
||||||
resolveUniqueConfig,
|
resolveUniqueConfig,
|
||||||
rollUniqueLottery,
|
rollUniqueLottery,
|
||||||
|
rollUniqueLotteryDetailed,
|
||||||
} from '../src/rewards/uniqueLottery.js';
|
} from '../src/rewards/uniqueLottery.js';
|
||||||
|
|
||||||
const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({
|
const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({
|
||||||
@@ -118,6 +119,51 @@ describe('unique lottery', () => {
|
|||||||
expect(result).toBe('itemB');
|
expect(result).toBe('itemB');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('distinguishes no slot, a failed roll, and exhausted supply', () => {
|
||||||
|
const itemRegistry = buildRegistry();
|
||||||
|
const base = {
|
||||||
|
itemRegistry,
|
||||||
|
scenarioId: 200,
|
||||||
|
userCount: 1,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
startYear: 180,
|
||||||
|
initYear: 180,
|
||||||
|
initMonth: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
rollUniqueLotteryDetailed({
|
||||||
|
...base,
|
||||||
|
rng: new RandUtil(LiteHashDRBG.build('no-slot')),
|
||||||
|
config: buildConfig(),
|
||||||
|
generalItems: { horse: null, weapon: 'itemB', book: null, item: null },
|
||||||
|
occupiedUniqueCounts: new Map([['itemB', 1]]),
|
||||||
|
})
|
||||||
|
).toEqual({ status: 'NO_SLOT' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
rollUniqueLotteryDetailed({
|
||||||
|
...base,
|
||||||
|
rng: new RandUtil(LiteHashDRBG.build('roll-failed')),
|
||||||
|
config: buildConfig({ uniqueTrialCoef: 0, maxUniqueTrialProb: 0 }),
|
||||||
|
generalItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
occupiedUniqueCounts: new Map(),
|
||||||
|
})
|
||||||
|
).toEqual({ status: 'ROLL_FAILED' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
rollUniqueLotteryDetailed({
|
||||||
|
...base,
|
||||||
|
rng: new RandUtil(LiteHashDRBG.build('no-supply')),
|
||||||
|
config: buildConfig(),
|
||||||
|
generalItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
occupiedUniqueCounts: new Map([['itemB', 1]]),
|
||||||
|
acquireType: '건국',
|
||||||
|
})
|
||||||
|
).toEqual({ status: 'NO_SUPPLY' });
|
||||||
|
});
|
||||||
|
|
||||||
it('counts only non-buyable equipped items', () => {
|
it('counts only non-buyable equipped items', () => {
|
||||||
const itemRegistry = new Map<string, ItemModule>([
|
const itemRegistry = new Map<string, ItemModule>([
|
||||||
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
|
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
|
||||||
|
|||||||
@@ -167,6 +167,93 @@ const buildGeneral = (strength: number): General => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('war triggers', () => {
|
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 () => {
|
it('passes battle time and maximum tech level to year-scaling stat items', async () => {
|
||||||
const general = buildGeneral(80);
|
const general = buildGeneral(80);
|
||||||
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
||||||
@@ -604,8 +691,14 @@ describe('resolveWarBattle', () => {
|
|||||||
for (const expectedCharges of [1, null] as const) {
|
for (const expectedCharges of [1, null] as const) {
|
||||||
general.crew = 5000;
|
general.crew = 5000;
|
||||||
general.rice = 10000;
|
general.rice = 10000;
|
||||||
const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 };
|
const defenderCity = {
|
||||||
resolveWarBattle({
|
...buildCity(),
|
||||||
|
defence: 100_000,
|
||||||
|
defenceMax: 100_000,
|
||||||
|
wall: 3000,
|
||||||
|
wallMax: 3000,
|
||||||
|
};
|
||||||
|
const outcome = resolveWarBattle({
|
||||||
rng: new RandUtil(new ConstantRNG(0)),
|
rng: new RandUtil(new ConstantRNG(0)),
|
||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
@@ -625,13 +718,51 @@ describe('resolveWarBattle', () => {
|
|||||||
if (expectedCharges === null) {
|
if (expectedCharges === null) {
|
||||||
expect(equipped).toBeNull();
|
expect(equipped).toBeNull();
|
||||||
expect(general.role.items.item).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 {
|
} else {
|
||||||
expect(equipped?.state.charges).toBe(expectedCharges);
|
expect(equipped?.state.charges).toBe(expectedCharges);
|
||||||
expect(general.role.items.item).toBe('event_충차');
|
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 () => {
|
it('removes a one-use battle item through the canonical inventory', async () => {
|
||||||
const general = buildGeneral(100);
|
const general = buildGeneral(100);
|
||||||
equipNewItem(general, 'item', 'che_저격_수극');
|
equipNewItem(general, 'item', 'che_저격_수극');
|
||||||
@@ -639,7 +770,7 @@ describe('resolveWarBattle', () => {
|
|||||||
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
|
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
|
||||||
).war;
|
).war;
|
||||||
|
|
||||||
resolveWarBattle({
|
const outcome = resolveWarBattle({
|
||||||
rng: new RandUtil(new ConstantRNG(0)),
|
rng: new RandUtil(new ConstantRNG(0)),
|
||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
@@ -657,6 +788,42 @@ describe('resolveWarBattle', () => {
|
|||||||
|
|
||||||
expect(getEquippedItemInstance(general, 'item')).toBeNull();
|
expect(getEquippedItemInstance(general, 'item')).toBeNull();
|
||||||
expect(general.role.items.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', () => {
|
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,
|
"repeatCnt": 1,
|
||||||
"action": "battle",
|
"action": "battle",
|
||||||
"attackerGeneral": {
|
"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,
|
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||||
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
|
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
|
||||||
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
|
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
|
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
|
||||||
},
|
},
|
||||||
"defenderGenerals": [{
|
"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,
|
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||||
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
|
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
|
||||||
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
|
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -16,8 +17,16 @@ import {
|
|||||||
loadNationTraitModules,
|
loadNationTraitModules,
|
||||||
loadPersonalityTraitModules,
|
loadPersonalityTraitModules,
|
||||||
loadWarTraitModules,
|
loadWarTraitModules,
|
||||||
|
ActionLogger,
|
||||||
|
formatLogText,
|
||||||
|
LogCategory,
|
||||||
|
LogFormat,
|
||||||
|
LogScope,
|
||||||
|
type LogEntryDraft,
|
||||||
type UnitSetDefinition,
|
type UnitSetDefinition,
|
||||||
|
type WarBattleOutcome,
|
||||||
type WarBattleTraceEvent,
|
type WarBattleTraceEvent,
|
||||||
|
type WarBattleTraceUnitSnapshot,
|
||||||
type WarEngineConfig,
|
type WarEngineConfig,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
@@ -32,13 +41,19 @@ import type {
|
|||||||
|
|
||||||
interface ReferenceTrace {
|
interface ReferenceTrace {
|
||||||
engine: 'ref';
|
engine: 'ref';
|
||||||
|
seed: string;
|
||||||
|
fixtureIdentity: FixtureIdentity;
|
||||||
conquered: boolean;
|
conquered: boolean;
|
||||||
|
attacker: WarBattleTraceEvent['attacker'];
|
||||||
|
city: WarBattleTraceEvent['city'];
|
||||||
|
finishedDefenders: WarBattleTraceEvent['attacker'][];
|
||||||
defenderOrder?: {
|
defenderOrder?: {
|
||||||
before: Array<{ id: number; order: number }>;
|
before: Array<{ id: number; order: number }>;
|
||||||
after: Array<{ id: number; order: number }>;
|
after: Array<{ id: number; order: number }>;
|
||||||
};
|
};
|
||||||
events: WarBattleTraceEvent[];
|
events: WarBattleTraceEvent[];
|
||||||
rng: RandomCall[];
|
rng: RandomCall[];
|
||||||
|
boolRng: BoolRandomCall[];
|
||||||
logs: {
|
logs: {
|
||||||
attacker: ReferenceLogBuckets;
|
attacker: ReferenceLogBuckets;
|
||||||
defenders: Record<string, 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 {
|
interface ReferenceLogBuckets {
|
||||||
generalHistoryLog: string[];
|
generalHistoryLog: string[];
|
||||||
generalActionLog: string[];
|
generalActionLog: string[];
|
||||||
@@ -56,6 +83,65 @@ interface ReferenceLogBuckets {
|
|||||||
globalActionLog: string[];
|
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 {
|
interface RandomCall {
|
||||||
seq: number;
|
seq: number;
|
||||||
operation: string;
|
operation: string;
|
||||||
@@ -80,6 +166,7 @@ type ReferenceTraitCatalog = Record<
|
|||||||
|
|
||||||
class TracingRng implements RNG {
|
class TracingRng implements RNG {
|
||||||
public readonly calls: RandomCall[] = [];
|
public readonly calls: RandomCall[] = [];
|
||||||
|
public readonly boolCalls: BoolRandomCall[] = [];
|
||||||
|
|
||||||
public constructor(private readonly inner: RNG) {}
|
public constructor(private readonly inner: RNG) {}
|
||||||
|
|
||||||
@@ -111,6 +198,19 @@ class TracingRng implements RNG {
|
|||||||
return result;
|
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 {
|
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
|
||||||
this.calls.push({ seq: this.calls.length, operation, arguments: args, result });
|
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 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 => {
|
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
||||||
|
assertFixtureGeneralCityContract(fixtureJson);
|
||||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||||
if (compareContainer) {
|
if (compareContainer) {
|
||||||
const stdout = execFileSync(
|
const stdout = execFileSync(
|
||||||
@@ -156,61 +462,22 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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;
|
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||||
if (compareSourceRoot) {
|
if (compareSourceRoot) {
|
||||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
const stdout = runReferenceSourceScript({
|
||||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-'));
|
workspaceRoot,
|
||||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
compareSourceRoot,
|
||||||
recursive: true,
|
script: 'hwe/compare/battle_trace.php',
|
||||||
filter: (source) => {
|
args: ['-'],
|
||||||
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}`)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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,
|
input: fixtureJson,
|
||||||
encoding: 'utf8',
|
});
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
const reference = JSON.parse(stdout) as ReferenceTrace;
|
||||||
}
|
assertReferenceFixtureIdentity(reference, fixtureJson);
|
||||||
);
|
return reference;
|
||||||
return JSON.parse(stdout) as ReferenceTrace;
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const stdout = execFileSync(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
@@ -222,21 +489,63 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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[] => {
|
const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => {
|
||||||
|
fixtureLines.forEach((line, index) => assertFixtureGeneralCityContract(line, `fixture[${index}]`));
|
||||||
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
|
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
|
||||||
if (precomputedTracePath) {
|
if (precomputedTracePath) {
|
||||||
const traces = fs
|
return readBoundPrecomputedTraces(precomputedTracePath, fixtureLines);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||||
if (compareContainer) {
|
if (compareContainer) {
|
||||||
@@ -259,84 +568,54 @@ const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]):
|
|||||||
maxBuffer: 512 * 1024 * 1024,
|
maxBuffer: 512 * 1024 * 1024,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return stdout
|
const traces = stdout
|
||||||
.split(/\r?\n/u)
|
.split(/\r?\n/u)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
.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;
|
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||||
if (!compareSourceRoot) {
|
if (!compareSourceRoot) {
|
||||||
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
|
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
|
||||||
}
|
}
|
||||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
const stdout = runReferenceSourceScript({
|
||||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-'));
|
workspaceRoot,
|
||||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
compareSourceRoot,
|
||||||
recursive: true,
|
script: 'hwe/compare/battle_trace.php',
|
||||||
filter: (source) => {
|
args: ['--jsonl'],
|
||||||
const relative = path.relative(resolvedCompareRoot, source);
|
input: normalizeJsonlForManifest(fixtureLines),
|
||||||
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}`)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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,
|
maxBuffer: 512 * 1024 * 1024,
|
||||||
}
|
});
|
||||||
);
|
const traces = stdout
|
||||||
traces.push(
|
|
||||||
...stdout
|
|
||||||
.split(/\r?\n/u)
|
.split(/\r?\n/u)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => JSON.parse(line) as ReferenceTrace)
|
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (traces.length !== fixtureLines.length) {
|
if (traces.length !== fixtureLines.length) {
|
||||||
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
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;
|
return traces;
|
||||||
} finally {
|
|
||||||
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record<string, ReferenceItemMetadata> => {
|
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(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'],
|
['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 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(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'],
|
['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 => {
|
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
|
||||||
expect(typeof actual, `${label}: actual type`).toBe('number');
|
expect(typeof actual, `${label}: actual type`).toBe('number');
|
||||||
expect(typeof expected, `${label}: reference type`).toBe('number');
|
expect(typeof expected, `${label}: reference type`).toBe('number');
|
||||||
if (process.env['STRICT_BATTLE_PARITY'] === '1') {
|
const actualNumber = actual as number;
|
||||||
expect(actual, `${label}: exact battle parity`).toBe(expected);
|
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;
|
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(
|
const tolerance = Math.max(
|
||||||
Number.EPSILON * Math.max(1, Math.abs(reference)) * 8,
|
Number.EPSILON * Math.max(1, Math.abs(expectedNumber)) * 16,
|
||||||
Math.abs(reference) * configuredRelativeTolerance
|
Math.abs(expectedNumber) * 1e-12,
|
||||||
|
1e-12
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
Math.abs((actual as number) - reference),
|
Math.abs(actualNumber - expectedNumber),
|
||||||
`${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}`
|
`${label}: core=${String(actualNumber)}, ref=${String(expectedNumber)}, tolerance=${tolerance}`
|
||||||
).toBeLessThanOrEqual(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> =>
|
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
|
||||||
Array.isArray(value) && value.length === 0 ? {} : value;
|
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 describeTextDifference = (actual: string | undefined, expected: string): string => {
|
||||||
const actualText = actual ?? '';
|
const actualText = actual ?? '';
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -448,19 +844,23 @@ const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null):
|
|||||||
arguments: normalizeRandomArguments(args),
|
arguments: normalizeRandomArguments(args),
|
||||||
result,
|
result,
|
||||||
}));
|
}));
|
||||||
const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng);
|
assertCanonicalValue(normalizedCoreRng, normalizedReferenceRng, 'rng');
|
||||||
if (rngDifference) {
|
assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng');
|
||||||
throw new Error(rngDifference);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertTraceParity = (
|
const assertTraceParity = (
|
||||||
coreEvents: WarBattleTraceEvent[],
|
coreEvents: WarBattleTraceEvent[],
|
||||||
reference: ReferenceTrace,
|
reference: ReferenceTrace,
|
||||||
coreRng: TracingRng | null
|
coreRng: TracingRng | null,
|
||||||
|
coreOutcome: WarBattleOutcome | null
|
||||||
): void => {
|
): void => {
|
||||||
const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null;
|
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) {
|
if (reference.defenderOrder) {
|
||||||
// Ref retains non-participating (order <= 0) defenders at the tail and
|
// Ref retains non-participating (order <= 0) defenders at the tail and
|
||||||
// stops when it reaches them. Core discards them before sorting. The
|
// 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),
|
after: reference.defenderOrder.after.filter(({ order }) => order > 0),
|
||||||
};
|
};
|
||||||
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
|
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
|
||||||
expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual(
|
expect(
|
||||||
effectiveReferenceOrder.before.map(({ id }) => id)
|
coreOrder?.before.map(({ id }) => id),
|
||||||
);
|
'defender order before IDs'
|
||||||
expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual(
|
).toEqual(effectiveReferenceOrder.before.map(({ id }) => id));
|
||||||
effectiveReferenceOrder.after.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 (const side of ['before', 'after'] as const) {
|
||||||
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
|
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
|
||||||
expectNearlyEqual(
|
expectNearlyEqual(
|
||||||
@@ -487,37 +889,145 @@ const assertTraceParity = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertRngParity(reference, coreRng);
|
assertRngParity(reference, coreRng);
|
||||||
const coreEventNames = comparableCoreEvents.map((event) => event.event);
|
assertCanonicalValue(comparableCoreEvents, reference.events, 'events');
|
||||||
const referenceEventNames = reference.events.map((event) => event.event);
|
assertFinalOutcomeParity(coreOutcome, coreEvents, reference);
|
||||||
expect(
|
};
|
||||||
coreEventNames,
|
|
||||||
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
|
||||||
).toEqual(referenceEventNames);
|
|
||||||
|
|
||||||
for (let index = 0; index < reference.events.length; index += 1) {
|
const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => {
|
||||||
const core = comparableCoreEvents[index]!;
|
const value = general.meta[key];
|
||||||
const ref = reference.events[index]!;
|
return typeof value === 'number' ? value : 0;
|
||||||
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);
|
const buildOutcomeGeneralSnapshot = (
|
||||||
expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase);
|
transient: WarBattleTraceUnitSnapshot,
|
||||||
expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase);
|
general: WarBattleOutcome['attacker'],
|
||||||
if (core.defender && ref.defender) {
|
report: WarBattleOutcome['reports'][number],
|
||||||
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
|
activatedSkills: Record<string, number>
|
||||||
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
|
): WarBattleTraceUnitSnapshot => ({
|
||||||
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
|
...transient,
|
||||||
expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase);
|
kind: 'general',
|
||||||
expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase);
|
id: general.id,
|
||||||
expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase);
|
name: general.name,
|
||||||
} else {
|
isAttacker: report.isAttacker,
|
||||||
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
|
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;
|
||||||
}
|
}
|
||||||
if (core.event === 'phase_damage') {
|
const finalEvent = coreEvents.at(-1);
|
||||||
for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) {
|
expect(finalEvent?.event, 'final battle trace event').toBe('battle_end');
|
||||||
expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`);
|
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;
|
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;
|
const itWithBattleCorpus = battleCorpusPath ? it : it.skip;
|
||||||
|
|
||||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
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(
|
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 },
|
{ timeout: 600_000 },
|
||||||
() => {
|
() => {
|
||||||
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
|
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
|
||||||
@@ -560,7 +1089,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
const categoryCounts = new Map<string, number>();
|
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);
|
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
||||||
if (failures.length < 40) {
|
if (failures.length < 40) {
|
||||||
failures.push(
|
failures.push(
|
||||||
@@ -593,7 +1127,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const reference = referenceTraces[index]!;
|
const reference = referenceTraces[index]!;
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const coreResult = processBattleSimJob(
|
const coreResult = processBattleSimJob(
|
||||||
{
|
{
|
||||||
...fixture,
|
...fixture,
|
||||||
@@ -604,50 +1140,27 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
|
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalReference = reference.events.at(-1);
|
try {
|
||||||
if (finalReference) {
|
assertAllLogBucketsParity(coreLogs, reference, fixture, `fixture[${index}]`);
|
||||||
const outcome = {
|
} catch (error) {
|
||||||
phase: coreResult.phase,
|
recordFailure('logs', index, fixture, error instanceof Error ? error.message : String(error));
|
||||||
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)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
|
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
|
||||||
@@ -734,6 +1247,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
|
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const coreResult = processBattleSimJob(
|
const coreResult = processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -744,16 +1258,19 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
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');
|
const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched');
|
||||||
if (entry.directCity) {
|
if (entry.directCity) {
|
||||||
expect(
|
expect(
|
||||||
@@ -792,7 +1309,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
|
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
|
||||||
);
|
);
|
||||||
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
|
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
|
||||||
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
|
'적군의 전멸에 <span style="color: cyan;">진격</span>이 이어집니다!'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -874,17 +1391,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
base.attackerGeneral.strength = 85;
|
base.attackerGeneral.strength = 85;
|
||||||
base.attackerGeneral.intel = 80;
|
base.attackerGeneral.intel = 80;
|
||||||
base.attackerGeneral.special =
|
base.attackerGeneral.special =
|
||||||
entry.kind === 'dualSlot'
|
entry.kind === 'dualSlot' ? entry.special : entry.kind === 'eventDomestic' ? entry.key : 'None';
|
||||||
? entry.special
|
|
||||||
: entry.kind === 'eventDomestic'
|
|
||||||
? entry.key
|
|
||||||
: 'None';
|
|
||||||
base.attackerGeneral.special2 =
|
base.attackerGeneral.special2 =
|
||||||
entry.kind === 'dualSlot'
|
entry.kind === 'dualSlot' ? entry.special2 : entry.kind === 'war' ? entry.key : 'None';
|
||||||
? entry.special2
|
|
||||||
: entry.kind === 'war'
|
|
||||||
? entry.key
|
|
||||||
: 'None';
|
|
||||||
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
|
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
|
||||||
if (entry.kind === 'nation') {
|
if (entry.kind === 'nation') {
|
||||||
base.attackerNation.type = entry.key;
|
base.attackerNation.type = entry.key;
|
||||||
@@ -892,6 +1401,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
|
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(
|
processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -901,14 +1411,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
assertTraceParity(
|
||||||
|
coreEvents,
|
||||||
|
runReferenceTrace(workspaceRoot!, JSON.stringify(base)),
|
||||||
|
coreRng,
|
||||||
|
coreOutcome
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(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 },
|
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(
|
processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -999,13 +1519,45 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(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', () => {
|
it('matches 척사 items against region-restricted troops', () => {
|
||||||
@@ -1033,7 +1585,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
base.attackerGeneral.crew = 5000;
|
base.attackerGeneral.crew = 5000;
|
||||||
base.defenderGenerals[0]!.crewtype = 1101;
|
base.defenderGenerals[0]!.crewtype = 1101;
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(
|
processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -1043,17 +1597,39 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(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 fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
|
||||||
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
||||||
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
|
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
|
||||||
@@ -1086,18 +1662,271 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const coreResult = processBattleSimJob(payload, {
|
const coreResult = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(coreResult.result).toBe(true);
|
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', () => {
|
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
||||||
@@ -1142,11 +1971,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
@@ -1157,12 +1990,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
assertRngParity(reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
const finalReference = reference.events.at(-1)!;
|
|
||||||
expect({ phase: result.phase, killed: result.killed }).toEqual({
|
|
||||||
phase: finalReference.attacker.phase,
|
|
||||||
killed: finalReference.attacker.killed,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ref keeps the injury-adjusted intelligence fraction here:
|
// Ref keeps the injury-adjusted intelligence fraction here:
|
||||||
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
|
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
|
||||||
@@ -1185,7 +2013,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
{
|
{
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
fractionalCoreRng = new TracingRng(LiteHashDRBG.build(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[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2);
|
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', () => {
|
it('matches siege dexterity and castle damage handling', () => {
|
||||||
@@ -1315,18 +2147,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.some((event) => event.defender?.kind === 'city')).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', () => {
|
it('matches the no-defender supply-retreat branch without consuming RNG', () => {
|
||||||
@@ -1358,18 +2194,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']);
|
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 }, () => {
|
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 },
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(payload, {
|
processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
|
assertAllLogBucketsParity(coreLogs, reference, base, `item.attacker.${itemKey}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const debug =
|
const debug =
|
||||||
process.env['ITEM_PARITY_DEBUG'] === '1'
|
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 },
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(payload, {
|
processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
|
assertAllLogBucketsParity(coreLogs, reference, base, `item.defender.${itemKey}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const debug =
|
const debug =
|
||||||
process.env['ITEM_PARITY_DEBUG'] === '1'
|
process.env['ITEM_PARITY_DEBUG'] === '1'
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
|||||||
commandProfile: createCoreTurnCommandProfile(request),
|
commandProfile: createCoreTurnCommandProfile(request),
|
||||||
});
|
});
|
||||||
world = new InMemoryTurnWorld(state, snapshot, {
|
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,
|
generalTurnHandler: handler,
|
||||||
});
|
});
|
||||||
const actor = world.getGeneralById(request.actorGeneralId);
|
const actor = world.getGeneralById(request.actorGeneralId);
|
||||||
|
|||||||
Reference in New Issue
Block a user