merge: 특수 유저 커맨드 호환 검증을 반영한다
This commit is contained in:
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { throwIfCommandRejected } from '../router/shared/turnDaemon.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
import { resolveAuctionTimerScore } from './scheduler.js';
|
||||
|
||||
@@ -21,6 +22,7 @@ export type OpenAuctionInput =
|
||||
|
||||
export const openAuctionWithDaemon = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string,
|
||||
generalId: number,
|
||||
input: OpenAuctionInput,
|
||||
requestId?: string
|
||||
@@ -28,9 +30,11 @@ export const openAuctionWithDaemon = async (
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionOpen',
|
||||
...(requestId ? { requestId } : {}),
|
||||
userId,
|
||||
generalId,
|
||||
...input,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'auctionOpen') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const scopeHttpIdempotencyKey = (options: {
|
||||
rawKey: string | undefined;
|
||||
profileId: string;
|
||||
userId: string | null;
|
||||
}): string | undefined => {
|
||||
const rawKey = options.rawKey?.trim();
|
||||
if (!rawKey) {
|
||||
return undefined;
|
||||
}
|
||||
// Request IDs are global in input_event. Hash the untrusted header with
|
||||
// its authenticated principal and profile so equal client keys cannot
|
||||
// collide across users or profiles, and oversized/punctuation-heavy
|
||||
// headers never flow into DB identifiers or child command IDs.
|
||||
const digest = createHash('sha256')
|
||||
.update(options.profileId)
|
||||
.update('\0')
|
||||
.update(options.userId ?? 'anonymous')
|
||||
.update('\0')
|
||||
.update(rawKey)
|
||||
.digest('hex');
|
||||
return `http:${digest}`;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { buildAuctionAlias } from '@sammo-ts/logic';
|
||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||
import { resolveAuctionTimerScore } from '../../auction/scheduler.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
|
||||
const zBidInput = z.object({
|
||||
auctionId: z.number().int().positive(),
|
||||
@@ -340,6 +341,7 @@ export const auctionRouter = router({
|
||||
await ensureAuctionSeasonActive(ctx.db);
|
||||
return openAuctionWithDaemon(
|
||||
ctx,
|
||||
auth.user.id,
|
||||
general.id,
|
||||
{ auctionType: 'BUY_RICE', ...input },
|
||||
ctx.requestId ? `${ctx.requestId}:auction.openBuyRice:engine:0:auctionOpen` : undefined
|
||||
@@ -351,6 +353,7 @@ export const auctionRouter = router({
|
||||
await ensureAuctionSeasonActive(ctx.db);
|
||||
return openAuctionWithDaemon(
|
||||
ctx,
|
||||
auth.user.id,
|
||||
general.id,
|
||||
{ auctionType: 'SELL_RICE', ...input },
|
||||
ctx.requestId ? `${ctx.requestId}:auction.openSellRice:engine:0:auctionOpen` : undefined
|
||||
@@ -362,6 +365,7 @@ export const auctionRouter = router({
|
||||
await ensureAuctionSeasonActive(ctx.db);
|
||||
return openAuctionWithDaemon(
|
||||
ctx,
|
||||
auth.user.id,
|
||||
general.id,
|
||||
{
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
@@ -425,12 +429,14 @@ export const auctionRouter = router({
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionBid',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidBuyRice:engine:0:auctionBid` } : {}),
|
||||
userId: auth.user.id,
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
@@ -501,12 +507,14 @@ export const auctionRouter = router({
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionBid',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidSellRice:engine:0:auctionBid` } : {}),
|
||||
userId: auth.user.id,
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
@@ -638,12 +646,14 @@ export const auctionRouter = router({
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionBid',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidUnique:engine:0:auctionBid` } : {}),
|
||||
userId: auth.user.id,
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
@@ -223,6 +224,7 @@ export const diplomacyRouter = router({
|
||||
nationColor: destNation.color,
|
||||
},
|
||||
};
|
||||
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
|
||||
|
||||
const created = await ctx.db.diplomacyLetter.create({
|
||||
data: {
|
||||
@@ -232,6 +234,7 @@ export const diplomacyRouter = router({
|
||||
state: 'PROPOSED',
|
||||
textBrief: purifyDiplomacyHtml(input.brief),
|
||||
textDetail: purifyDiplomacyHtml(input.detail),
|
||||
date: letterDate,
|
||||
srcSignerId: me.id,
|
||||
aux: aux as GamePrisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
resolveRegionName,
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../services/gameDisplayNames.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
import {
|
||||
loadTraitNames,
|
||||
resolveNationBill,
|
||||
@@ -299,88 +300,97 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0);
|
||||
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog, rankRows] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
region: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
meta: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
tickSeconds: true,
|
||||
lastTurnTick: true,
|
||||
config: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
officerCityId > 0
|
||||
? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
general.troopId > 0
|
||||
? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
general.troopId > 0
|
||||
? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } })
|
||||
: Promise.resolve(null),
|
||||
general.troopId > 0
|
||||
? ctx.db.generalTurn.findFirst({
|
||||
where: { generalId: general.troopId },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
select: { actionCode: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.generalAccessLog.findUnique({
|
||||
where: { generalId: general.id },
|
||||
select: { refreshScore: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.rankData.findMany({
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
]);
|
||||
const [
|
||||
city,
|
||||
queriedNation,
|
||||
worldState,
|
||||
officerCity,
|
||||
troop,
|
||||
troopLeader,
|
||||
troopLeaderFirstTurn,
|
||||
accessLog,
|
||||
rankRows,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
region: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
meta: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
tickSeconds: true,
|
||||
lastTurnTick: true,
|
||||
config: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
officerCityId > 0
|
||||
? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
general.troopId > 0
|
||||
? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
general.troopId > 0
|
||||
? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } })
|
||||
: Promise.resolve(null),
|
||||
general.troopId > 0
|
||||
? ctx.db.generalTurn.findFirst({
|
||||
where: { generalId: general.troopId },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
select: { actionCode: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.generalAccessLog.findUnique({
|
||||
where: { generalId: general.id },
|
||||
select: { refreshScore: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.rankData.findMany({
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, chiefAndCityOfficerRows] =
|
||||
@@ -450,14 +460,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] = await Promise.all([
|
||||
loadTraitNames([general.personalCode], 'personality'),
|
||||
loadTraitNames([general.specialCode], 'domestic'),
|
||||
loadTraitNames([general.special2Code], 'war'),
|
||||
loadTraitNames([nation.typeCode], 'nation'),
|
||||
loadCrewTypeDisplayDetails(worldState, ctx.profile.id),
|
||||
loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
|
||||
]);
|
||||
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] =
|
||||
await Promise.all([
|
||||
loadTraitNames([general.personalCode], 'personality'),
|
||||
loadTraitNames([general.specialCode], 'domestic'),
|
||||
loadTraitNames([general.special2Code], 'war'),
|
||||
loadTraitNames([nation.typeCode], 'nation'),
|
||||
loadCrewTypeDisplayDetails(worldState, ctx.profile.id),
|
||||
loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
|
||||
]);
|
||||
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
@@ -711,15 +722,15 @@ export const generalRouter = router({
|
||||
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
|
||||
const resetToDefault = input?.resetToDefault === true;
|
||||
if (resetToDefault && input?.iconId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
if (!resetToDefault && !input?.iconId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
|
||||
}
|
||||
if (
|
||||
resetToDefault &&
|
||||
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
|
||||
) {
|
||||
if (resetToDefault && (ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
|
||||
}
|
||||
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
|
||||
@@ -727,7 +738,10 @@ export const generalRouter = router({
|
||||
}
|
||||
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
|
||||
if (!iconRevision) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '계정 아이콘 변경 시각을 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const projection = resetToDefault
|
||||
? {
|
||||
@@ -740,13 +754,7 @@ export const generalRouter = router({
|
||||
imageServer: selected!.imageServer,
|
||||
revision: iconRevision,
|
||||
};
|
||||
return adjustAccountIconForUser(
|
||||
ctx,
|
||||
userId,
|
||||
projection,
|
||||
true,
|
||||
input?.clientRequestId ?? ctx.requestId
|
||||
);
|
||||
return adjustAccountIconForUser(ctx, userId, projection, true, input?.clientRequestId ?? ctx.requestId);
|
||||
}),
|
||||
me: authedProcedure.query(({ ctx }) => getGeneralContext(ctx)),
|
||||
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
@@ -795,12 +803,15 @@ export const generalRouter = router({
|
||||
requestImmediateAction(ctx, input, 'instantRetreat')
|
||||
),
|
||||
vacation: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'vacation',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.vacation:engine:0:vacation` } : {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'vacation') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
@@ -810,15 +821,16 @@ export const generalRouter = router({
|
||||
return { ok: true };
|
||||
}),
|
||||
setMySetting: accessEngineAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
...(ctx.requestId
|
||||
? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` }
|
||||
: {}),
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` } : {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
settings: input,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'setMySetting') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
@@ -828,22 +840,27 @@ export const generalRouter = router({
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
dropItem: engineAuthedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'dropItem',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0:dropItem` } : {}),
|
||||
generalId: general.id,
|
||||
itemType: input.itemType,
|
||||
});
|
||||
if (!result || result.type !== 'dropItem') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
dropItem: engineAuthedProcedure
|
||||
.input(z.object({ itemType: z.enum(['horse', 'weapon', 'book', 'item']) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'dropItem',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0:dropItem` } : {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
itemType: input.itemType,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'dropItem') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
getMyLog: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -416,6 +416,7 @@ export const inheritRouter = router({
|
||||
}
|
||||
const result = await openAuctionWithDaemon(
|
||||
ctx,
|
||||
userId,
|
||||
general.id,
|
||||
{
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
|
||||
|
||||
export const appoint = engineAuthedProcedure
|
||||
.input(
|
||||
@@ -13,15 +14,18 @@ export const appoint = engineAuthedProcedure
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'appoint',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.appoint:engine:0:appoint` } : {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
destGeneralId: input.destGeneralId,
|
||||
destCityId: input.destCityId,
|
||||
officerLevel: input.officerLevel,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'appoint') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
|
||||
|
||||
export const changePermission = engineAuthedProcedure
|
||||
.input(
|
||||
@@ -15,16 +16,19 @@ export const changePermission = engineAuthedProcedure
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'changePermission',
|
||||
...(ctx.requestId
|
||||
? { requestId: `${ctx.requestId}:nation.changePermission:engine:0:changePermission` }
|
||||
: {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
isAmbassador: input.isAmbassador,
|
||||
targetGeneralIds: input.targetGeneralIds,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'changePermission') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
|
||||
@@ -2,18 +2,22 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
|
||||
|
||||
export const kick = engineAuthedProcedure
|
||||
.input(z.object({ destGeneralId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'kick',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.kick:engine:0:kick` } : {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
destGeneralId: input.destGeneralId,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
if (!result || result.type !== 'kick') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
|
||||
|
||||
export const setBill = authedProcedure
|
||||
export const setBill = engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
amount: z.number().int().min(20).max(200),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -24,14 +23,6 @@ export const setBill = authedProcedure
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
bill: input.amount,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
await updateNationSetting(ctx, userId, me, 'setBill', { kind: 'bill', amount: input.amount });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,49 +1,31 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
|
||||
|
||||
export const setBlockScout = authedProcedure
|
||||
export const setBlockScout = engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
value: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const [nation, worldState] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { meta: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: { meta: true },
|
||||
}),
|
||||
]);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
if (worldMeta.block_change_scout === true) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
||||
});
|
||||
}
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
scout: input.value ? 1 : 0,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
await updateNationSetting(ctx, userId, me, 'setBlockScout', {
|
||||
kind: 'blockScout',
|
||||
value: input.value,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, resolveWarSettingRemain, updateNationMeta } from '../shared.js';
|
||||
|
||||
export const setBlockWar = authedProcedure
|
||||
export const setBlockWar = engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
value: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -25,20 +24,9 @@ export const setBlockWar = authedProcedure
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
|
||||
const meta = asRecord(nation.meta);
|
||||
const remain = resolveWarSettingRemain(meta);
|
||||
if (remain <= 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '잔여 횟수가 부족합니다.' });
|
||||
}
|
||||
const nextRemain = Math.max(0, remain - 1);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
war: input.value ? 1 : 0,
|
||||
available_war_setting_cnt: nextRemain,
|
||||
},
|
||||
meta
|
||||
);
|
||||
return { availableCnt: nextRemain };
|
||||
const result = await updateNationSetting(ctx, userId, me, 'setBlockWar', {
|
||||
kind: 'blockWar',
|
||||
value: input.value,
|
||||
});
|
||||
return { availableCnt: result.availableCnt ?? 0 };
|
||||
});
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { purifyNationHtml } from '../../../security/nationHtml.js';
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { legacyRequiredText } from '../settingInput.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
export const setNotice = authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
msg: z.string().min(1).max(16384),
|
||||
})
|
||||
)
|
||||
export const setNotice = engineAuthedProcedure
|
||||
.input(z.object({ msg: legacyRequiredText(16_384) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -25,16 +21,7 @@ export const setNotice = authedProcedure
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const msg = purifyNationHtml(input.msg);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
notice: msg,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
ctx.changeJournal?.mark('front.nation', me.nationId);
|
||||
await updateNationSetting(ctx, userId, me, 'setNotice', { kind: 'notice', message: msg });
|
||||
return { ok: true, msg };
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
|
||||
|
||||
export const setRate = authedProcedure
|
||||
export const setRate = engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
amount: z.number().int().min(5).max(30),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -24,14 +23,6 @@ export const setRate = authedProcedure
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
rate: input.amount,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
await updateNationSetting(ctx, userId, me, 'setRate', { kind: 'rate', amount: input.amount });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { purifyNationHtml } from '../../../security/nationHtml.js';
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { legacyRequiredText } from '../settingInput.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
export const setScoutMsg = authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
msg: z.string().min(1).max(1000),
|
||||
})
|
||||
)
|
||||
export const setScoutMsg = engineAuthedProcedure
|
||||
.input(z.object({ msg: legacyRequiredText(1_000) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -25,15 +21,7 @@ export const setScoutMsg = authedProcedure
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const msg = purifyNationHtml(input.msg);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
infoText: msg,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
await updateNationSetting(ctx, userId, me, 'setScoutMsg', { kind: 'scoutMessage', message: msg });
|
||||
return { ok: true, msg };
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { engineAuthedProcedure } from '../../../trpc.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js';
|
||||
|
||||
export const setSecretLimit = authedProcedure
|
||||
export const setSecretLimit = engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
amount: z.number().int().min(1).max(99),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -24,14 +23,9 @@ export const setSecretLimit = authedProcedure
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
assertNationEditable(me, nation.meta);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
me.nationId,
|
||||
{
|
||||
secretlimit: input.amount,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
await updateNationSetting(ctx, userId, me, 'setSecretLimit', {
|
||||
kind: 'secretLimit',
|
||||
amount: input.amount,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// PHP trim() removes only this default ASCII character list. JavaScript
|
||||
// String#trim also removes Unicode spaces such as U+3000 and would reject a
|
||||
// value that Ref accepts.
|
||||
const hasLegacyTrimmedContent = (value: string): boolean => /[^ \t\n\r\0\v]/u.test(value);
|
||||
|
||||
export const legacyRequiredText = (maximumCodePoints: number) =>
|
||||
z
|
||||
.string()
|
||||
.refine(hasLegacyTrimmedContent, '필수 입력입니다.')
|
||||
.refine((value) => Array.from(value).length <= maximumCodePoints, `최대 ${maximumCodePoints}자까지 입력할 수 있습니다.`);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, type TurnDaemonCommand } from '@sammo-ts/common';
|
||||
import {
|
||||
createIncomeActionContext,
|
||||
DomesticTraitLoader,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type TriggerValue,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { purifyNationHtml } from '../../security/nationHtml.js';
|
||||
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
@@ -277,8 +277,10 @@ export const resolveNationScoutMessage = (meta: Record<string, unknown>): string
|
||||
|
||||
export const resolveWarSettingRemain = (meta: Record<string, unknown>): number => {
|
||||
const legacy = readMetaNumber(meta, 'available_war_setting_cnt', -1);
|
||||
const fallback =
|
||||
legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT);
|
||||
// Ref treats an absent counter as zero. The monthly refill is responsible
|
||||
// for creating or replenishing it; a missing migration value must not grant
|
||||
// ten free policy changes.
|
||||
const fallback = legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', 0);
|
||||
return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_CNT, fallback));
|
||||
};
|
||||
|
||||
@@ -428,38 +430,30 @@ export const assertNationEditable = (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateNationMeta = async (
|
||||
ctx: Pick<GameApiContext, 'turnDaemon' | 'changeJournal'>,
|
||||
nationId: number,
|
||||
updates: Record<string, unknown>,
|
||||
currentMeta: Record<string, unknown>
|
||||
): Promise<InputJsonValue> => {
|
||||
const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined;
|
||||
type NationSettingMutation = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>['mutation'];
|
||||
|
||||
export const updateNationSetting = async (
|
||||
ctx: Pick<GameApiContext, 'turnDaemon' | 'requestId'>,
|
||||
userId: string,
|
||||
general: { id: number; nationId: number },
|
||||
endpoint: string,
|
||||
mutation: NationSettingMutation
|
||||
) => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setNationMeta',
|
||||
nationId,
|
||||
updates,
|
||||
expectedUpdatedAt,
|
||||
type: 'setNationSetting',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.${endpoint}:engine:0:setNationSetting` } : {}),
|
||||
userId,
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
mutation,
|
||||
});
|
||||
if (!result || result.type !== 'setNationMeta') {
|
||||
if (!result || result.type !== 'setNationSetting') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'CONFLICT') {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
throw new TRPCError({ code: result.code, message: result.reason });
|
||||
}
|
||||
ctx.changeJournal?.mark('nation.content', nationId);
|
||||
ctx.changeJournal?.mark('dashboard.global');
|
||||
return {
|
||||
...currentMeta,
|
||||
...updates,
|
||||
_updatedAt: result.updatedAt,
|
||||
} as InputJsonValue;
|
||||
return result;
|
||||
};
|
||||
|
||||
export const mapGeneralList = async (
|
||||
|
||||
@@ -3,137 +3,24 @@ import { z } from 'zod';
|
||||
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import {
|
||||
DEFAULT_GENERAL_PRIORITY,
|
||||
DEFAULT_NATION_POLICY,
|
||||
DEFAULT_NATION_PRIORITY,
|
||||
type NationPolicy,
|
||||
} from '@sammo-ts/game-engine/turn/npcPolicyMutation.js';
|
||||
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { accessEngineAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
type NationPolicy = {
|
||||
reqNationGold: number;
|
||||
reqNationRice: number;
|
||||
CombatForce: Record<number, [number, number]>;
|
||||
SupportForce: number[];
|
||||
DevelopForce: number[];
|
||||
reqHumanWarUrgentGold: number;
|
||||
reqHumanWarUrgentRice: number;
|
||||
reqHumanWarRecommandGold: number;
|
||||
reqHumanWarRecommandRice: number;
|
||||
reqHumanDevelGold: number;
|
||||
reqHumanDevelRice: number;
|
||||
reqNPCWarGold: number;
|
||||
reqNPCWarRice: number;
|
||||
reqNPCDevelGold: number;
|
||||
reqNPCDevelRice: number;
|
||||
minimumResourceActionAmount: number;
|
||||
maximumResourceActionAmount: number;
|
||||
minNPCWarLeadership: number;
|
||||
minWarCrew: number;
|
||||
minNPCRecruitCityPopulation: number;
|
||||
safeRecruitCityPopulationRatio: number;
|
||||
properWarTrainAtmos: number;
|
||||
cureThreshold: number;
|
||||
};
|
||||
|
||||
type SetterInfo = {
|
||||
setter: string | null;
|
||||
date: string | null;
|
||||
};
|
||||
|
||||
const updateNationMeta = async (
|
||||
ctx: Pick<GameApiContext, 'turnDaemon'>,
|
||||
nationId: number,
|
||||
updates: Record<string, unknown>,
|
||||
currentMeta: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined;
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setNationMeta',
|
||||
nationId,
|
||||
updates,
|
||||
expectedUpdatedAt,
|
||||
});
|
||||
if (!result || result.type !== 'setNationMeta') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'CONFLICT') {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_NATION_PRIORITY = [
|
||||
'불가침제의',
|
||||
'선전포고',
|
||||
'천도',
|
||||
'유저장긴급포상',
|
||||
'부대전방발령',
|
||||
'유저장구출발령',
|
||||
'유저장후방발령',
|
||||
'부대유저장후방발령',
|
||||
'유저장전방발령',
|
||||
'유저장포상',
|
||||
'부대구출발령',
|
||||
'부대후방발령',
|
||||
'NPC긴급포상',
|
||||
'NPC구출발령',
|
||||
'NPC후방발령',
|
||||
'NPC포상',
|
||||
'NPC전방발령',
|
||||
'유저장내정발령',
|
||||
'NPC내정발령',
|
||||
'NPC몰수',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_GENERAL_PRIORITY = [
|
||||
'NPC사망대비',
|
||||
'귀환',
|
||||
'금쌀구매',
|
||||
'출병',
|
||||
'긴급내정',
|
||||
'전투준비',
|
||||
'전방워프',
|
||||
'NPC헌납',
|
||||
'징병',
|
||||
'후방워프',
|
||||
'전쟁내정',
|
||||
'소집해제',
|
||||
'일반내정',
|
||||
'내정워프',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_NATION_POLICY: NationPolicy = {
|
||||
reqNationGold: 10000,
|
||||
reqNationRice: 12000,
|
||||
CombatForce: {},
|
||||
SupportForce: [],
|
||||
DevelopForce: [],
|
||||
reqHumanWarUrgentGold: 0,
|
||||
reqHumanWarUrgentRice: 0,
|
||||
reqHumanWarRecommandGold: 0,
|
||||
reqHumanWarRecommandRice: 0,
|
||||
reqHumanDevelGold: 10000,
|
||||
reqHumanDevelRice: 10000,
|
||||
reqNPCWarGold: 0,
|
||||
reqNPCWarRice: 0,
|
||||
reqNPCDevelGold: 0,
|
||||
reqNPCDevelRice: 500,
|
||||
minimumResourceActionAmount: 1000,
|
||||
maximumResourceActionAmount: 10000,
|
||||
minNPCWarLeadership: 40,
|
||||
minWarCrew: 1500,
|
||||
minNPCRecruitCityPopulation: 50000,
|
||||
safeRecruitCityPopulationRatio: 0.5,
|
||||
properWarTrainAtmos: 90,
|
||||
cureThreshold: 10,
|
||||
};
|
||||
|
||||
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
|
||||
|
||||
const INTEGER_POLICY_KEYS = [
|
||||
@@ -310,7 +197,7 @@ const applyPolicyValues = (base: NationPolicy, values: Record<string, unknown>):
|
||||
const floatKey = key as FloatPolicyKey;
|
||||
if (FLOAT_POLICY_KEYS.includes(floatKey)) {
|
||||
const value = readNumber(rawValue, next.safeRecruitCityPopulationRatio);
|
||||
next.safeRecruitCityPopulationRatio = Math.max(0, value);
|
||||
next.safeRecruitCityPopulationRatio = value;
|
||||
continue;
|
||||
}
|
||||
if (key === 'CombatForce' && isRecord(rawValue)) {
|
||||
@@ -415,34 +302,51 @@ const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'pri
|
||||
};
|
||||
};
|
||||
|
||||
const validateGeneralPriority = (priority: string[]): string | null => {
|
||||
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
|
||||
const mustHave = new Set(['출병', '일반내정']);
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
for (const [idx, item] of priority.entries()) {
|
||||
if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) {
|
||||
return `${item}은 올바른 명령이 아닙니다.`;
|
||||
}
|
||||
orderMap.set(item, idx);
|
||||
mustHave.delete(item);
|
||||
const requestNpcPolicyMutation = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db' | 'requestId' | 'turnDaemon'>,
|
||||
endpoint: 'setNationPolicy' | 'setNationPriority' | 'setGeneralPriority',
|
||||
mutation:
|
||||
| { kind: 'nationPolicy'; values: Record<string, unknown> }
|
||||
| { kind: 'nationPriority'; priority: string[] }
|
||||
| { kind: 'generalPriority'; priority: string[] }
|
||||
): Promise<{ ok: true }> => {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
for (const [pre, post] of orderRequired) {
|
||||
const preIdx = orderMap.get(pre);
|
||||
const postIdx = orderMap.get(post);
|
||||
if (preIdx === undefined || postIdx === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (preIdx > postIdx) {
|
||||
return `${pre} 명령은 ${post} 명령보다 먼저여야 합니다.`;
|
||||
}
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' });
|
||||
}
|
||||
|
||||
if (mustHave.size > 0) {
|
||||
return `${Array.from(mustHave)[0]}은 항상 사용해야 합니다.`;
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
return null;
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const expectedUpdatedAt =
|
||||
typeof nationMeta._npcPolicyUpdatedAt === 'string'
|
||||
? nationMeta._npcPolicyUpdatedAt
|
||||
: typeof nationMeta._updatedAt === 'string'
|
||||
? nationMeta._updatedAt
|
||||
: null;
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setNpcPolicy',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:npc.${endpoint}:engine:0:setNpcPolicy` } : {}),
|
||||
userId: ctx.auth.user.id,
|
||||
generalId: general.id,
|
||||
nationId: nation.id,
|
||||
expectedUpdatedAt,
|
||||
mutation,
|
||||
});
|
||||
if (!result || result.type !== 'setNpcPolicy') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: result.code, message: result.reason });
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const npcRouter = router({
|
||||
@@ -542,272 +446,13 @@ export const npcRouter = router({
|
||||
permissionLevel,
|
||||
};
|
||||
}),
|
||||
setNationPolicy: accessAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const keys = Object.keys(input);
|
||||
for (const key of keys) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const troopRows = await ctx.db.troop.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
select: { troopLeaderId: true },
|
||||
});
|
||||
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
||||
|
||||
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
||||
const citySet = new Set(cityRows.map((row) => row.id));
|
||||
const assigned = new Set<number>();
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
||||
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
}
|
||||
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = value;
|
||||
}
|
||||
|
||||
if ('CombatForce' in input) {
|
||||
const rawCombat = input.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
||||
}
|
||||
const combatForce: Record<number, [number, number]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawKey);
|
||||
if (!Number.isFinite(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 입력양식이 올바르지 않습니다.`,
|
||||
});
|
||||
}
|
||||
const fromCity = Number(rawValue[0]);
|
||||
const toCity = Number(rawValue[1]);
|
||||
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
combatForce[leaderId] = [fromCity, toCity];
|
||||
assigned.add(leaderId);
|
||||
}
|
||||
nextValues.CombatForce = combatForce;
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = input[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawValue of rawList) {
|
||||
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
if (assigned.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
assigned.add(rawValue);
|
||||
list.push(rawValue);
|
||||
}
|
||||
if (key === 'SupportForce') {
|
||||
nextValues.SupportForce = list;
|
||||
} else {
|
||||
nextValues.DevelopForce = list;
|
||||
}
|
||||
}
|
||||
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
values: nextValues,
|
||||
valueSetter: general.name,
|
||||
valueSetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
for (const item of input) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: input,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setGeneralPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const validationError = validateGeneralPriority(input);
|
||||
if (validationError) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
||||
}
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: input,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_general_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPolicy: accessEngineAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(({ ctx, input }) =>
|
||||
requestNpcPolicyMutation(ctx, 'setNationPolicy', { kind: 'nationPolicy', values: input })
|
||||
),
|
||||
setNationPriority: accessEngineAuthedInputProcedure(z.array(z.string())).mutation(({ ctx, input }) =>
|
||||
requestNpcPolicyMutation(ctx, 'setNationPriority', { kind: 'nationPriority', priority: input })
|
||||
),
|
||||
setGeneralPriority: accessEngineAuthedInputProcedure(z.array(z.string())).mutation(({ ctx, input }) =>
|
||||
requestNpcPolicyMutation(ctx, 'setGeneralPriority', { kind: 'generalPriority', priority: input })
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
|
||||
export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
|
||||
if (!ctx.auth?.user.id) {
|
||||
export const getAuthenticatedUserId = (ctx: Pick<GameApiContext, 'auth'>): string => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId: ctx.auth.user.id },
|
||||
where: { userId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
@@ -14,20 +20,15 @@ export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) =>
|
||||
return general;
|
||||
};
|
||||
|
||||
export const getOwnedGeneral = async (
|
||||
ctx: Pick<GameApiContext, 'db' | 'auth'>,
|
||||
generalId: number
|
||||
) => {
|
||||
if (!ctx.auth?.user.id) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
export const getOwnedGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>, generalId: number) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found.' });
|
||||
}
|
||||
if (general.userId !== ctx.auth.user.id) {
|
||||
if (general.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'General is not owned by the authenticated user.',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
|
||||
export const throwIfCommandRejected = (result: TurnDaemonCommandResult | null): void => {
|
||||
if (result?.type !== 'commandRejected') {
|
||||
return;
|
||||
}
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||
};
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
resolveRemainingMinutes,
|
||||
} from '../../services/generalBasicCardProjection.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
|
||||
const TROOP_PANEL_RECORD_TYPES = [
|
||||
'firenum',
|
||||
@@ -54,6 +55,7 @@ const assertCommandResult = <T extends 'troopCreate' | 'troopJoin' | 'troopExit'
|
||||
result: TurnDaemonCommandResult | null,
|
||||
expectedType: T
|
||||
): never => {
|
||||
throwIfCommandRejected(result);
|
||||
if (!result) {
|
||||
throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' });
|
||||
}
|
||||
@@ -427,6 +429,7 @@ export const troopRouter = router({
|
||||
};
|
||||
}),
|
||||
create: engineAuthedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
const troopName = normalizeRequiredTroopName(input.troopName);
|
||||
if (me.troopId !== 0) {
|
||||
@@ -444,6 +447,7 @@ export const troopRouter = router({
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopCreate',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.create:engine:0:troopCreate` } : {}),
|
||||
userId,
|
||||
generalId: me.id,
|
||||
troopName,
|
||||
});
|
||||
@@ -458,10 +462,12 @@ export const troopRouter = router({
|
||||
join: engineAuthedProcedure
|
||||
.input(z.object({ troopId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopJoin',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.join:engine:0:troopJoin` } : {}),
|
||||
userId,
|
||||
generalId: me.id,
|
||||
troopId: input.troopId,
|
||||
});
|
||||
@@ -474,10 +480,12 @@ export const troopRouter = router({
|
||||
return { ok: true };
|
||||
}),
|
||||
exit: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopExit',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.exit:engine:0:troopExit` } : {}),
|
||||
userId,
|
||||
generalId: me.id,
|
||||
});
|
||||
if (!result || result.type !== 'troopExit') {
|
||||
@@ -496,6 +504,7 @@ export const troopRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
if (me.id !== input.troopId || me.troopId !== me.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
@@ -520,6 +529,7 @@ export const troopRouter = router({
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopKick',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.kick:engine:0:troopKick` } : {}),
|
||||
userId,
|
||||
generalId: me.id,
|
||||
troopId: input.troopId,
|
||||
targetGeneralId: input.targetGeneralId,
|
||||
@@ -541,6 +551,7 @@ export const troopRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const me = await getMyGeneral(ctx);
|
||||
const troopName = normalizeRequiredTroopName(input.troopName);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
@@ -562,6 +573,7 @@ export const troopRouter = router({
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopRename',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.rename:engine:0:troopRename` } : {}),
|
||||
userId,
|
||||
generalId: me.id,
|
||||
troopId: input.troopId,
|
||||
troopName,
|
||||
|
||||
@@ -5,8 +5,9 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
@@ -347,15 +348,19 @@ export const voteRouter = router({
|
||||
if (new Set(sortedSelection).size !== sortedSelection.length) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' });
|
||||
}
|
||||
const userId = getAuthenticatedUserId(ctx);
|
||||
const general = await getMyGeneral(ctx);
|
||||
|
||||
const rewardResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'voteReward',
|
||||
...(ctx.requestId ? { requestId: `${ctx.requestId}:vote.submitVote:engine:0:voteReward` } : {}),
|
||||
userId,
|
||||
voteId: input.voteId,
|
||||
generalId: general.id,
|
||||
selection: sortedSelection,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
throwIfCommandRejected(rewardResult);
|
||||
|
||||
if (!rewardResult || rewardResult.type !== 'voteReward') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
|
||||
@@ -47,6 +47,7 @@ import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
|
||||
import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||
import { scopeHttpIdempotencyKey } from './requestId.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -247,11 +248,15 @@ export const createGameApiServer = async () => {
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key'];
|
||||
return createGameApiContext({
|
||||
requestId:
|
||||
(Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key']) || undefined,
|
||||
requestId: scopeHttpIdempotencyKey({
|
||||
rawKey: rawIdempotencyKey,
|
||||
profileId: config.profile,
|
||||
userId: auth?.user.id ?? null,
|
||||
}),
|
||||
db: postgres.prisma,
|
||||
redis: redis.client,
|
||||
turnDaemon,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TournamentKeys } from './keys.js';
|
||||
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
||||
@@ -19,15 +20,115 @@ interface RedisClientLike {
|
||||
publish?(channel: string, message: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||
if (!raw) {
|
||||
export class CorruptTournamentProjectionError extends Error {
|
||||
constructor(readonly key: string) {
|
||||
super(`Tournament projection is malformed: ${key}`);
|
||||
this.name = 'CorruptTournamentProjectionError';
|
||||
}
|
||||
}
|
||||
|
||||
const zTournamentState = z
|
||||
.object({
|
||||
stage: z.number().int(),
|
||||
phase: z.number().int(),
|
||||
type: z.number().int(),
|
||||
auto: z.boolean(),
|
||||
openYear: z.number().int(),
|
||||
openMonth: z.number().int(),
|
||||
termSeconds: z.number(),
|
||||
nextAt: z.string(),
|
||||
bettingId: z.number().int().optional(),
|
||||
bettingCloseAt: z.string().optional(),
|
||||
winnerId: z.number().int().optional(),
|
||||
bettingSettled: z.boolean().optional(),
|
||||
rewardSettled: z.boolean().optional(),
|
||||
participantsLockedAt: z.string().optional(),
|
||||
lastError: z.string().optional(),
|
||||
lastErrorAt: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zTournamentParticipant = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
leadership: z.number(),
|
||||
strength: z.number(),
|
||||
intel: z.number(),
|
||||
level: z.number(),
|
||||
groupId: z.number().int().optional(),
|
||||
groupNo: z.number().int().optional(),
|
||||
win: z.number().int().optional(),
|
||||
draw: z.number().int().optional(),
|
||||
lose: z.number().int().optional(),
|
||||
gl: z.number().int().optional(),
|
||||
seedRank: z.number().int().optional(),
|
||||
finalRank: z.number().int().optional(),
|
||||
preliminaryGroupId: z.number().int().optional(),
|
||||
preliminaryGroupNo: z.number().int().optional(),
|
||||
preliminaryRank: z.number().int().optional(),
|
||||
preliminaryWin: z.number().int().optional(),
|
||||
preliminaryDraw: z.number().int().optional(),
|
||||
preliminaryLose: z.number().int().optional(),
|
||||
preliminaryGl: z.number().int().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zTournamentLogEntry = z
|
||||
.object({
|
||||
phase: z.number().int(),
|
||||
attackerEnergy: z.number(),
|
||||
defenderEnergy: z.number(),
|
||||
attackerDamage: z.number(),
|
||||
defenderDamage: z.number(),
|
||||
text: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zTournamentMatch = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
stage: z.number().int(),
|
||||
roundIndex: z.number().int(),
|
||||
attackerId: z.number().int(),
|
||||
defenderId: z.number().int(),
|
||||
groupId: z.number().int().optional(),
|
||||
winnerId: z.number().int().optional(),
|
||||
log: z.array(z.string()).optional(),
|
||||
logEntries: z.array(zTournamentLogEntry).optional(),
|
||||
lastEnergy: z
|
||||
.object({
|
||||
attacker: z.number(),
|
||||
defender: z.number(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T>): T | null => {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
value = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
throw new CorruptTournamentProjectionError(key);
|
||||
}
|
||||
const parsed = schema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new CorruptTournamentProjectionError(key);
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export class TournamentStore {
|
||||
@@ -61,7 +162,11 @@ export class TournamentStore {
|
||||
}
|
||||
|
||||
async getState(): Promise<TournamentState | null> {
|
||||
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
|
||||
return parseProjection(
|
||||
await this.redis.get(this.keys.stateKey),
|
||||
this.keys.stateKey,
|
||||
zTournamentState
|
||||
) as TournamentState | null;
|
||||
}
|
||||
|
||||
async getSourceRevision(): Promise<string | null> {
|
||||
@@ -77,7 +182,13 @@ export class TournamentStore {
|
||||
}
|
||||
|
||||
async getParticipants(): Promise<TournamentParticipantEntry[]> {
|
||||
return safeJsonParse<TournamentParticipantEntry[]>(await this.redis.get(this.keys.participantsKey)) ?? [];
|
||||
return (
|
||||
parseProjection(
|
||||
await this.redis.get(this.keys.participantsKey),
|
||||
this.keys.participantsKey,
|
||||
z.array(zTournamentParticipant)
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async setParticipants(participants: TournamentParticipantEntry[]): Promise<string> {
|
||||
@@ -85,7 +196,13 @@ export class TournamentStore {
|
||||
}
|
||||
|
||||
async getMatches(): Promise<TournamentMatchEntry[]> {
|
||||
return safeJsonParse<TournamentMatchEntry[]>(await this.redis.get(this.keys.matchesKey)) ?? [];
|
||||
return (
|
||||
parseProjection(
|
||||
await this.redis.get(this.keys.matchesKey),
|
||||
this.keys.matchesKey,
|
||||
z.array(zTournamentMatch)
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async setMatches(matches: TournamentMatchEntry[]): Promise<string> {
|
||||
@@ -93,7 +210,13 @@ export class TournamentStore {
|
||||
}
|
||||
|
||||
async getBettingEntries(): Promise<TournamentBetEntry[]> {
|
||||
return safeJsonParse<TournamentBetEntry[]>(await this.redis.get(this.keys.bettingKey)) ?? [];
|
||||
return (
|
||||
parseProjection(
|
||||
await this.redis.get(this.keys.bettingKey),
|
||||
this.keys.bettingKey,
|
||||
z.array(zTournamentBet)
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async setBettingEntries(entries: TournamentBetEntry[]): Promise<string> {
|
||||
|
||||
@@ -241,6 +241,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionOpen',
|
||||
auctionType: 'BUY_RICE',
|
||||
userId: 'user-1',
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
@@ -269,6 +270,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
type: 'auctionOpen',
|
||||
requestId: 'http-auction-open:auction.openBuyRice:engine:0:auctionOpen',
|
||||
auctionType: 'BUY_RICE',
|
||||
userId: 'user-1',
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
@@ -381,6 +383,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionBid',
|
||||
userId: 'user-1',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
|
||||
@@ -108,6 +108,15 @@ const buildContext = (officerLevel = 12, letter: Record<string, unknown> = store
|
||||
findFirst: vi.fn(async () => null),
|
||||
create,
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 6n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-31T00:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
@@ -146,6 +155,7 @@ describe('diplomacy HTML API boundary', () => {
|
||||
textBrief: '<p><strong>공개</strong></p>',
|
||||
textDetail:
|
||||
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
||||
date: new Date('0185-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,16 +10,6 @@ const classifications = {
|
||||
'messages.delete',
|
||||
'messages.respond',
|
||||
'messages.send',
|
||||
'nation.setBill',
|
||||
'nation.setBlockScout',
|
||||
'nation.setBlockWar',
|
||||
'nation.setNotice',
|
||||
'nation.setRate',
|
||||
'nation.setScoutMsg',
|
||||
'nation.setSecretLimit',
|
||||
'npc.setGeneralPriority',
|
||||
'npc.setNationPolicy',
|
||||
'npc.setNationPriority',
|
||||
'turns.repeatGeneral',
|
||||
'turns.setGeneral',
|
||||
'turns.setGeneralBulk',
|
||||
@@ -69,6 +59,16 @@ const classifications = {
|
||||
'nation.appoint',
|
||||
'nation.changePermission',
|
||||
'nation.kick',
|
||||
'nation.setBill',
|
||||
'nation.setBlockScout',
|
||||
'nation.setBlockWar',
|
||||
'nation.setNotice',
|
||||
'nation.setRate',
|
||||
'nation.setScoutMsg',
|
||||
'nation.setSecretLimit',
|
||||
'npc.setGeneralPriority',
|
||||
'npc.setNationPolicy',
|
||||
'npc.setNationPriority',
|
||||
'troop.create',
|
||||
'troop.exit',
|
||||
'troop.join',
|
||||
|
||||
@@ -14,9 +14,9 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
const firstAttempt = new IdempotentTurnDaemonTransport(inner, 'api-event');
|
||||
const retry = new IdempotentTurnDaemonTransport(inner, 'api-event');
|
||||
|
||||
await firstAttempt.sendCommand({ type: 'vacation', generalId: 7 });
|
||||
await firstAttempt.sendCommand({ type: 'dropItem', generalId: 7, itemType: 'weapon' });
|
||||
await retry.sendCommand({ type: 'vacation', generalId: 7 });
|
||||
await firstAttempt.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 });
|
||||
await firstAttempt.sendCommand({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' });
|
||||
await retry.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 });
|
||||
|
||||
expect(inner.commands.map((entry) => entry.requestId)).toEqual([
|
||||
'api-event:engine:0:vacation',
|
||||
@@ -29,6 +29,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
const auctionBid = {
|
||||
type: 'auctionBid',
|
||||
requestId: 'auction-bid',
|
||||
userId: 'user-7',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
@@ -40,6 +41,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
const voteReward = {
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-reward',
|
||||
userId: 'user-7',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
@@ -93,6 +95,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
const persistedPayload = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
userId: 'user-7',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
|
||||
@@ -595,6 +595,7 @@ describe('in-game my information ownership', () => {
|
||||
await caller.general.setMySetting({ tnmt: 1, defence_train: 999 });
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { tnmt: 1, defence_train: 999 },
|
||||
});
|
||||
@@ -622,18 +623,51 @@ describe('in-game my information ownership', () => {
|
||||
requestCommand,
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'setMySetting',
|
||||
requestId: 'http-general-setting:general.setMySetting:engine:0:setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { tnmt: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['horse', 'weapon', 'book', 'item'] as const)(
|
||||
'dispatches the authenticated dropItem command for the %s slot',
|
||||
async (itemType) => {
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
type: 'dropItem' as const,
|
||||
ok: true as const,
|
||||
generalId: 7,
|
||||
}));
|
||||
const fixture = createContext({ requestCommand });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.dropItem({ itemType })).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'dropItem',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects an unknown dropItem slot before dispatching it to ENGINE', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'dropItem' as const, ok: true as const, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).general.dropItem({ itemType: 'armor' as never })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
|
||||
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
|
||||
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
|
||||
|
||||
@@ -32,10 +32,7 @@ const payloadHasGeneral = (payload: unknown, generalId: number): boolean => {
|
||||
const changes = (payload as { changes?: unknown }).changes;
|
||||
return (
|
||||
Array.isArray(changes) &&
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId
|
||||
)
|
||||
changes.some((change) => Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -134,10 +131,7 @@ integration('API input event boundary', () => {
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(wakeSnapshot).toBeDefined();
|
||||
const [event, revision] = (await wakeSnapshot) as [
|
||||
{ status: string },
|
||||
{ revision: bigint },
|
||||
];
|
||||
const [event, revision] = (await wakeSnapshot) as [{ status: string }, { revision: bigint }];
|
||||
expect(event.status).toBe('SUCCEEDED');
|
||||
expect(revision.revision).toBe(1n);
|
||||
expect(redisPublish).not.toHaveBeenCalled();
|
||||
@@ -158,9 +152,7 @@ integration('API input event boundary', () => {
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
await expect(
|
||||
journalBoundaryRouter
|
||||
.createCaller(context)
|
||||
.mutate({ generalId: journalGeneralIds[1], fail: true })
|
||||
journalBoundaryRouter.createCaller(context).mutate({ generalId: journalGeneralIds[1], fail: true })
|
||||
).rejects.toThrow('injected journal rollback');
|
||||
|
||||
await expect(
|
||||
@@ -257,21 +249,24 @@ integration('API input event boundary', () => {
|
||||
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
||||
const requestId = 'integration:api:engine-child';
|
||||
const acceptedWindowStart = Date.now();
|
||||
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
|
||||
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
|
||||
const acceptedWindowEnd = Date.now();
|
||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
expect(event.actorUserId).toBe('user-7');
|
||||
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
|
||||
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
|
||||
ConflictingTurnDaemonCommandError
|
||||
);
|
||||
await expect(
|
||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
||||
).resolves.toBe(requestId);
|
||||
await expect(
|
||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 8 })
|
||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||
});
|
||||
|
||||
it('distinguishes a stored terminal engine failure from a result timeout', async () => {
|
||||
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
||||
const requestId = 'integration:api:engine-failed';
|
||||
const command = { type: 'vacation' as const, requestId, generalId: 7 };
|
||||
const command = { type: 'vacation' as const, requestId, userId: 'user-7', generalId: 7 };
|
||||
await transport.sendCommand(command);
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
|
||||
@@ -73,8 +73,9 @@ const auth: GameSessionTokenPayload = {
|
||||
const buildContext = () => {
|
||||
const changeJournal = new ChangeJournal();
|
||||
const requestCommand = vi.fn(async (command: unknown) => ({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: '2026-01-01T00:00:01.000Z',
|
||||
command,
|
||||
}));
|
||||
@@ -97,6 +98,7 @@ const buildContext = () => {
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
requestId: 'http-nation-html',
|
||||
changeJournal,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
@@ -131,29 +133,59 @@ describe('nation HTML API boundary', () => {
|
||||
});
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNationSetting',
|
||||
requestId: `http-nation-html:nation.${procedure}:engine:0:setNationSetting`,
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
updates: {
|
||||
[metaKey]: msg,
|
||||
},
|
||||
expectedUpdatedAt: undefined,
|
||||
mutation: metaKey === 'notice' ? { kind: 'notice', message: msg } : { kind: 'scoutMessage', message: msg },
|
||||
});
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
...(procedure === 'setNotice' ? [{ domain: 'front.nation' as const, entityId: 1 }] : []),
|
||||
{ domain: 'nation.content', entityId: 1 },
|
||||
]);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['setNotice', 'setScoutMsg'] as const)(
|
||||
'rejects an empty $procedure value like Ref required validation',
|
||||
'rejects empty and whitespace-only $procedure values like Ref required validation',
|
||||
async (procedure) => {
|
||||
await expect(buildContext().caller.nation[procedure]({ msg: '' })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
for (const msg of ['', ' \t\n\v\0']) {
|
||||
const fixture = buildContext();
|
||||
await expect(fixture.caller.nation[procedure]({ msg })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['setNotice', 'setScoutMsg'] as const)(
|
||||
'keeps PHP trim semantics for a Unicode-only $procedure value',
|
||||
async (procedure) => {
|
||||
const fixture = buildContext();
|
||||
await expect(fixture.caller.nation[procedure]({ msg: ' ' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
msg: ' ',
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['setNotice', 'notice'],
|
||||
['setScoutMsg', 'scoutMessage'],
|
||||
] as const)('dispatches unsafe-only $procedure HTML as the empty string Ref persists', async (procedure, kind) => {
|
||||
const fixture = buildContext();
|
||||
|
||||
await expect(fixture.caller.nation[procedure]({ msg: '<script></script>' })).resolves.toEqual({
|
||||
ok: true,
|
||||
msg: '',
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind, message: '' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('purifies legacy stored values on every read resolver', () => {
|
||||
expect(
|
||||
resolveNationNotice({
|
||||
|
||||
@@ -140,9 +140,26 @@ describe('nation personnel router', () => {
|
||||
await caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [9] });
|
||||
|
||||
expect(requestCommand.mock.calls).toEqual([
|
||||
[{ type: 'appoint', generalId: 22, destGeneralId: 7, destCityId: 1, officerLevel: 4 }],
|
||||
[{ type: 'kick', generalId: 22, destGeneralId: 8 }],
|
||||
[{ type: 'changePermission', generalId: 22, isAmbassador: true, targetGeneralIds: [9] }],
|
||||
[
|
||||
{
|
||||
type: 'appoint',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
destGeneralId: 7,
|
||||
destCityId: 1,
|
||||
officerLevel: 4,
|
||||
},
|
||||
],
|
||||
[{ type: 'kick', userId: 'user-22', generalId: 22, destGeneralId: 8 }],
|
||||
[
|
||||
{
|
||||
type: 'changePermission',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [9],
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -160,6 +177,7 @@ describe('nation personnel router', () => {
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'kick',
|
||||
requestId: 'http-nation-kick:nation.kick:engine:0:kick',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
destGeneralId: 8,
|
||||
});
|
||||
@@ -277,7 +295,7 @@ describe('nation personnel router', () => {
|
||||
};
|
||||
const makeCommand = () =>
|
||||
vi.fn(async () => ({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: '2026-01-01T00:01:00.000Z',
|
||||
@@ -291,15 +309,13 @@ describe('nation personnel router', () => {
|
||||
.nation.setRate({ amount: 20 })
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(headCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNationSetting',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
nationId: 1,
|
||||
updates: { rate: 20 },
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'rate', amount: 20 },
|
||||
});
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'dashboard.global', entityId: 0 },
|
||||
{ domain: 'nation.content', entityId: 1 },
|
||||
]);
|
||||
expect(changeJournal.snapshot()).toEqual([]);
|
||||
|
||||
const ambassadorCommand = makeCommand();
|
||||
const ambassador = {
|
||||
@@ -312,6 +328,13 @@ describe('nation personnel router', () => {
|
||||
.createCaller(createContext({ me: ambassador, db: nationDb, requestCommand: ambassadorCommand }))
|
||||
.nation.setRate({ amount: 25 })
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(ambassadorCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationSetting',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
nationId: 1,
|
||||
mutation: { kind: 'rate', amount: 25 },
|
||||
});
|
||||
|
||||
const memberCommand = makeCommand();
|
||||
const member = { ...baseGeneral, officerLevel: 1 };
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const general: GeneralRow = {
|
||||
id: 71,
|
||||
userId: 'authenticated-user',
|
||||
name: '국가설정담당',
|
||||
nationId: 3,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 5,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
lastTurn: {},
|
||||
meta: {},
|
||||
penalty: {},
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||
sessionId: 'session-setting',
|
||||
user: {
|
||||
id: 'authenticated-user',
|
||||
username: 'tester',
|
||||
displayName: 'Tester',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const buildContext = (authenticated = true) => {
|
||||
const requestCommand = vi.fn(async (command: unknown) => {
|
||||
const kind = (command as { mutation?: { kind?: string } }).mutation?.kind;
|
||||
return {
|
||||
type: 'setNationSetting' as const,
|
||||
ok: true as const,
|
||||
nationId: 3,
|
||||
updatedAt: '2026-01-01T00:00:01.000Z',
|
||||
...(kind === 'blockWar' ? { availableCnt: 7 } : {}),
|
||||
};
|
||||
});
|
||||
const transaction = vi.fn(async () => {
|
||||
throw new Error('nation settings must not use an API input-event transaction');
|
||||
});
|
||||
const db = {
|
||||
$transaction: transaction,
|
||||
general: {
|
||||
findFirst: vi.fn(async () => general),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||
},
|
||||
};
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
};
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: authenticated ? auth : null,
|
||||
requestId: 'http-setting-boundary',
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand, transaction };
|
||||
};
|
||||
|
||||
describe('nation setting router engine boundary', () => {
|
||||
it('binds all semantic setting commands to the authenticated actor and a stable request id', async () => {
|
||||
const fixture = buildContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.nation.setNotice({ msg: '<strong>방침</strong>' })).resolves.toEqual({
|
||||
ok: true,
|
||||
msg: '<strong>방침</strong>',
|
||||
});
|
||||
await expect(caller.nation.setScoutMsg({ msg: '<em>등용문</em>' })).resolves.toEqual({
|
||||
ok: true,
|
||||
msg: '<em>등용문</em>',
|
||||
});
|
||||
await expect(caller.nation.setRate({ amount: 30 })).resolves.toEqual({ ok: true });
|
||||
await expect(caller.nation.setBill({ amount: 200 })).resolves.toEqual({ ok: true });
|
||||
await expect(caller.nation.setSecretLimit({ amount: 99 })).resolves.toEqual({ ok: true });
|
||||
await expect(caller.nation.setBlockWar({ value: false })).resolves.toEqual({ availableCnt: 7 });
|
||||
await expect(caller.nation.setBlockScout({ value: true })).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.requestCommand.mock.calls.map(([command]) => command)).toEqual([
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setNotice:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'notice', message: '<strong>방침</strong>' },
|
||||
},
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setScoutMsg:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'scoutMessage', message: '<em>등용문</em>' },
|
||||
},
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setRate:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'rate', amount: 30 },
|
||||
},
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setBill:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'bill', amount: 200 },
|
||||
},
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setSecretLimit:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'secretLimit', amount: 99 },
|
||||
},
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setBlockWar:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'blockWar', value: false },
|
||||
},
|
||||
{
|
||||
type: 'setNationSetting',
|
||||
requestId: 'http-setting-boundary:nation.setBlockScout:engine:0:setNationSetting',
|
||||
userId: 'authenticated-user',
|
||||
generalId: 71,
|
||||
nationId: 3,
|
||||
mutation: { kind: 'blockScout', value: true },
|
||||
},
|
||||
]);
|
||||
expect(fixture.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated calls before querying or dispatching a setting command', async () => {
|
||||
const fixture = buildContext(false);
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).nation.setBill({ amount: 20 })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
expect(fixture.context.db.general.findFirst).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ const createContext = (
|
||||
const requestCommand =
|
||||
options.requestCommand ??
|
||||
vi.fn(async () => ({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: '2026-01-01T00:01:00.000Z',
|
||||
@@ -161,46 +161,43 @@ describe('NPC policy router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => {
|
||||
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
|
||||
const reader = { ...baseGeneral, officerLevel: 2 };
|
||||
const fixture = createContext({ me: reader });
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
type: 'setNpcPolicy' as const,
|
||||
ok: false as const,
|
||||
code: 'FORBIDDEN' as const,
|
||||
reason: '권한이 부족합니다.',
|
||||
nationId: 1,
|
||||
}));
|
||||
const fixture = createContext({ me: reader, requestCommand });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 });
|
||||
await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['군주', { ...baseGeneral, officerLevel: 12 }],
|
||||
['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }],
|
||||
['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }],
|
||||
])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => {
|
||||
])('%s dispatches actor-bound policy intent to the daemon', async (_label, me) => {
|
||||
const fixture = createContext({ me });
|
||||
await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'user-22',
|
||||
generalId: 22,
|
||||
nationId: 1,
|
||||
updates: {
|
||||
npc_nation_policy: expect.objectContaining({
|
||||
priority: ['천도', '천도'],
|
||||
prioritySetter: '정책담당',
|
||||
prioritySetTime: expect.any(String),
|
||||
}),
|
||||
},
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPriority', priority: ['천도', '천도'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => {
|
||||
it('forwards raw policy intent so ENGINE can validate current troop and city state atomically', async () => {
|
||||
const fixture = createContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
@@ -211,43 +208,29 @@ describe('NPC policy router', () => {
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updates: {
|
||||
npc_nation_policy: expect.objectContaining({
|
||||
values: expect.objectContaining({
|
||||
reqNationGold: 0,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: { 101: [1, 2] },
|
||||
}),
|
||||
}),
|
||||
type: 'setNpcPolicy',
|
||||
mutation: {
|
||||
kind: 'nationPolicy',
|
||||
values: {
|
||||
reqNationGold: -100,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: { 101: [1, 2] },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fixture.requestCommand.mockClear();
|
||||
await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => {
|
||||
it('preserves duplicate priority entries in the dispatched intent', async () => {
|
||||
const fixture = createContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']);
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updates: {
|
||||
npc_general_policy: expect.objectContaining({
|
||||
priority: ['출병', '출병', '일반내정'],
|
||||
}),
|
||||
},
|
||||
mutation: { kind: 'generalPriority', priority: ['출병', '출병', '일반내정'] },
|
||||
})
|
||||
);
|
||||
await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => {
|
||||
@@ -262,10 +245,11 @@ describe('NPC policy router', () => {
|
||||
});
|
||||
|
||||
const staleCommand = vi.fn(async () => ({
|
||||
type: 'setNationMeta',
|
||||
type: 'setNpcPolicy',
|
||||
ok: false,
|
||||
code: 'CONFLICT',
|
||||
nationId: 1,
|
||||
reason: 'CONFLICT',
|
||||
reason: '다른 사용자가 정책을 변경했습니다.',
|
||||
}));
|
||||
const stale = createContext({ requestCommand: staleCommand });
|
||||
await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
|
||||
|
||||
describe('HTTP idempotency request IDs', () => {
|
||||
it('is stable for one principal and isolated across users and profiles', () => {
|
||||
const first = scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' });
|
||||
expect(first).toBe(scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' }));
|
||||
expect(first).not.toBe(
|
||||
scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-b' })
|
||||
);
|
||||
expect(first).not.toBe(
|
||||
scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'che', userId: 'user-a' })
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds and neutralizes untrusted header content while omitting blank keys', () => {
|
||||
expect(scopeHttpIdempotencyKey({ rawKey: ' \n\t ', profileId: 'hwe', userId: 'user-a' })).toBeUndefined();
|
||||
const scoped = scopeHttpIdempotencyKey({
|
||||
rawKey: `${'x'.repeat(10_000)}:../../unexpected`,
|
||||
profileId: 'hwe',
|
||||
userId: null,
|
||||
});
|
||||
expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u);
|
||||
expect(scoped).toHaveLength(69);
|
||||
});
|
||||
});
|
||||
@@ -67,8 +67,14 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const buildContext = (blockChangeScout: boolean) => {
|
||||
const requestCommand = vi.fn();
|
||||
const buildContext = () => {
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
type: 'setNationSetting' as const,
|
||||
ok: false as const,
|
||||
code: 'FORBIDDEN' as const,
|
||||
nationId: 1,
|
||||
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
||||
}));
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => general),
|
||||
@@ -77,7 +83,7 @@ const buildContext = (blockChangeScout: boolean) => {
|
||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({ meta: { block_change_scout: blockChangeScout } })),
|
||||
findFirst: vi.fn(async () => ({ meta: { block_change_scout: true } })),
|
||||
},
|
||||
};
|
||||
const redisClient = {
|
||||
@@ -102,14 +108,21 @@ const buildContext = (blockChangeScout: boolean) => {
|
||||
};
|
||||
|
||||
describe('nation scout policy lock', () => {
|
||||
it('rejects policy changes before daemon dispatch while the scenario lock is enabled', async () => {
|
||||
const fixture = buildContext(true);
|
||||
await expect(appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })).rejects.toMatchObject(
|
||||
{
|
||||
code: 'FORBIDDEN',
|
||||
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
||||
}
|
||||
);
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
it('lets the engine recheck the scenario lock and maps its rejection', async () => {
|
||||
const fixture = buildContext();
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationSetting',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
mutation: { kind: 'blockScout', value: false },
|
||||
});
|
||||
expect(fixture.context.db.worldState.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
|
||||
import { createGameApiServer } from '../src/server.js';
|
||||
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
|
||||
|
||||
@@ -41,6 +42,12 @@ const foreignNationId = 99_002;
|
||||
const fixtureNationIds = [ownerNationId, foreignNationId];
|
||||
const fixtureWorldId = 990_001;
|
||||
const mutationRequestPrefix = `security-http-matrix-${process.pid}-`;
|
||||
const matrixApiEventTypes = [
|
||||
'messages.send',
|
||||
'turns.reserved.setGeneral',
|
||||
'turns.reserved.setNation',
|
||||
] as const;
|
||||
const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId];
|
||||
const secret = 'security-http-e2e-secret';
|
||||
const redisPrefix = `sammo:security-http:${process.pid}`;
|
||||
const envKeys = [
|
||||
@@ -303,7 +310,7 @@ const readReservedMutationState = async () => ({
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
engineInputEvents: await db.inputEvent.findMany({
|
||||
where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } },
|
||||
where: { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } },
|
||||
select: { requestId: true, eventType: true, status: true, actorUserId: true },
|
||||
orderBy: { sequence: 'asc' },
|
||||
}),
|
||||
@@ -315,6 +322,31 @@ const readReservedMutationState = async () => ({
|
||||
|
||||
const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`;
|
||||
|
||||
const isMatrixApiInputEvent = (rowJson: string): boolean => {
|
||||
const row = JSON.parse(rowJson) as { target?: unknown; event_type?: unknown };
|
||||
return row.target === 'API' && matrixApiEventTypes.includes(row.event_type as (typeof matrixApiEventTypes)[number]);
|
||||
};
|
||||
|
||||
const deleteMatrixInputEvents = async (): Promise<void> => {
|
||||
await db.inputEvent.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ requestId: { startsWith: mutationRequestPrefix } },
|
||||
{ target: 'API', eventType: { in: [...matrixApiEventTypes] } },
|
||||
{ target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } },
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resolveScopedApiRequestId = (idempotencyKey: string, procedure: string, actorUserId: string): string => {
|
||||
const scopedRequestId = scopeHttpIdempotencyKey({ rawKey: idempotencyKey, profileId, userId: actorUserId });
|
||||
if (!scopedRequestId) {
|
||||
throw new Error('matrix idempotency key unexpectedly resolved to an empty request ID');
|
||||
}
|
||||
return `${scopedRequestId}:${procedure}`;
|
||||
};
|
||||
|
||||
const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
|
||||
const tables = await db.$queryRawUnsafe<Array<{ tableName: string }>>(
|
||||
`SELECT table_name AS "tableName"
|
||||
@@ -326,16 +358,17 @@ const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
|
||||
return Promise.all(
|
||||
tables.map(async ({ tableName }) => {
|
||||
const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`;
|
||||
const matrixApiFilter =
|
||||
tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : '';
|
||||
const rows = await db.$queryRawUnsafe<Array<{ rowJson: string }>>(
|
||||
`SELECT to_jsonb(snapshot_row)::text AS "rowJson"
|
||||
FROM ${qualifiedTable} AS snapshot_row
|
||||
${matrixApiFilter}
|
||||
ORDER BY to_jsonb(snapshot_row)::text`,
|
||||
...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : [])
|
||||
ORDER BY to_jsonb(snapshot_row)::text`
|
||||
);
|
||||
return { tableName, rows: rows.map(({ rowJson }) => rowJson) };
|
||||
return {
|
||||
tableName,
|
||||
rows: rows
|
||||
.map(({ rowJson }) => rowJson)
|
||||
.filter((rowJson) => tableName !== 'input_event' || !isMatrixApiInputEvent(rowJson)),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -389,12 +422,11 @@ const expectApiInputEvent = async (
|
||||
procedure: string,
|
||||
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
|
||||
): Promise<void> => {
|
||||
const requestId = `${idempotencyKey}:${procedure}`;
|
||||
const events = await db.inputEvent.findMany({
|
||||
// beforeEach removes the whole matrix prefix. Query that complete
|
||||
// namespace so an extra/rewritten API journal row cannot hide behind
|
||||
// the full-schema snapshot's one explicitly allowed exclusion.
|
||||
where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } },
|
||||
// The HTTP boundary hashes the raw client key together with profile and
|
||||
// actor. Query the whole procedure matrix so an unexpected extra row
|
||||
// cannot hide behind the full-schema snapshot's explicit exclusion.
|
||||
where: { target: 'API', eventType: { in: [...matrixApiEventTypes] } },
|
||||
select: {
|
||||
requestId: true,
|
||||
target: true,
|
||||
@@ -417,6 +449,7 @@ const expectApiInputEvent = async (
|
||||
expect(events).toEqual([]);
|
||||
return;
|
||||
}
|
||||
const requestId = resolveScopedApiRequestId(idempotencyKey, procedure, expected.actorUserId);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
requestId,
|
||||
@@ -638,7 +671,7 @@ integration('game API security over HTTP transport', () => {
|
||||
afterAll(async () => {
|
||||
await server?.app.close();
|
||||
await closeGatewayStatusStub();
|
||||
await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
|
||||
if (db) await deleteMatrixInputEvents();
|
||||
await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
@@ -670,7 +703,7 @@ integration('game API security over HTTP transport', () => {
|
||||
}, 30_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
|
||||
await deleteMatrixInputEvents();
|
||||
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||
@@ -1055,7 +1088,7 @@ integration('game API security over HTTP transport', () => {
|
||||
});
|
||||
expect(
|
||||
await db.inputEvent.count({
|
||||
where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } },
|
||||
where: { target: 'ENGINE', actorUserId: userId },
|
||||
})
|
||||
).toBe(0);
|
||||
await expect.poll(() => db.readModelOutbox.count()).toBe(1);
|
||||
@@ -1513,8 +1546,9 @@ integration('game API security over HTTP transport', () => {
|
||||
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||
const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||
const replayRedisBefore = await readRealtimeRedisState();
|
||||
const replayRequestId = resolveScopedApiRequestId(idempotencyKey, 'turns.reserved.setNation', userId);
|
||||
const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
|
||||
where: { requestId: replayRequestId },
|
||||
});
|
||||
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||
expect(replay.response.status).toBe(409);
|
||||
@@ -1524,12 +1558,10 @@ integration('game API security over HTTP transport', () => {
|
||||
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
|
||||
where: { requestId: replayRequestId },
|
||||
})
|
||||
).toEqual(replayJournalBefore);
|
||||
expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe(
|
||||
1
|
||||
);
|
||||
expect(await db.inputEvent.count({ where: { requestId: replayRequestId } })).toBe(1);
|
||||
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||
actorUserId: userId,
|
||||
status: 'SUCCEEDED',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTournamentKeys } from '../src/tournament/keys.js';
|
||||
import { TournamentStore } from '../src/tournament/store.js';
|
||||
import { CorruptTournamentProjectionError, TournamentStore } from '../src/tournament/store.js';
|
||||
|
||||
class AtomicMemoryRedis {
|
||||
readonly events: string[] = [];
|
||||
@@ -130,4 +130,114 @@ describe('TournamentStore source revision', () => {
|
||||
await expect(store.getSourceRevision()).resolves.toBe('50');
|
||||
expect(redis.published).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('distinguishes missing projections from malformed JSON and invalid shapes', async () => {
|
||||
const redis = new AtomicMemoryRedis();
|
||||
const keys = buildTournamentKeys('corrupt:default');
|
||||
const store = new TournamentStore(redis, keys);
|
||||
|
||||
await expect(store.getParticipants()).resolves.toEqual([]);
|
||||
await redis.set(keys.participantsKey, '{broken');
|
||||
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
||||
await redis.set(keys.participantsKey, JSON.stringify([{ id: 'not-a-number' }]));
|
||||
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
||||
});
|
||||
|
||||
it('rejects corrupt settlement flags instead of silently skipping tournament settlement', async () => {
|
||||
const redis = new AtomicMemoryRedis();
|
||||
const keys = buildTournamentKeys('corrupt-settlement:default');
|
||||
const store = new TournamentStore(redis, keys);
|
||||
const canonicalState = {
|
||||
stage: 0,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: false,
|
||||
openYear: 185,
|
||||
openMonth: 2,
|
||||
termSeconds: 300,
|
||||
nextAt: '2026-08-17T00:00:00.000Z',
|
||||
bettingId: 123,
|
||||
bettingCloseAt: '2026-08-17T00:05:00.000Z',
|
||||
winnerId: 7,
|
||||
bettingSettled: false,
|
||||
rewardSettled: false,
|
||||
participantsLockedAt: '2026-08-17T00:01:00.000Z',
|
||||
lastError: 'retryable fixture',
|
||||
lastErrorAt: '2026-08-17T00:02:00.000Z',
|
||||
};
|
||||
|
||||
await redis.set(keys.stateKey, JSON.stringify(canonicalState));
|
||||
await expect(store.getState()).resolves.toEqual(canonicalState);
|
||||
|
||||
for (const [field, value] of [
|
||||
['winnerId', '7'],
|
||||
['bettingId', '123'],
|
||||
['rewardSettled', 'yes'],
|
||||
['bettingSettled', 1],
|
||||
] as const) {
|
||||
await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value }));
|
||||
await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates known optional participant and match projection fields', async () => {
|
||||
const redis = new AtomicMemoryRedis();
|
||||
const keys = buildTournamentKeys('corrupt-optional:default');
|
||||
const store = new TournamentStore(redis, keys);
|
||||
const participant = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
leadership: 90,
|
||||
strength: 97,
|
||||
intel: 75,
|
||||
level: 5,
|
||||
groupId: 0,
|
||||
groupNo: 1,
|
||||
win: 2,
|
||||
draw: 1,
|
||||
lose: 0,
|
||||
gl: 10,
|
||||
seedRank: 1,
|
||||
finalRank: 2,
|
||||
preliminaryGroupId: 0,
|
||||
preliminaryGroupNo: 1,
|
||||
preliminaryRank: 2,
|
||||
preliminaryWin: 2,
|
||||
preliminaryDraw: 1,
|
||||
preliminaryLose: 0,
|
||||
preliminaryGl: 10,
|
||||
};
|
||||
const match = {
|
||||
id: 1,
|
||||
stage: 7,
|
||||
roundIndex: 0,
|
||||
groupId: 0,
|
||||
attackerId: 7,
|
||||
defenderId: 8,
|
||||
winnerId: 7,
|
||||
log: ['결과'],
|
||||
logEntries: [
|
||||
{
|
||||
phase: 1,
|
||||
attackerEnergy: 90,
|
||||
defenderEnergy: 0,
|
||||
attackerDamage: 10,
|
||||
defenderDamage: 100,
|
||||
text: '결과',
|
||||
},
|
||||
],
|
||||
lastEnergy: { attacker: 90, defender: 0 },
|
||||
};
|
||||
|
||||
await redis.set(keys.participantsKey, JSON.stringify([participant]));
|
||||
await redis.set(keys.matchesKey, JSON.stringify([match]));
|
||||
await expect(store.getParticipants()).resolves.toEqual([participant]);
|
||||
await expect(store.getMatches()).resolves.toEqual([match]);
|
||||
|
||||
await redis.set(keys.participantsKey, JSON.stringify([{ ...participant, win: '2' }]));
|
||||
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
||||
await redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }]));
|
||||
await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -295,6 +295,7 @@ describe('troop router permissions and mutations', () => {
|
||||
});
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'troopCreate',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopName: '백마대',
|
||||
});
|
||||
@@ -317,11 +318,30 @@ describe('troop router permissions and mutations', () => {
|
||||
expect(requestCommand).toHaveBeenCalledWith({
|
||||
type: 'troopCreate',
|
||||
requestId: 'http-troop-create:troop.create:engine:0:troopCreate',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopName: '백마대',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
|
||||
const fixture = buildContext({
|
||||
result: {
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: 'troopCreate',
|
||||
reason: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => {
|
||||
const assigned = buildContext({
|
||||
me: buildGeneral({ troopId: 9 }),
|
||||
@@ -372,6 +392,7 @@ describe('troop router permissions and mutations', () => {
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(authorized.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'troopKick',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
targetGeneralId: 3,
|
||||
|
||||
@@ -97,6 +97,7 @@ const buildContext = (options: {
|
||||
metaDevelCost?: number;
|
||||
auctionTargets?: string[];
|
||||
clockTick?: number;
|
||||
requestId?: string;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -202,6 +203,7 @@ const buildContext = (options: {
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
@@ -247,13 +249,15 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
|
||||
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
|
||||
const fixture = buildContext({ general: owned, clockTick: 100 });
|
||||
const fixture = buildContext({ general: owned, clockTick: 100, requestId: 'http-vote-submit' });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
||||
).resolves.toEqual({ ok: true, wonLottery: false });
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'voteReward',
|
||||
requestId: 'http-vote-submit:vote.submitVote:engine:0:voteReward',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
"types": "./dist/turn/monthlyNationBettingAction.d.ts",
|
||||
"default": "./dist/turn/monthlyNationBettingAction.js"
|
||||
},
|
||||
"./turn/npcPolicyMutation.js": {
|
||||
"types": "./dist/turn/npcPolicyMutation.d.ts",
|
||||
"default": "./dist/turn/npcPolicyMutation.js"
|
||||
},
|
||||
"./turn/npcPossessionService.js": {
|
||||
"types": "./dist/turn/npcPossessionService.d.ts",
|
||||
"default": "./dist/turn/npcPossessionService.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, isRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogFormat, LogScope, type MessageDraft, type MessagePayload } from '@sammo-ts/logic';
|
||||
|
||||
@@ -15,6 +15,7 @@ interface MessageRow {
|
||||
id: number;
|
||||
mailbox: number;
|
||||
type: string;
|
||||
time: Date;
|
||||
validUntil: Date;
|
||||
message: unknown;
|
||||
}
|
||||
@@ -25,8 +26,31 @@ export interface ActionableMessageResponseResult {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const parsePayload = (value: unknown): MessagePayload =>
|
||||
(typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload;
|
||||
const parsePayload = (value: unknown): MessagePayload | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const payload = asRecord(parsed);
|
||||
const src = asRecord(payload.src);
|
||||
const dest = asRecord(payload.dest);
|
||||
const isTarget = (target: Record<string, unknown>): boolean =>
|
||||
Number.isSafeInteger(target.generalId) &&
|
||||
Number.isSafeInteger(target.nationId) &&
|
||||
typeof target.generalName === 'string' &&
|
||||
typeof target.nationName === 'string' &&
|
||||
typeof target.color === 'string' &&
|
||||
typeof target.icon === 'string';
|
||||
if (!isTarget(src) || !isTarget(dest) || typeof payload.text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (payload.option !== undefined && payload.option !== null && !isRecord(payload.option)) {
|
||||
return null;
|
||||
}
|
||||
return payload as unknown as MessagePayload;
|
||||
};
|
||||
|
||||
const systemTarget: MessageDraft['src'] = {
|
||||
generalId: 0,
|
||||
@@ -37,6 +61,16 @@ const systemTarget: MessageDraft['src'] = {
|
||||
icon: '',
|
||||
};
|
||||
|
||||
const isLegacyTruthy = (value: unknown): boolean => {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const queuePrivateNotice = (
|
||||
world: InMemoryTurnWorld,
|
||||
destination: MessagePayload['dest'],
|
||||
@@ -103,7 +137,7 @@ const fetchMessageForUpdate = async (
|
||||
): Promise<MessageRow | null> => {
|
||||
const currentTick = BigInt(world.dateToGameTick(now));
|
||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||
SELECT id, mailbox, type, valid_until AS "validUntil", message
|
||||
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
||||
FROM message
|
||||
WHERE id = ${messageId}
|
||||
AND (
|
||||
@@ -130,7 +164,7 @@ const respondToScout = async (options: {
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (asRecord(payload.option).used === true) {
|
||||
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
@@ -280,6 +314,7 @@ export const respondToActionableMessage = async (options: {
|
||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||
const payload = parsePayload(row.message);
|
||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||
const action = asRecord(payload.option).action;
|
||||
if (action === 'scout') {
|
||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||
|
||||
@@ -25,6 +25,8 @@ const parseWith = <T>(schema: z.ZodType<T>, value: unknown): T | null => {
|
||||
const zFiniteNumber = z.number().finite();
|
||||
const zSafeInteger = zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER);
|
||||
const zRecord = z.record(z.string(), z.unknown());
|
||||
const zBoundedCodePointText = (maximumCodePoints: number) =>
|
||||
z.string().refine((value) => Array.from(value).length <= maximumCodePoints);
|
||||
|
||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||
const zTurnRunBudget = z.object({
|
||||
@@ -42,6 +44,7 @@ const zAuctionFinalize = z.object({
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
type: z.literal('auctionOpen'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']),
|
||||
amount: zFiniteNumber,
|
||||
@@ -53,6 +56,7 @@ const zAuctionOpen = z.object({
|
||||
|
||||
const zAuctionBid = z.object({
|
||||
type: z.literal('auctionBid'),
|
||||
userId: z.string().min(1),
|
||||
auctionId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
amount: zFiniteNumber,
|
||||
@@ -62,23 +66,27 @@ const zAuctionBid = z.object({
|
||||
|
||||
const zTroopJoin = z.object({
|
||||
type: z.literal('troopJoin'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopCreate = z.object({
|
||||
type: z.literal('troopCreate'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopName: z.string(),
|
||||
});
|
||||
|
||||
const zTroopExit = z.object({
|
||||
type: z.literal('troopExit'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopKick = z.object({
|
||||
type: z.literal('troopKick'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
targetGeneralId: zFiniteNumber,
|
||||
@@ -86,6 +94,7 @@ const zTroopKick = z.object({
|
||||
|
||||
const zTroopRename = z.object({
|
||||
type: z.literal('troopRename'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
troopName: z.string(),
|
||||
@@ -125,11 +134,13 @@ const zMessageRespond = z.object({
|
||||
|
||||
const zVacation = z.object({
|
||||
type: z.literal('vacation'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zSetMySetting = z.object({
|
||||
type: z.literal('setMySetting'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
settings: z.object({
|
||||
tnmt: z.number().int().optional(),
|
||||
@@ -146,25 +157,30 @@ const zSetMySetting = z.object({
|
||||
|
||||
const zDropItem = z.object({
|
||||
type: z.literal('dropItem'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
itemType: z.string().min(1),
|
||||
itemType: z.enum(['horse', 'weapon', 'book', 'item']),
|
||||
});
|
||||
|
||||
const zChangePermission = z.object({
|
||||
type: z.literal('changePermission'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
isAmbassador: z.boolean(),
|
||||
targetGeneralIds: z.array(zFiniteNumber).min(1),
|
||||
// Ref uses an empty selection to clear every holder of the role.
|
||||
targetGeneralIds: z.array(zFiniteNumber),
|
||||
});
|
||||
|
||||
const zKick = z.object({
|
||||
type: z.literal('kick'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
destGeneralId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zAppoint = z.object({
|
||||
type: z.literal('appoint'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
destGeneralId: zFiniteNumber,
|
||||
destCityId: zFiniteNumber,
|
||||
@@ -198,17 +214,42 @@ const zTournamentReward = z.object({
|
||||
|
||||
const zVoteReward = z.object({
|
||||
type: z.literal('voteReward'),
|
||||
userId: z.string().min(1),
|
||||
voteId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
selection: z.array(zFiniteNumber.int()).min(1),
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
});
|
||||
|
||||
const zSetNationMeta = z.object({
|
||||
type: z.literal('setNationMeta'),
|
||||
const zSetNationSetting = z.object({
|
||||
type: z.literal('setNationSetting'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
nationId: zFiniteNumber,
|
||||
updates: zRecord,
|
||||
expectedUpdatedAt: z.string().optional(),
|
||||
mutation: z.discriminatedUnion('kind', [
|
||||
// Ref validates required content before HTML purification. A raw,
|
||||
// non-empty value can therefore become an empty persisted string.
|
||||
z.object({ kind: z.literal('notice'), message: zBoundedCodePointText(16_384) }),
|
||||
z.object({ kind: z.literal('scoutMessage'), message: zBoundedCodePointText(1_000) }),
|
||||
z.object({ kind: z.literal('rate'), amount: z.number().int().min(5).max(30) }),
|
||||
z.object({ kind: z.literal('bill'), amount: z.number().int().min(20).max(200) }),
|
||||
z.object({ kind: z.literal('secretLimit'), amount: z.number().int().min(1).max(99) }),
|
||||
z.object({ kind: z.literal('blockWar'), value: z.boolean() }),
|
||||
z.object({ kind: z.literal('blockScout'), value: z.boolean() }),
|
||||
]),
|
||||
});
|
||||
|
||||
const zSetNpcPolicy = z.object({
|
||||
type: z.literal('setNpcPolicy'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
nationId: zFiniteNumber,
|
||||
expectedUpdatedAt: z.string().nullable(),
|
||||
mutation: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('nationPolicy'), values: zRecord }),
|
||||
z.object({ kind: z.literal('nationPriority'), priority: z.array(z.string()) }),
|
||||
z.object({ kind: z.literal('generalPriority'), priority: z.array(z.string()) }),
|
||||
]),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z.object({
|
||||
@@ -640,8 +681,16 @@ const normalizeVoteReward: CommandNormalizer<'voteReward'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSetNationMeta: CommandNormalizer<'setNationMeta'> = (envelope) => {
|
||||
const command = parseWith(zSetNationMeta, envelope.command);
|
||||
const normalizeSetNationSetting: CommandNormalizer<'setNationSetting'> = (envelope) => {
|
||||
const command = parseWith(zSetNationSetting, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
||||
const command = parseWith(zSetNpcPolicy, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
@@ -804,7 +853,8 @@ const normalizers: CommandNormalizerMap = {
|
||||
tournamentBettingPayout: normalizeTournamentBettingPayout,
|
||||
tournamentReward: normalizeTournamentReward,
|
||||
voteReward: normalizeVoteReward,
|
||||
setNationMeta: normalizeSetNationMeta,
|
||||
setNationSetting: normalizeSetNationSetting,
|
||||
setNpcPolicy: normalizeSetNpcPolicy,
|
||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
|
||||
type SetNationSettingCommand = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>;
|
||||
type SetNationSettingResult = Extract<TurnDaemonCommandResult, { type: 'setNationSetting' }>;
|
||||
|
||||
const MAX_AVAILABLE_WAR_SETTING_COUNT = 10;
|
||||
|
||||
const reject = (
|
||||
code: Extract<SetNationSettingResult, { ok: false }>['code'],
|
||||
reason: string,
|
||||
nationId?: number
|
||||
): SetNationSettingResult => ({
|
||||
type: 'setNationSetting',
|
||||
ok: false,
|
||||
code,
|
||||
reason,
|
||||
...(nationId === undefined ? {} : { nationId }),
|
||||
});
|
||||
|
||||
const readInteger = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readWarSettingRemain = (meta: Record<string, unknown>): number => {
|
||||
const legacy = readInteger(meta.available_war_setting_cnt);
|
||||
const migrated = readInteger(meta.availableWarSettingCnt);
|
||||
// Ref treats an absent counter as zero. The monthly refill creates it;
|
||||
// a missing legacy migration value must not grant ten extra changes.
|
||||
const value = legacy ?? migrated ?? 0;
|
||||
return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_COUNT, value));
|
||||
};
|
||||
|
||||
const isLegacyTruthy = (value: unknown): boolean => {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildRevision = (acceptedAt: Date, requestId: string): string => {
|
||||
const suffix = createHash('sha256').update(requestId).digest('hex').slice(0, 16);
|
||||
return `${acceptedAt.toISOString()}#${suffix}`;
|
||||
};
|
||||
|
||||
export const applyNationSettingMutation = (options: {
|
||||
world: InMemoryTurnWorld;
|
||||
command: SetNationSettingCommand;
|
||||
acceptedAt: Date;
|
||||
}): SetNationSettingResult => {
|
||||
const { world, command, acceptedAt } = options;
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor) {
|
||||
return reject('NOT_FOUND', '장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
if (actor.userId !== command.userId) {
|
||||
return reject('FORBIDDEN', '인증된 사용자와 장수의 소유자가 일치하지 않습니다.');
|
||||
}
|
||||
if (actor.nationId <= 0 || actor.nationId !== command.nationId) {
|
||||
return reject('PRECONDITION_FAILED', '국가에 소속되어있지 않거나 소속 국가가 변경되었습니다.');
|
||||
}
|
||||
|
||||
const nation = world.getNationById(command.nationId);
|
||||
if (!nation) {
|
||||
return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', command.nationId);
|
||||
}
|
||||
const permission = resolveTroopSecretPermission(actor, nation.meta, false);
|
||||
if (permission < 0 || (actor.officerLevel < 5 && permission !== 4)) {
|
||||
return reject('FORBIDDEN', '권한이 부족합니다.', command.nationId);
|
||||
}
|
||||
|
||||
const currentMeta = asRecord(nation.meta);
|
||||
let updates: Record<string, unknown>;
|
||||
let availableCnt: number | undefined;
|
||||
switch (command.mutation.kind) {
|
||||
case 'notice': {
|
||||
const message = command.mutation.message;
|
||||
if (Array.from(message).length > 16_384) {
|
||||
return reject('BAD_REQUEST', '올바른 국가 방침을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = {
|
||||
notice: message,
|
||||
nationNotice: {
|
||||
date: formatServerDateTime(world.getGameNow(acceptedAt)),
|
||||
msg: message,
|
||||
author: actor.name,
|
||||
authorID: actor.id,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'scoutMessage': {
|
||||
const message = command.mutation.message;
|
||||
if (Array.from(message).length > 1_000) {
|
||||
return reject('BAD_REQUEST', '올바른 임관 권유문을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { infoText: message };
|
||||
break;
|
||||
}
|
||||
case 'rate':
|
||||
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 5 || command.mutation.amount > 30) {
|
||||
return reject('BAD_REQUEST', '올바른 세율을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { rate: command.mutation.amount };
|
||||
break;
|
||||
case 'bill':
|
||||
if (
|
||||
!Number.isInteger(command.mutation.amount) ||
|
||||
command.mutation.amount < 20 ||
|
||||
command.mutation.amount > 200
|
||||
) {
|
||||
return reject('BAD_REQUEST', '올바른 지급률을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { bill: command.mutation.amount };
|
||||
break;
|
||||
case 'secretLimit':
|
||||
if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 1 || command.mutation.amount > 99) {
|
||||
return reject('BAD_REQUEST', '올바른 기밀 공개 기준을 입력해주세요.', command.nationId);
|
||||
}
|
||||
updates = { secretlimit: command.mutation.amount };
|
||||
break;
|
||||
case 'blockWar': {
|
||||
const remain = readWarSettingRemain(currentMeta);
|
||||
if (remain <= 0) {
|
||||
return reject('BAD_REQUEST', '잔여 횟수가 부족합니다.', command.nationId);
|
||||
}
|
||||
availableCnt = remain - 1;
|
||||
updates = {
|
||||
war: command.mutation.value ? 1 : 0,
|
||||
available_war_setting_cnt: availableCnt,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'blockScout':
|
||||
if (isLegacyTruthy(asRecord(world.getState().meta).block_change_scout)) {
|
||||
return reject('FORBIDDEN', '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', command.nationId);
|
||||
}
|
||||
updates = { scout: command.mutation.value ? 1 : 0 };
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
|
||||
world.updateNation(command.nationId, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
...updates,
|
||||
_updatedAt: updatedAt,
|
||||
},
|
||||
});
|
||||
return {
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
nationId: command.nationId,
|
||||
updatedAt,
|
||||
...(availableCnt === undefined ? {} : { availableCnt }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
formatServerDateTime,
|
||||
isRecord,
|
||||
type TurnDaemonCommand,
|
||||
type TurnDaemonCommandResult,
|
||||
} from '@sammo-ts/common';
|
||||
import { resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
|
||||
export type NationPolicy = {
|
||||
reqNationGold: number;
|
||||
reqNationRice: number;
|
||||
CombatForce: Record<number, [number, number]>;
|
||||
SupportForce: number[];
|
||||
DevelopForce: number[];
|
||||
reqHumanWarUrgentGold: number;
|
||||
reqHumanWarUrgentRice: number;
|
||||
reqHumanWarRecommandGold: number;
|
||||
reqHumanWarRecommandRice: number;
|
||||
reqHumanDevelGold: number;
|
||||
reqHumanDevelRice: number;
|
||||
reqNPCWarGold: number;
|
||||
reqNPCWarRice: number;
|
||||
reqNPCDevelGold: number;
|
||||
reqNPCDevelRice: number;
|
||||
minimumResourceActionAmount: number;
|
||||
maximumResourceActionAmount: number;
|
||||
minNPCWarLeadership: number;
|
||||
minWarCrew: number;
|
||||
minNPCRecruitCityPopulation: number;
|
||||
safeRecruitCityPopulationRatio: number;
|
||||
properWarTrainAtmos: number;
|
||||
cureThreshold: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_NATION_PRIORITY = [
|
||||
'불가침제의',
|
||||
'선전포고',
|
||||
'천도',
|
||||
'유저장긴급포상',
|
||||
'부대전방발령',
|
||||
'유저장구출발령',
|
||||
'유저장후방발령',
|
||||
'부대유저장후방발령',
|
||||
'유저장전방발령',
|
||||
'유저장포상',
|
||||
'부대구출발령',
|
||||
'부대후방발령',
|
||||
'NPC긴급포상',
|
||||
'NPC구출발령',
|
||||
'NPC후방발령',
|
||||
'NPC포상',
|
||||
'NPC전방발령',
|
||||
'유저장내정발령',
|
||||
'NPC내정발령',
|
||||
'NPC몰수',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_GENERAL_PRIORITY = [
|
||||
'NPC사망대비',
|
||||
'귀환',
|
||||
'금쌀구매',
|
||||
'출병',
|
||||
'긴급내정',
|
||||
'전투준비',
|
||||
'전방워프',
|
||||
'NPC헌납',
|
||||
'징병',
|
||||
'후방워프',
|
||||
'전쟁내정',
|
||||
'소집해제',
|
||||
'일반내정',
|
||||
'내정워프',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_NATION_POLICY: NationPolicy = {
|
||||
reqNationGold: 10000,
|
||||
reqNationRice: 12000,
|
||||
CombatForce: {},
|
||||
SupportForce: [],
|
||||
DevelopForce: [],
|
||||
reqHumanWarUrgentGold: 0,
|
||||
reqHumanWarUrgentRice: 0,
|
||||
reqHumanWarRecommandGold: 0,
|
||||
reqHumanWarRecommandRice: 0,
|
||||
reqHumanDevelGold: 10000,
|
||||
reqHumanDevelRice: 10000,
|
||||
reqNPCWarGold: 0,
|
||||
reqNPCWarRice: 0,
|
||||
reqNPCDevelGold: 0,
|
||||
reqNPCDevelRice: 500,
|
||||
minimumResourceActionAmount: 1000,
|
||||
maximumResourceActionAmount: 10000,
|
||||
minNPCWarLeadership: 40,
|
||||
minWarCrew: 1500,
|
||||
minNPCRecruitCityPopulation: 50000,
|
||||
safeRecruitCityPopulationRatio: 0.5,
|
||||
properWarTrainAtmos: 90,
|
||||
cureThreshold: 10,
|
||||
};
|
||||
|
||||
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
|
||||
|
||||
const INTEGER_POLICY_KEYS = [
|
||||
'reqNationGold',
|
||||
'reqNationRice',
|
||||
'reqHumanWarUrgentGold',
|
||||
'reqHumanWarUrgentRice',
|
||||
'reqHumanWarRecommandGold',
|
||||
'reqHumanWarRecommandRice',
|
||||
'reqHumanDevelGold',
|
||||
'reqHumanDevelRice',
|
||||
'reqNPCWarGold',
|
||||
'reqNPCWarRice',
|
||||
'reqNPCDevelGold',
|
||||
'reqNPCDevelRice',
|
||||
'minimumResourceActionAmount',
|
||||
'maximumResourceActionAmount',
|
||||
'minNPCWarLeadership',
|
||||
'minWarCrew',
|
||||
'minNPCRecruitCityPopulation',
|
||||
'properWarTrainAtmos',
|
||||
'cureThreshold',
|
||||
] as const satisfies ReadonlyArray<keyof NationPolicy>;
|
||||
|
||||
const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const satisfies ReadonlyArray<keyof NationPolicy>;
|
||||
const INTEGER_POLICY_KEY_SET = new Set<string>(INTEGER_POLICY_KEYS);
|
||||
const FLOAT_POLICY_KEY_SET = new Set<string>(FLOAT_POLICY_KEYS);
|
||||
|
||||
type SetNpcPolicyCommand = Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>;
|
||||
type SetNpcPolicyResult = Extract<TurnDaemonCommandResult, { type: 'setNpcPolicy' }>;
|
||||
|
||||
const reject = (
|
||||
code: Extract<SetNpcPolicyResult, { ok: false }>['code'],
|
||||
reason: string,
|
||||
extra: Pick<Extract<SetNpcPolicyResult, { ok: false }>, 'nationId' | 'currentUpdatedAt'> = {}
|
||||
): SetNpcPolicyResult => ({ type: 'setNpcPolicy', ok: false, code, reason, ...extra });
|
||||
|
||||
const buildRevision = (acceptedAt: Date, requestId: string): string => {
|
||||
const suffix = createHash('sha256').update(requestId).digest('hex').slice(0, 16);
|
||||
return `${acceptedAt.toISOString()}#${suffix}`;
|
||||
};
|
||||
|
||||
const validateGeneralPriority = (priority: readonly string[]): string | null => {
|
||||
const mustHave = new Set(['출병', '일반내정']);
|
||||
const orderMap = new Map<string, number>();
|
||||
|
||||
for (const item of priority) {
|
||||
if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) {
|
||||
return `${item}은 올바른 명령이 아닙니다.`;
|
||||
}
|
||||
mustHave.delete(item);
|
||||
// Ref uses the count of distinct keys at each assignment. Updating an
|
||||
// existing key therefore advances it after the currently known keys.
|
||||
orderMap.set(item, orderMap.size);
|
||||
}
|
||||
|
||||
const sortieIndex = orderMap.get('출병');
|
||||
const domesticIndex = orderMap.get('일반내정');
|
||||
if (sortieIndex !== undefined && domesticIndex !== undefined && sortieIndex > domesticIndex) {
|
||||
return '출병 명령은 일반내정 명령보다 먼저여야 합니다.';
|
||||
}
|
||||
if (mustHave.size > 0) {
|
||||
return `${mustHave.values().next().value}은 항상 사용해야 합니다.`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyNationPolicyValues = (
|
||||
world: InMemoryTurnWorld,
|
||||
nationId: number,
|
||||
currentValues: Record<string, unknown>,
|
||||
values: Record<string, unknown>
|
||||
): { values?: Record<string, unknown>; error?: string } => {
|
||||
if (Object.keys(values).length === 0) {
|
||||
return { error: '올바른 입력이 아닙니다.' };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
return { error: `${key}는 올바른 정책값이 아닙니다.` };
|
||||
}
|
||||
}
|
||||
|
||||
// Ref persists only the supplied delta on top of the existing nation
|
||||
// overrides. Materialising defaults here would freeze later server-policy
|
||||
// changes and is not equivalent to j_set_npc_control.php.
|
||||
const nextValues = { ...currentValues };
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!Object.hasOwn(values, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = values[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
return { error: `${key}는 올바른 값이 아닙니다.` };
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
}
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!Object.hasOwn(values, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = values[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return { error: `${key}는 올바른 값이 아닙니다.` };
|
||||
}
|
||||
// Ref clamps negative integers but deliberately leaves floating-point
|
||||
// ratios unchanged.
|
||||
nextValues[key] = value;
|
||||
}
|
||||
|
||||
const troopIds = new Set(
|
||||
world
|
||||
.listTroops()
|
||||
.filter((troop) => troop.nationId === nationId)
|
||||
.map((troop) => troop.id)
|
||||
);
|
||||
const assigned = new Set<number>();
|
||||
|
||||
if (Object.hasOwn(values, 'CombatForce')) {
|
||||
const rawCombat = values.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
return { error: 'CombatForce는 올바른 정책값이 아닙니다.' };
|
||||
}
|
||||
for (const [rawLeaderId, rawTarget] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawLeaderId);
|
||||
if (!Number.isSafeInteger(leaderId) || !troopIds.has(leaderId)) {
|
||||
return { error: `${rawLeaderId}는 국가의 부대가 아닙니다.` };
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
return { error: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.` };
|
||||
}
|
||||
if (!Array.isArray(rawTarget) || rawTarget.length !== 2) {
|
||||
return { error: `${leaderId}의 입력양식이 올바르지 않습니다.` };
|
||||
}
|
||||
// Ref j_set_npc_control.php accidentally destructures the complete
|
||||
// troop-role cache instead of this rawTarget. Positive troop IDs
|
||||
// therefore leave indexes 0/1 undefined and every non-empty
|
||||
// CombatForce is rejected with this observable empty-city error.
|
||||
// Preserve that legacy bug until a separately approved contract
|
||||
// change fixes Ref and Core together.
|
||||
return { error: `${leaderId}의 도시 , 가 올바른 도시 번호가 아닙니다.` };
|
||||
}
|
||||
nextValues.CombatForce = {};
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!Object.hasOwn(values, key)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = values[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
return { error: `${key}는 올바른 정책값이 아닙니다.` };
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawLeaderId of rawList) {
|
||||
const leaderId = Number(rawLeaderId);
|
||||
if (!Number.isSafeInteger(leaderId) || !troopIds.has(leaderId)) {
|
||||
return { error: `${String(rawLeaderId)}는 국가의 부대가 아닙니다.` };
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
return { error: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.` };
|
||||
}
|
||||
assigned.add(leaderId);
|
||||
list.push(leaderId);
|
||||
}
|
||||
nextValues[key] = list;
|
||||
}
|
||||
|
||||
// Numeric keys have already been handled. The three role keys are handled
|
||||
// above, leaving no accepted policy key unprocessed.
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (
|
||||
!INTEGER_POLICY_KEY_SET.has(key) &&
|
||||
!FLOAT_POLICY_KEY_SET.has(key) &&
|
||||
!['CombatForce', 'SupportForce', 'DevelopForce'].includes(key)
|
||||
) {
|
||||
nextValues[key] = value;
|
||||
}
|
||||
}
|
||||
return { values: nextValues };
|
||||
};
|
||||
|
||||
export const applyNpcPolicyMutation = (options: {
|
||||
world: InMemoryTurnWorld;
|
||||
command: SetNpcPolicyCommand;
|
||||
acceptedAt: Date;
|
||||
}): SetNpcPolicyResult => {
|
||||
const { world, command, acceptedAt } = options;
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor) {
|
||||
return reject('NOT_FOUND', '장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
if (actor.userId !== command.userId) {
|
||||
return reject('FORBIDDEN', '인증된 사용자와 장수의 소유자가 일치하지 않습니다.');
|
||||
}
|
||||
if (actor.nationId <= 0 || actor.nationId !== command.nationId) {
|
||||
return reject('PRECONDITION_FAILED', '국가에 소속되어있지 않거나 소속 국가가 변경되었습니다.');
|
||||
}
|
||||
|
||||
const nation = world.getNationById(command.nationId);
|
||||
if (!nation) {
|
||||
return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', { nationId: command.nationId });
|
||||
}
|
||||
if (resolveTroopSecretPermission(actor, nation.meta, true) < 3) {
|
||||
return reject('FORBIDDEN', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', {
|
||||
nationId: command.nationId,
|
||||
});
|
||||
}
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const currentUpdatedAt =
|
||||
typeof nationMeta._npcPolicyUpdatedAt === 'string'
|
||||
? nationMeta._npcPolicyUpdatedAt
|
||||
: typeof nationMeta._updatedAt === 'string'
|
||||
? nationMeta._updatedAt
|
||||
: null;
|
||||
if (command.expectedUpdatedAt !== currentUpdatedAt) {
|
||||
return reject('CONFLICT', '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.', {
|
||||
nationId: command.nationId,
|
||||
currentUpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const gameNow = formatServerDateTime(world.getGameNow(acceptedAt));
|
||||
let updates: Record<string, unknown>;
|
||||
if (command.mutation.kind === 'nationPolicy') {
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const applied = applyNationPolicyValues(
|
||||
world,
|
||||
command.nationId,
|
||||
asRecord(policyRoot.values),
|
||||
command.mutation.values
|
||||
);
|
||||
if (!applied.values) {
|
||||
return reject('BAD_REQUEST', applied.error ?? '올바른 입력이 아닙니다.', { nationId: command.nationId });
|
||||
}
|
||||
updates = {
|
||||
npc_nation_policy: {
|
||||
...policyRoot,
|
||||
values: applied.values,
|
||||
valueSetter: actor.name,
|
||||
valueSetTime: gameNow,
|
||||
},
|
||||
};
|
||||
} else if (command.mutation.kind === 'nationPriority') {
|
||||
if (command.mutation.priority.length === 0) {
|
||||
return reject('BAD_REQUEST', '올바른 입력이 아닙니다.', { nationId: command.nationId });
|
||||
}
|
||||
for (const item of command.mutation.priority) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
return reject('BAD_REQUEST', `${item}은 올바른 명령이 아닙니다.`, { nationId: command.nationId });
|
||||
}
|
||||
}
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
updates = {
|
||||
npc_nation_policy: {
|
||||
...policyRoot,
|
||||
priority: [...command.mutation.priority],
|
||||
prioritySetter: actor.name,
|
||||
prioritySetTime: gameNow,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const validationError = validateGeneralPriority(command.mutation.priority);
|
||||
if (validationError) {
|
||||
return reject('BAD_REQUEST', validationError, { nationId: command.nationId });
|
||||
}
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
updates = {
|
||||
npc_general_policy: {
|
||||
...policyRoot,
|
||||
priority: [...command.mutation.priority],
|
||||
prioritySetter: actor.name,
|
||||
prioritySetTime: gameNow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// input_event timestamps have millisecond precision, so two independent
|
||||
// accepted commands can share the same time. Include the durable request
|
||||
// identity to keep the strict CAS token unique.
|
||||
const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`);
|
||||
world.updateNation(command.nationId, {
|
||||
meta: {
|
||||
...nation.meta,
|
||||
...updates,
|
||||
// Keep the policy CAS independent from notice/tax/scout settings.
|
||||
// The legacy shared _updatedAt remains a one-time migration fallback.
|
||||
_npcPolicyUpdatedAt: updatedAt,
|
||||
},
|
||||
});
|
||||
return { type: 'setNpcPolicy', ok: true, nationId: command.nationId, updatedAt };
|
||||
};
|
||||
@@ -9,6 +9,11 @@ const readNumber = (value: unknown): number => {
|
||||
const readTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||
|
||||
const readArchiveText = (values: readonly unknown[], fallback: string | null): string | null => {
|
||||
const value = values.find((candidate) => candidate !== undefined && candidate !== null);
|
||||
return value === undefined ? fallback : String(value);
|
||||
};
|
||||
|
||||
export const buildOldNationArchiveData = (options: {
|
||||
nation: Nation;
|
||||
generalIds: readonly number[];
|
||||
@@ -22,6 +27,7 @@ export const buildOldNationArchiveData = (options: {
|
||||
...maxPower,
|
||||
};
|
||||
const maxCities = readTextArray(maxPower.maxCities);
|
||||
const nationNotice = asRecord(meta.nationNotice);
|
||||
|
||||
return {
|
||||
...nation,
|
||||
@@ -34,5 +40,7 @@ export const buildOldNationArchiveData = (options: {
|
||||
aux,
|
||||
generals: [...options.generalIds],
|
||||
history: [...options.history],
|
||||
msg: readArchiveText([meta.notice, nationNotice.msg, meta.msg], ''),
|
||||
scout_msg: readArchiveText([meta.infoText, meta.scout_msg], null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2541,7 +2541,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
const failureText =
|
||||
definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ??
|
||||
`${reason} ${definition.name} 실패.`;
|
||||
if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') {
|
||||
if (input.actionKey === 'che_접경귀환') {
|
||||
options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime);
|
||||
}
|
||||
return { ok: false, reason: failureText };
|
||||
@@ -2671,10 +2671,16 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
nextGeneral = applyLegacyGeneralProgression(
|
||||
{
|
||||
...nextGeneral,
|
||||
lastTurn: {
|
||||
command: definition.name,
|
||||
arg: extractArgsRecord(args),
|
||||
},
|
||||
// Ref's recruitment-letter acceptance is an immediate
|
||||
// side action and never replaces the receiver's
|
||||
// reserved-command repetition state.
|
||||
lastTurn:
|
||||
input.actionKey === 'che_등용수락'
|
||||
? general.lastTurn
|
||||
: {
|
||||
command: definition.name,
|
||||
arg: extractArgsRecord(args),
|
||||
},
|
||||
},
|
||||
general,
|
||||
input.actionKey,
|
||||
|
||||
@@ -962,6 +962,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
scenarioMeta: snapshot.scenarioMeta,
|
||||
map: snapshot.map,
|
||||
commandProfile,
|
||||
generalActionModules: monthlyActionModules.general,
|
||||
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
|
||||
auctionFinalizer: auctionFinalizer ?? undefined,
|
||||
auctionBidder: auctionBidder ?? undefined,
|
||||
|
||||
@@ -496,15 +496,12 @@ export const persistUnificationFinalization = async (
|
||||
const cityCount = cities.filter((city) => city.nationId === input.winnerNationId).length;
|
||||
const totalPop = cities.reduce((sum, city) => sum + city.population, 0);
|
||||
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
|
||||
const winnerMeta = asRecord(winner.meta);
|
||||
const winnerData = {
|
||||
...buildOldNationArchiveData({
|
||||
nation: winner,
|
||||
generalIds: winnerGenerals.map((general) => general.id),
|
||||
history: nationHistory,
|
||||
}),
|
||||
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
|
||||
scout_msg: String(winnerMeta.scout_msg ?? ''),
|
||||
generationKey: input.generationKey,
|
||||
};
|
||||
await transaction.oldNation.upsert({
|
||||
|
||||
@@ -22,20 +22,24 @@ import {
|
||||
buildVoteUniqueSeed,
|
||||
countOccupiedUniqueItems,
|
||||
createItemModuleRegistry,
|
||||
GeneralActionPipeline,
|
||||
isDefenceTrainPenaltyWaivedByScenarioEffect,
|
||||
isValidTroopNameWidth,
|
||||
loadItemModules,
|
||||
normalizeTroopName,
|
||||
resolveTroopSecretPermission,
|
||||
resolveMessageTargetIcon,
|
||||
type GeneralActionModule,
|
||||
resolveUniqueConfig,
|
||||
rollUniqueLottery,
|
||||
type ItemModule,
|
||||
type LogEntryDraft,
|
||||
type MapDefinition,
|
||||
type ScenarioMeta,
|
||||
type TriggerValue,
|
||||
type TurnCommandProfile,
|
||||
} from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import { round as roundLegacyInteger, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import {
|
||||
cloneItemInventory,
|
||||
ensureItemInventory,
|
||||
@@ -45,7 +49,12 @@ import {
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
import { createImmediateGeneralActionExecutor, type ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
|
||||
import {
|
||||
applyLegacyGeneralProgression,
|
||||
createImmediateGeneralActionExecutor,
|
||||
type ImmediateGeneralActionExecutor,
|
||||
} from './reservedTurnHandler.js';
|
||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||
import { openAuction } from '../auction/opener.js';
|
||||
import {
|
||||
hasScenarioStaticEventHandler,
|
||||
@@ -63,6 +72,8 @@ import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js
|
||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
||||
import { executeInheritanceAction } from './inheritanceActionService.js';
|
||||
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
|
||||
import { applyNationSettingMutation } from './nationSettingMutation.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -140,6 +151,7 @@ interface CommandHandlerContext {
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
getImmediateGeneralActionExecutor?: () => Promise<ImmediateGeneralActionExecutor>;
|
||||
reservedTurns?: InMemoryReservedTurnStore;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule>;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
}
|
||||
|
||||
@@ -150,6 +162,72 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
return ctx.commandDb as unknown as DatabaseClient;
|
||||
};
|
||||
|
||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||
'troopCreate',
|
||||
'troopJoin',
|
||||
'troopExit',
|
||||
'troopKick',
|
||||
'troopRename',
|
||||
'vacation',
|
||||
'setMySetting',
|
||||
'dropItem',
|
||||
'auctionOpen',
|
||||
'auctionBid',
|
||||
'changePermission',
|
||||
'kick',
|
||||
'appoint',
|
||||
'voteReward',
|
||||
] as const satisfies readonly TurnDaemonCommand['type'][];
|
||||
|
||||
type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number];
|
||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPES = new Set<ActorBoundGeneralCommandType>(ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST);
|
||||
|
||||
type ActorBoundGeneralCommand = Extract<TurnDaemonCommand, { type: ActorBoundGeneralCommandType }>;
|
||||
|
||||
const isActorBoundGeneralCommand = (command: TurnDaemonCommand): command is ActorBoundGeneralCommand =>
|
||||
ACTOR_BOUND_GENERAL_COMMAND_TYPES.has(command.type as ActorBoundGeneralCommandType);
|
||||
|
||||
const rejectActorBoundGeneralCommand = (
|
||||
command: ActorBoundGeneralCommand,
|
||||
reason: string
|
||||
): TurnDaemonCommandResult => ({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
reason,
|
||||
});
|
||||
|
||||
const validateActorBoundGeneralCommand = async (
|
||||
ctx: CommandHandlerContext,
|
||||
command: ActorBoundGeneralCommand
|
||||
): Promise<TurnDaemonCommandResult | null> => {
|
||||
if (!ctx.commandDb) {
|
||||
return null;
|
||||
}
|
||||
if (!command.requestId) {
|
||||
return rejectActorBoundGeneralCommand(command, '인증 명령의 요청 ID가 없습니다.');
|
||||
}
|
||||
|
||||
const event = await ctx.commandDb.inputEvent.findUnique({
|
||||
where: { requestId: command.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true },
|
||||
});
|
||||
if (
|
||||
!event ||
|
||||
event.actorUserId !== command.userId ||
|
||||
event.target !== 'ENGINE' ||
|
||||
event.eventType !== command.type
|
||||
) {
|
||||
return rejectActorBoundGeneralCommand(command, '인증 명령의 입력 이벤트가 일치하지 않습니다.');
|
||||
}
|
||||
|
||||
const general = ctx.world.getGeneralById(command.generalId);
|
||||
if (!general || general.userId !== command.userId) {
|
||||
return rejectActorBoundGeneralCommand(command, '명령 수행 장수의 현재 소유자가 일치하지 않습니다.');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveCommandAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Extract<
|
||||
@@ -164,7 +242,9 @@ const resolveCommandAcceptedAt = async (
|
||||
| 'selectPoolCreate'
|
||||
| 'selectPoolReselect'
|
||||
| 'adjustGeneralIcon'
|
||||
| 'inheritanceAction';
|
||||
| 'inheritanceAction'
|
||||
| 'setNationSetting'
|
||||
| 'setNpcPolicy';
|
||||
}
|
||||
>
|
||||
): Promise<Date> => {
|
||||
@@ -504,49 +584,22 @@ interface TournamentRewardFinalizer {
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
}
|
||||
|
||||
async function handleSetNationMeta(
|
||||
async function handleSetNationSetting(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }>
|
||||
command: Extract<TurnDaemonCommand, { type: 'setNationSetting' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const nation = world.getNationById(command.nationId);
|
||||
if (!nation) {
|
||||
return {
|
||||
type: 'setNationMeta',
|
||||
ok: false,
|
||||
nationId: command.nationId,
|
||||
reason: '국가 정보를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
return applyNationSettingMutation({ world: ctx.world, command, acceptedAt });
|
||||
}
|
||||
|
||||
const meta = (nation.meta ?? {}) as Record<string, unknown>;
|
||||
const currentUpdatedAt = typeof meta._updatedAt === 'string' ? meta._updatedAt : undefined;
|
||||
if (command.expectedUpdatedAt && currentUpdatedAt && command.expectedUpdatedAt !== currentUpdatedAt) {
|
||||
return {
|
||||
type: 'setNationMeta',
|
||||
ok: false,
|
||||
nationId: command.nationId,
|
||||
reason: 'CONFLICT',
|
||||
currentUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const nextMeta = {
|
||||
...meta,
|
||||
...command.updates,
|
||||
_updatedAt: updatedAt,
|
||||
};
|
||||
|
||||
world.updateNation(command.nationId, {
|
||||
meta: nextMeta,
|
||||
});
|
||||
return {
|
||||
type: 'setNationMeta',
|
||||
ok: true,
|
||||
nationId: command.nationId,
|
||||
updatedAt,
|
||||
};
|
||||
async function handleSetNpcPolicy(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
||||
}
|
||||
|
||||
async function handleAdjustGeneralResources(
|
||||
@@ -1832,10 +1885,15 @@ async function handleDropItem(
|
||||
if (!general) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType);
|
||||
if (!slot || !general.role.items[slot]) {
|
||||
const slot = command.itemType;
|
||||
const itemKey = general.role.items[slot];
|
||||
if (!itemKey) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' };
|
||||
}
|
||||
const item = (await getItemRegistry()).get(itemKey);
|
||||
if (!item) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
const nextGeneral = {
|
||||
...general,
|
||||
role: { ...general.role, items: { ...general.role.items } },
|
||||
@@ -1847,6 +1905,34 @@ async function handleDropItem(
|
||||
role: nextGeneral.role,
|
||||
itemInventory: nextGeneral.itemInventory,
|
||||
});
|
||||
|
||||
const josaUl = JosaUtil.pick(item.rawName, '을');
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: `<C>${item.name}</>${josaUl} 버렸습니다.`,
|
||||
generalId: general.id,
|
||||
meta: {},
|
||||
});
|
||||
if (!item.buyable) {
|
||||
const nationName = world.getNationById(general.nationId)?.name ?? '재야';
|
||||
const josaYi = JosaUtil.pick(general.name, '이');
|
||||
world.pushLog({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: `<Y>${general.name}</>${josaYi} <C>${item.name}</>${josaUl} 잃었습니다!`,
|
||||
meta: {},
|
||||
});
|
||||
world.pushLog({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
text: `<R><b>【망실】</b></><D><b>${nationName}</b></>의 <Y>${general.name}</>${josaYi} <C>${item.name}</>${josaUl} 잃었습니다!`,
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
return { type: 'dropItem', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -1975,6 +2061,9 @@ async function handleKick(
|
||||
command: Extract<TurnDaemonCommand, { type: 'kick' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const operationalAcceptedAt = ctx.commandDb
|
||||
? await resolveOperationalAcceptedAt(ctx.commandDb, command)
|
||||
: new Date();
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
@@ -2043,14 +2132,38 @@ async function handleKick(
|
||||
const worldState = world.getState();
|
||||
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
|
||||
const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear);
|
||||
let nextExperience = target.experience;
|
||||
let nextDedication = target.dedication;
|
||||
let applyProgression = false;
|
||||
if (worldState.currentYear > startYear || target.npcState >= 2) {
|
||||
const betray = Math.max(0, readMetaNumber(targetMeta, 'betray', 0));
|
||||
const maxBetrayCnt = readMetaNumber(config, 'maxBetrayCnt', 9);
|
||||
const detachedTarget: TurnGeneral = {
|
||||
...target,
|
||||
nationId: 0,
|
||||
officerLevel: 0,
|
||||
troopId: 0,
|
||||
meta: nextMeta,
|
||||
};
|
||||
const pipeline = new GeneralActionPipeline(ctx.generalActionModules ?? []);
|
||||
const actionContext = {
|
||||
general: detachedTarget,
|
||||
nation: null,
|
||||
worldView: world,
|
||||
time: {
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
startYear,
|
||||
},
|
||||
};
|
||||
nextExperience = roundLegacyInteger(
|
||||
target.experience + pipeline.onCalcStat(actionContext, 'experience', -target.experience * 0.15 * betray)
|
||||
);
|
||||
nextDedication = roundLegacyInteger(
|
||||
target.dedication + pipeline.onCalcStat(actionContext, 'dedication', -target.dedication * 0.15 * betray)
|
||||
);
|
||||
nextMeta.betray = Math.min(maxBetrayCnt, betray + 1);
|
||||
world.updateGeneral(target.id, {
|
||||
experience: Math.max(0, Math.floor(target.experience - target.experience * 0.15 * betray)),
|
||||
dedication: Math.max(0, Math.floor(target.dedication - target.dedication * 0.15 * betray)),
|
||||
});
|
||||
applyProgression = true;
|
||||
} else {
|
||||
nextMeta.makelimit = targetMeta.makelimit ?? 12;
|
||||
}
|
||||
@@ -2070,14 +2183,28 @@ async function handleKick(
|
||||
world.removeTroop(target.id);
|
||||
}
|
||||
|
||||
world.updateGeneral(command.destGeneralId, {
|
||||
const detachedTarget: TurnGeneral = {
|
||||
...target,
|
||||
nationId: 0,
|
||||
officerLevel: 0,
|
||||
troopId: 0,
|
||||
gold: Math.min(target.gold, defaultGold),
|
||||
rice: Math.min(target.rice, defaultRice),
|
||||
experience: nextExperience,
|
||||
dedication: nextDedication,
|
||||
meta: nextMeta,
|
||||
});
|
||||
};
|
||||
const progressionLogs: LogEntryDraft[] = [];
|
||||
const nextTarget = applyProgression
|
||||
? applyLegacyGeneralProgression(
|
||||
detachedTarget,
|
||||
target,
|
||||
'kick',
|
||||
buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||
progressionLogs
|
||||
)
|
||||
: detachedTarget;
|
||||
world.updateGeneral(command.destGeneralId, nextTarget);
|
||||
const nationMeta =
|
||||
worldState.currentYear >= startYear + 3
|
||||
? setOfficerLock(nation.meta, 'chief_set', general.officerLevel)
|
||||
@@ -2098,6 +2225,17 @@ async function handleKick(
|
||||
text: `<Y>${target.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>추방</>당했습니다.`,
|
||||
meta: {},
|
||||
});
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
text: `<D><b>${nation.name}</b></>에서 <R>추방</>당했습니다.`,
|
||||
generalId: target.id,
|
||||
meta: {},
|
||||
});
|
||||
for (const log of progressionLogs) {
|
||||
world.pushLog(log);
|
||||
}
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
@@ -2125,7 +2263,7 @@ async function handleKick(
|
||||
const text = rng.choice([
|
||||
'날 버리다니... 곧 전장에서 복수해주겠다...',
|
||||
'추방이라... 내가 무얼 잘못했단 말인가...',
|
||||
'어디 추방해가면서 잘되나 보자... 꼭 복수하겠다.',
|
||||
'어디 추방해가면서 잘되나 보자... 꼭 복수하겠다...',
|
||||
'인덕이 제일이거늘... 추방이 웬말인가... 저주한다!',
|
||||
'날 추방했으니 그 복수로 적국에 정보를 팔아 넘겨야겠군요. 그럼 이만.',
|
||||
]);
|
||||
@@ -2135,14 +2273,14 @@ async function handleKick(
|
||||
nationId: nation.id,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: target.picture === null ? '' : String(target.picture),
|
||||
icon: resolveMessageTargetIcon(target),
|
||||
};
|
||||
world.queueMessage({
|
||||
msgType: 'public',
|
||||
src: messageTarget,
|
||||
dest: messageTarget,
|
||||
text,
|
||||
time: world.getGameNow(new Date()),
|
||||
time: world.getGameNow(operationalAcceptedAt),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
});
|
||||
@@ -2873,6 +3011,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
scenarioMeta?: ScenarioMeta;
|
||||
map?: MapDefinition;
|
||||
commandProfile?: TurnCommandProfile;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule>;
|
||||
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
||||
auctionFinalizer?: AuctionFinalizer;
|
||||
auctionBidder?: AuctionBidder;
|
||||
@@ -2886,6 +3025,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
auctionBidder: options.auctionBidder,
|
||||
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
|
||||
reservedTurns: options.reservedTurns,
|
||||
generalActionModules: options.generalActionModules,
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
getImmediateGeneralActionExecutor: () => {
|
||||
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
|
||||
@@ -2961,8 +3101,10 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
tournamentReward: (command) =>
|
||||
handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
|
||||
voteReward: (command) => handleVoteReward(ctx, command as Extract<TurnDaemonCommand, { type: 'voteReward' }>),
|
||||
setNationMeta: (command) =>
|
||||
handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
|
||||
setNationSetting: (command) =>
|
||||
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
||||
setNpcPolicy: (command) =>
|
||||
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
|
||||
adjustGeneralResources: (command) =>
|
||||
handleAdjustGeneralResources(
|
||||
ctx,
|
||||
@@ -3005,6 +3147,12 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
}
|
||||
ctx.commandDb = executionContext?.db;
|
||||
try {
|
||||
if (isActorBoundGeneralCommand(command)) {
|
||||
const rejected = await validateActorBoundGeneralCommand(ctx, command);
|
||||
if (rejected) {
|
||||
return rejected;
|
||||
}
|
||||
}
|
||||
return await handler(command);
|
||||
} finally {
|
||||
ctx.commandDb = undefined;
|
||||
|
||||
@@ -92,6 +92,7 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
||||
id: 29,
|
||||
mailbox: actor.id,
|
||||
type: 'private',
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
message: {
|
||||
src: source,
|
||||
@@ -172,6 +173,76 @@ describe('actionable message response', () => {
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
|
||||
for (const row of [
|
||||
buildRow('scout', { option: { action: 'scout', used: 1 } }),
|
||||
{
|
||||
...buildRow('scout'),
|
||||
validUntil: new Date('0199-12-31T23:59:59.000Z'),
|
||||
},
|
||||
]) {
|
||||
const world = buildWorld();
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' });
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps PHP's special string-zero used value false", async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
|
||||
const { db, updateMany } = buildDb([[row], []]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, action: 'scout', reason: 'success' });
|
||||
expect(executor.execute).toHaveBeenCalledOnce();
|
||||
expect(updateMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects malformed actionable payloads without throwing inside the daemon transaction', async () => {
|
||||
const world = buildWorld();
|
||||
const row = { ...buildRow('scout'), message: { option: { action: 'scout' }, dest: null } };
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: '응답할 수 없는 메시지입니다.' });
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invalidate an invader prompt before validating its receiver', async () => {
|
||||
const world = buildWorld();
|
||||
const row = { ...buildRow('raiseInvader'), mailbox: 99 };
|
||||
|
||||
@@ -110,6 +110,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
const result = await auctionBidder.bid(
|
||||
{
|
||||
type: 'auctionBid',
|
||||
userId: 'user-7',
|
||||
auctionId: 31,
|
||||
generalId: general.id,
|
||||
amount,
|
||||
@@ -155,6 +156,7 @@ describe('resource auction Ref compatibility', () => {
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'auctionBid',
|
||||
userId: 'user-7',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
|
||||
@@ -120,6 +120,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const result = await openAuction(
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
userId: 'user-7',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: general.id,
|
||||
amount: 6_000,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
|
||||
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
{ type: 'troopKick', requestId: 'troopKick', userId, generalId: 7, troopId: 7, targetGeneralId: 8 },
|
||||
{ type: 'troopRename', requestId: 'troopRename', userId, generalId: 7, troopId: 7, troopName: '신대' },
|
||||
{ type: 'vacation', requestId: 'vacation', userId, generalId: 7 },
|
||||
{ type: 'setMySetting', requestId: 'setMySetting', userId, generalId: 7, settings: { tnmt: 1 } },
|
||||
{ type: 'dropItem', requestId: 'dropItem', userId, generalId: 7, itemType: 'weapon' },
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
requestId: 'auctionOpen',
|
||||
userId,
|
||||
generalId: 7,
|
||||
auctionType: 'BUY_RICE',
|
||||
amount: 1_000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 100,
|
||||
finishBidAmount: 500,
|
||||
},
|
||||
{ type: 'auctionBid', requestId: 'auctionBid', userId, generalId: 7, auctionId: 1, amount: 200 },
|
||||
{
|
||||
type: 'changePermission',
|
||||
requestId: 'changePermission',
|
||||
userId,
|
||||
generalId: 7,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [],
|
||||
},
|
||||
{ type: 'kick', requestId: 'kick', userId, generalId: 7, destGeneralId: 8 },
|
||||
{
|
||||
type: 'appoint',
|
||||
requestId: 'appoint',
|
||||
userId,
|
||||
generalId: 7,
|
||||
destGeneralId: 8,
|
||||
destCityId: 1,
|
||||
officerLevel: 4,
|
||||
},
|
||||
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
||||
];
|
||||
|
||||
const buildReadOnlyWorld = (ownerUserId: string) => {
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: ownerUserId })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
return { world, mutation };
|
||||
};
|
||||
|
||||
describe('authenticated actor-bound command registry and execution', () => {
|
||||
it.each(['horse', 'weapon', 'book', 'item'] as const)(
|
||||
'accepts the Ref equipment slot %s for dropItem',
|
||||
(itemType) => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: `drop-item:${itemType}`,
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: { type: 'dropItem', userId: 'user-7', generalId: 7, itemType },
|
||||
})
|
||||
).toEqual({
|
||||
type: 'dropItem',
|
||||
requestId: `drop-item:${itemType}`,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['armor', '', 0, null])('rejects the invalid dropItem slot %j at the daemon boundary', (itemType) => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'drop-item:invalid-slot',
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'dropItem',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
} as unknown as TurnDaemonCommand,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects every actor-bound queue payload that omits userId', () => {
|
||||
for (const command of buildActorBoundCommands()) {
|
||||
const {
|
||||
userId: _userId,
|
||||
requestId: _requestId,
|
||||
...payload
|
||||
} = command as unknown as Record<string, unknown>;
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: `missing-user:${command.type}`,
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: payload as TurnDaemonCommand,
|
||||
}),
|
||||
command.type
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects all stale-owner commands before any world mutation', async () => {
|
||||
const commands = buildActorBoundCommands();
|
||||
const { world, mutation } = buildReadOnlyWorld('new-owner');
|
||||
const eventTypes = new Map(commands.map((command) => [command.requestId, command.type]));
|
||||
const db = {
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({
|
||||
actorUserId: 'old-owner',
|
||||
target: 'ENGINE',
|
||||
eventType: eventTypes.get(where.requestId),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
for (const command of commands) {
|
||||
await expect(handler.handle(command, { db: db as never }), command.type).resolves.toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
}
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing event', null],
|
||||
['actor mismatch', { actorUserId: 'other-owner', target: 'ENGINE', eventType: 'vacation' }],
|
||||
['target mismatch', { actorUserId: 'old-owner', target: 'API', eventType: 'vacation' }],
|
||||
['event type mismatch', { actorUserId: 'old-owner', target: 'ENGINE', eventType: 'dropItem' }],
|
||||
])('returns commandRejected for %s without looking up or mutating the general', async (_label, event) => {
|
||||
const { world, mutation } = buildReadOnlyWorld('old-owner');
|
||||
const getGeneralById = world.getGeneralById as ReturnType<typeof vi.fn>;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const result = await handler.handle(
|
||||
{ type: 'vacation', requestId: 'vacation', userId: 'old-owner', generalId: 7 },
|
||||
{
|
||||
db: {
|
||||
inputEvent: { findUnique: vi.fn(async () => event) },
|
||||
} as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ type: 'commandRejected', ok: false, commandType: 'vacation' });
|
||||
expect(getGeneralById).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves direct in-memory invocation when no command database is supplied', async () => {
|
||||
const updateGeneral = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: 'current-owner', meta: { killturn: 12 } })),
|
||||
getState: vi.fn(() => ({ meta: { killturn: 24, autorun_user: {} } })),
|
||||
updateGeneral,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'vacation', userId: 'different-owner', generalId: 7 })).resolves.toEqual({
|
||||
type: 'vacation',
|
||||
ok: true,
|
||||
generalId: 7,
|
||||
});
|
||||
expect(updateGeneral).toHaveBeenCalledWith(7, { meta: { killturn: 72 } });
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
@@ -35,7 +39,8 @@ integration('database command queue', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'vacation',
|
||||
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
|
||||
actorUserId: 'user-7',
|
||||
payload: { type: 'vacation', requestId, userId: 'user-7', generalId: 7 } as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,7 +49,7 @@ integration('database command queue', () => {
|
||||
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
|
||||
const commands = firstCommands.concat(secondCommands);
|
||||
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId, generalId: 7 }]);
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
|
||||
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
|
||||
|
||||
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
@@ -64,7 +69,13 @@ integration('database command queue', () => {
|
||||
requestId: expiredId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'vacation',
|
||||
payload: { type: 'vacation', requestId: expiredId, generalId: 8 } as GamePrisma.InputJsonValue,
|
||||
actorUserId: 'user-8',
|
||||
payload: {
|
||||
type: 'vacation',
|
||||
requestId: expiredId,
|
||||
userId: 'user-8',
|
||||
generalId: 8,
|
||||
} as GamePrisma.InputJsonValue,
|
||||
status: 'PROCESSING',
|
||||
processingAt: new Date(Date.now() - 120_000),
|
||||
lockedBy: 'dead-worker',
|
||||
@@ -74,7 +85,13 @@ integration('database command queue', () => {
|
||||
requestId: activeId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'vacation',
|
||||
payload: { type: 'vacation', requestId: activeId, generalId: 9 } as GamePrisma.InputJsonValue,
|
||||
actorUserId: 'user-9',
|
||||
payload: {
|
||||
type: 'vacation',
|
||||
requestId: activeId,
|
||||
userId: 'user-9',
|
||||
generalId: 9,
|
||||
} as GamePrisma.InputJsonValue,
|
||||
status: 'PROCESSING',
|
||||
processingAt: new Date(),
|
||||
lockedBy: 'active-worker',
|
||||
@@ -87,7 +104,7 @@ integration('database command queue', () => {
|
||||
await queue.initialize();
|
||||
const commands = await queue.drain();
|
||||
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, generalId: 8 }]);
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
lockedBy: 'active-worker',
|
||||
@@ -101,9 +118,11 @@ integration('database command queue', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'vacation',
|
||||
actorUserId: 'user-10',
|
||||
payload: {
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'user-10',
|
||||
generalId: 10,
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
@@ -113,20 +132,16 @@ integration('database command queue', () => {
|
||||
const stale = new DatabaseTurnDaemonCommandQueue(db);
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
await expect(owner.drain()).resolves.toEqual([
|
||||
{ type: 'vacation', requestId, generalId: 10 },
|
||||
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
|
||||
]);
|
||||
await stale.publishCommandError(requestId, new Error('stale worker failure'));
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
|
||||
).resolves.toMatchObject({
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
attempts: attempt,
|
||||
});
|
||||
|
||||
await owner.publishCommandError(requestId, new Error('injected command failure'));
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
|
||||
).resolves.toMatchObject({
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: attempt < 3 ? 'PENDING' : 'FAILED',
|
||||
attempts: attempt,
|
||||
error: 'injected command failure',
|
||||
@@ -137,4 +152,81 @@ integration('database command queue', () => {
|
||||
|
||||
await expect(owner.drain()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('fails an actor-bound payload that omits userId instead of dispatching it', async () => {
|
||||
const requestId = 'integration:engine:missing-user-id';
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'vacation',
|
||||
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'FAILED',
|
||||
error: 'Invalid command payload for vacation',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
|
||||
const requestId = 'integration:engine:stale-owner-replay';
|
||||
const command: TurnDaemonCommand = {
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'old-owner',
|
||||
generalId: 7,
|
||||
};
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
actorUserId: command.userId,
|
||||
payload: command as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const handle = vi.spyOn(handler, 'handle');
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([command]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
await owner.publishCommandResult(requestId, result!);
|
||||
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: command.userId,
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
result,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
});
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('input event atomicity', () => {
|
||||
queue.enqueue({
|
||||
type: 'auctionBid',
|
||||
requestId: 'event-1',
|
||||
userId: 'user-7',
|
||||
auctionId: 3,
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
@@ -239,7 +240,7 @@ describe('input event atomicity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-uow', generalId: 7 });
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-uow', userId: 'user-7', generalId: 7 });
|
||||
const loop = lifecycle.start();
|
||||
await responded;
|
||||
|
||||
@@ -304,7 +305,7 @@ describe('input event atomicity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-2', generalId: 7 });
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-2', userId: 'user-7', generalId: 7 });
|
||||
const loop = lifecycle.start();
|
||||
await errorObserved;
|
||||
|
||||
@@ -374,7 +375,7 @@ describe('input event atomicity', () => {
|
||||
}
|
||||
);
|
||||
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-3', generalId: 7 });
|
||||
queue.enqueue({ type: 'vacation', requestId: 'event-3', userId: 'user-7', generalId: 7 });
|
||||
const loop = lifecycle.start();
|
||||
await errorObserved;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
items: { horse: 'che_명마_02_조랑', weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
@@ -103,7 +103,21 @@ const buildWorld = (
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
cities: [],
|
||||
nations: [],
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#111111',
|
||||
typeCode: 'che_중립',
|
||||
level: 1,
|
||||
capitalCityId: null,
|
||||
chiefGeneralId: 7,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
@@ -216,6 +230,7 @@ describe('my information world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: {
|
||||
tnmt: 9,
|
||||
@@ -250,6 +265,7 @@ describe('my information world commands', () => {
|
||||
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { tnmt: 0, defence_train: 999, use_treatment: 1 },
|
||||
});
|
||||
@@ -270,6 +286,7 @@ describe('my information world commands', () => {
|
||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
});
|
||||
@@ -279,26 +296,110 @@ describe('my information world commands', () => {
|
||||
|
||||
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
||||
const allowed = buildWorld();
|
||||
await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true });
|
||||
await expect(
|
||||
allowed.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72);
|
||||
|
||||
const blocked = buildWorld(buildGeneral(), { autorunLimit: true });
|
||||
await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
blocked.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.',
|
||||
});
|
||||
expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12);
|
||||
});
|
||||
|
||||
it('drops only the authenticated command target slot and rejects an empty slot', async () => {
|
||||
it('drops a buyable item with only the Ref personal action log', async () => {
|
||||
const fixture = buildWorld();
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' })
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' })
|
||||
).resolves.toMatchObject({ ok: false });
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' })
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'horse' })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull();
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<C>조랑(+2)</>을 버렸습니다.',
|
||||
generalId: 7,
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds Ref global loss logs when dropping a non-buyable item', async () => {
|
||||
const fixture = buildWorld(
|
||||
buildGeneral({
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: 'che_서적_14_한비자', item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'book' })
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.book).toBeNull();
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<C>한비자(+14)</>를 버렸습니다.',
|
||||
generalId: 7,
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<Y>테스트장수</>가 <C>한비자(+14)</>를 잃었습니다!',
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
text: '<R><b>【망실】</b></><D><b>테스트국</b></>의 <Y>테스트장수</>가 <C>한비자(+14)</>를 잃었습니다!',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops and logs an item stored under a mismatched equipment slot like Ref', async () => {
|
||||
const fixture = buildWorld(
|
||||
buildGeneral({
|
||||
role: {
|
||||
items: { horse: null, weapon: 'che_명마_02_조랑', book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' })
|
||||
).resolves.toMatchObject({ type: 'dropItem', ok: true, generalId: 7 });
|
||||
expect(fixture.world.getGeneralById(7)?.role.items.weapon).toBeNull();
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<C>조랑(+2)</>을 버렸습니다.',
|
||||
generalId: 7,
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('executes pre-open uprising through the action stack without advancing the turn clock', async () => {
|
||||
@@ -499,6 +600,7 @@ describe('my information world commands', () => {
|
||||
});
|
||||
|
||||
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
|
||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
@@ -506,6 +608,7 @@ describe('my information world commands', () => {
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const recruiter = buildGeneral({
|
||||
id: 9,
|
||||
@@ -566,6 +669,7 @@ describe('my information world commands', () => {
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||
experience: recruiter.experience + 100,
|
||||
@@ -592,6 +696,75 @@ describe('my information world commands', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => {
|
||||
const deletedRecruiterId = 99;
|
||||
const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '재야장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||
};
|
||||
const fixture = buildImmediateActionWorld({
|
||||
general: recipient,
|
||||
cities: [
|
||||
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||
] as TurnWorldSnapshot['cities'],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '등용국',
|
||||
color: '#222222',
|
||||
typeCode: 'che_중립',
|
||||
level: 1,
|
||||
capitalCityId: 2,
|
||||
chiefGeneralId: deletedRecruiterId,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
meta: { gennum: 1 },
|
||||
},
|
||||
] as TurnWorldSnapshot['nations'],
|
||||
map,
|
||||
});
|
||||
const executor = await createImmediateGeneralActionExecutor({
|
||||
world: fixture.world,
|
||||
reservedTurns: fixture.reservedTurns,
|
||||
scenarioMeta: fixture.scenarioMeta,
|
||||
map,
|
||||
commandProfile: { general: ['che_등용'], nation: [] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('accept-deleted-recruiter-letter')),
|
||||
args: { destNationId: 2, destGeneralId: deletedRecruiterId },
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getNationById(2)?.meta.gennum).toBe(2);
|
||||
expect(fixture.world.peekDirtyState().logs).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ generalId: deletedRecruiterId })])
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
|
||||
const general = buildGeneral({ nationId: 1, cityId: 1 });
|
||||
const fixture = buildImmediateActionWorld({
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic';
|
||||
import {
|
||||
loadActionModuleBundle,
|
||||
LogFormat,
|
||||
type GeneralActionModule,
|
||||
type TriggerValue,
|
||||
type TurnSchedule,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
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 { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
@@ -25,7 +32,7 @@ const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGen
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 12, belong: 5, permission: 'normal' },
|
||||
meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 1_000,
|
||||
@@ -48,6 +55,11 @@ const buildWorld = (options: {
|
||||
cityMeta?: Record<string, TriggerValue>;
|
||||
currentYear?: number;
|
||||
scenarioConst?: Record<string, unknown>;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule>;
|
||||
clock?: Pick<
|
||||
TurnWorldState,
|
||||
'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'
|
||||
>;
|
||||
}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
@@ -56,6 +68,7 @@ const buildWorld = (options: {
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
meta: { killturn: 24, scenarioMeta: { startYear: 180 } },
|
||||
...options.clock,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: options.generals ?? [
|
||||
@@ -129,14 +142,24 @@ const buildWorld = (options: {
|
||||
},
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
return { world, handler: createTurnDaemonCommandHandler({ world }) };
|
||||
return {
|
||||
world,
|
||||
handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('nation personnel world commands', () => {
|
||||
it('allows any unlocked head officer to appoint and preserves legacy officer state', async () => {
|
||||
const { world, handler } = buildWorld({});
|
||||
await expect(
|
||||
handler.handle({ type: 'appoint', generalId: 2, destGeneralId: 3, destCityId: 0, officerLevel: 9 })
|
||||
handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 0,
|
||||
officerLevel: 9,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(world.getGeneralById(3)).toMatchObject({
|
||||
officerLevel: 9,
|
||||
@@ -154,6 +177,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-3',
|
||||
generalId: 3,
|
||||
destGeneralId: 2,
|
||||
destCityId: 0,
|
||||
@@ -163,6 +187,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 0,
|
||||
@@ -175,6 +200,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
locked.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 0,
|
||||
@@ -196,6 +222,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 1,
|
||||
@@ -215,6 +242,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
locked.handler.handle({
|
||||
type: 'appoint',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
destCityId: 1,
|
||||
@@ -237,6 +265,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [3],
|
||||
@@ -245,6 +274,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [3, 5, 6],
|
||||
@@ -254,6 +284,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [2, 3, 4],
|
||||
@@ -263,6 +294,7 @@ describe('nation personnel world commands', () => {
|
||||
await expect(
|
||||
fixture.handler.handle({
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [2, 3],
|
||||
@@ -271,6 +303,22 @@ describe('nation personnel world commands', () => {
|
||||
expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('ambassador');
|
||||
expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('ambassador');
|
||||
expect(fixture.world.getGeneralById(4)?.meta.permission).toBe('normal');
|
||||
|
||||
const clearCommand = normalizeTurnDaemonCommand({
|
||||
requestId: 'clear-ambassadors',
|
||||
sentAt: '2026-01-01T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'changePermission',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [],
|
||||
},
|
||||
});
|
||||
expect(clearCommand).toMatchObject({ type: 'changePermission', targetGeneralIds: [] });
|
||||
await expect(fixture.handler.handle(clearCommand!)).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('normal');
|
||||
expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('normal');
|
||||
});
|
||||
|
||||
it('kicks for an unlocked head officer with resource, troop, permission, and log side effects', async () => {
|
||||
@@ -280,7 +328,14 @@ describe('nation personnel world commands', () => {
|
||||
rice: 3_000,
|
||||
experience: 1_000,
|
||||
dedication: 2_000,
|
||||
meta: { killturn: 12, permission: 'normal', belong: 8, betray: 1 },
|
||||
meta: {
|
||||
killturn: 12,
|
||||
permission: 'normal',
|
||||
belong: 8,
|
||||
betray: 1,
|
||||
explevel: 10,
|
||||
dedlevel: 5,
|
||||
},
|
||||
});
|
||||
const member = buildGeneral(4, { troopId: 3 });
|
||||
const fixture = buildWorld({
|
||||
@@ -289,7 +344,9 @@ describe('nation personnel world commands', () => {
|
||||
});
|
||||
fixture.world.createTroop({ id: 3, nationId: 1, name: '추방대' });
|
||||
|
||||
await expect(fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(3)).toMatchObject({
|
||||
@@ -309,7 +366,59 @@ describe('nation personnel world commands', () => {
|
||||
rice: 22_000,
|
||||
meta: expect.objectContaining({ gennum: 3 }),
|
||||
});
|
||||
expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
|
||||
expect(fixture.world.peekDirtyState().logs).toEqual([
|
||||
expect.objectContaining({ scope: 'SYSTEM', category: 'SUMMARY' }),
|
||||
expect.objectContaining({
|
||||
scope: 'GENERAL',
|
||||
category: 'ACTION',
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: 3,
|
||||
text: '<D><b>위</b></>에서 <R>추방</>당했습니다.',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: 'GENERAL',
|
||||
category: 'ACTION',
|
||||
text: expect.stringContaining('레벨다운'),
|
||||
}),
|
||||
expect.objectContaining({ scope: 'GENERAL', category: 'HISTORY' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies Ref-ordered personality and item modifiers before legacy INT rounding on kick', async () => {
|
||||
const modules = (await loadActionModuleBundle()).general;
|
||||
const target = buildGeneral(3, {
|
||||
experience: 1_001,
|
||||
dedication: 2_001,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: 'che_명성_구석' },
|
||||
personality: 'che_대의',
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
meta: {
|
||||
killturn: 12,
|
||||
permission: 'normal',
|
||||
belong: 8,
|
||||
betray: 1,
|
||||
explevel: 10,
|
||||
dedlevel: 5,
|
||||
},
|
||||
});
|
||||
const fixture = buildWorld({
|
||||
generals: [buildGeneral(1, { officerLevel: 12 }), buildGeneral(2, { officerLevel: 5 }), target],
|
||||
generalActionModules: modules,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(3)).toMatchObject({
|
||||
experience: 803,
|
||||
dedication: 1_701,
|
||||
meta: expect.objectContaining({ explevel: 8, dedlevel: 5, betray: 2 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
|
||||
@@ -336,7 +445,12 @@ describe('nation personnel world commands', () => {
|
||||
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
|
||||
fixture.handler.handle({
|
||||
type: 'kick',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: testCase.targetId,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
|
||||
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
|
||||
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
|
||||
@@ -354,7 +468,7 @@ describe('nation personnel world commands', () => {
|
||||
buildGeneral(3, { meta: { killturn: 12, belong: 8, permission: 'normal', betray: 1 } }),
|
||||
],
|
||||
});
|
||||
await early.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 });
|
||||
await early.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 });
|
||||
expect(early.world.getGeneralById(3)).toMatchObject({
|
||||
experience: 850,
|
||||
dedication: 1_700,
|
||||
@@ -372,12 +486,54 @@ describe('nation personnel world commands', () => {
|
||||
buildGeneral(3, { npcState: 2 }),
|
||||
],
|
||||
});
|
||||
await npc.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 });
|
||||
await npc.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 });
|
||||
expect(npc.world.peekDirtyState().messages).toHaveLength(1);
|
||||
expect(npc.world.peekDirtyState().messages[0]).toMatchObject({
|
||||
msgType: 'public',
|
||||
src: { generalId: 3, nationId: 1 },
|
||||
dest: { generalId: 3, nationId: 1 },
|
||||
src: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' },
|
||||
dest: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' },
|
||||
});
|
||||
});
|
||||
|
||||
it('timestamps a queued NPC kick message from the durable accepted instant', async () => {
|
||||
const acceptedAt = new Date('2026-01-01T00:10:00.000Z');
|
||||
const fixture = buildWorld({
|
||||
currentYear: 185,
|
||||
scenarioConst: { npcBanMessageProb: 1 },
|
||||
clock: {
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
},
|
||||
generals: [
|
||||
buildGeneral(1, { officerLevel: 12 }),
|
||||
buildGeneral(2, { officerLevel: 5 }),
|
||||
buildGeneral(3, { npcState: 2 }),
|
||||
],
|
||||
});
|
||||
const command = {
|
||||
type: 'kick' as const,
|
||||
requestId: 'kick-accepted-time',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
destGeneralId: 3,
|
||||
};
|
||||
const db = {
|
||||
inputEvent: {
|
||||
findUnique: async () => ({
|
||||
createdAt: acceptedAt,
|
||||
actorUserId: command.userId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(fixture.handler.handle(command, { db: db as never })).resolves.toMatchObject({ ok: true });
|
||||
expect(fixture.world.peekDirtyState().messages[0]?.time).toEqual(
|
||||
new Date('0185-01-01T00:10:00.000Z')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { applyNationSettingMutation } from '../src/turn/nationSettingMutation.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
type SetNationSettingCommand = Extract<TurnDaemonCommand, { type: 'setNationSetting' }>;
|
||||
type NationSettingMutation = SetNationSettingCommand['mutation'];
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const acceptedAt = new Date('2026-02-03T04:05:06.000Z');
|
||||
|
||||
const general: TurnGeneral = {
|
||||
id: 1,
|
||||
userId: 'owner-1',
|
||||
name: '테스트군주',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 75, strength: 40, intelligence: 70 },
|
||||
turnTime: new Date('0185-01-01T00:00: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 },
|
||||
penalty: {},
|
||||
officerLevel: 12,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
};
|
||||
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '허창',
|
||||
nationId: 1,
|
||||
level: 7,
|
||||
state: 0,
|
||||
population: 100_000,
|
||||
populationMax: 200_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: {},
|
||||
},
|
||||
],
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
power: 0,
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
meta: { tech: 3_000, preserved: 'yes' },
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'basic' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
meta: { killturn: 24 },
|
||||
};
|
||||
|
||||
const createWorld = (options?: {
|
||||
general?: Partial<TurnGeneral>;
|
||||
nationMeta?: TurnWorldSnapshot['nations'][number]['meta'];
|
||||
worldMeta?: Record<string, unknown>;
|
||||
}): InMemoryTurnWorld => {
|
||||
const nextSnapshot = structuredClone(snapshot);
|
||||
nextSnapshot.generals[0] = { ...nextSnapshot.generals[0]!, ...options?.general };
|
||||
nextSnapshot.nations[0]!.meta = {
|
||||
...nextSnapshot.nations[0]!.meta,
|
||||
...options?.nationMeta,
|
||||
};
|
||||
return new InMemoryTurnWorld(
|
||||
{
|
||||
...state,
|
||||
meta: { ...state.meta, ...options?.worldMeta },
|
||||
},
|
||||
nextSnapshot,
|
||||
{ schedule }
|
||||
);
|
||||
};
|
||||
|
||||
const command = (
|
||||
mutation: NationSettingMutation,
|
||||
overrides?: Partial<Omit<SetNationSettingCommand, 'type' | 'mutation'>>
|
||||
): SetNationSettingCommand => ({
|
||||
type: 'setNationSetting',
|
||||
requestId: 'nation-setting-test',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
mutation,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('nation setting mutation', () => {
|
||||
it('keeps raw required validation at the API boundary and only enforces code-point length durably', () => {
|
||||
const normalizeNotice = (message: string) =>
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'nation-setting-text-boundary',
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind: 'notice', message }),
|
||||
});
|
||||
|
||||
expect(normalizeNotice('')).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind: 'notice', message: '' },
|
||||
});
|
||||
expect(normalizeNotice(' \t\n\v\0')).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind: 'notice', message: ' \t\n\v\0' },
|
||||
});
|
||||
expect(normalizeNotice(' ')).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
mutation: { kind: 'notice', message: ' ' },
|
||||
});
|
||||
expect(normalizeNotice('😀'.repeat(16_384))).not.toBeNull();
|
||||
expect(normalizeNotice('😀'.repeat(16_385))).toBeNull();
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'nation-setting-scout-text-boundary',
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_000) }),
|
||||
})
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'nation-setting-scout-text-overflow',
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_001) }),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['notice', 'notice', 'nationNotice'],
|
||||
['scoutMessage', 'infoText', null],
|
||||
] as const)('stores an empty sanitized %s string like Ref', (kind, metaKey, structuredMetaKey) => {
|
||||
const normalized = normalizeTurnDaemonCommand({
|
||||
requestId: `nation-setting-empty-${kind}`,
|
||||
sentAt: acceptedAt.toISOString(),
|
||||
command: command({ kind, message: '' }),
|
||||
});
|
||||
expect(normalized).not.toBeNull();
|
||||
if (!normalized || normalized.type !== 'setNationSetting') {
|
||||
throw new Error('setNationSetting normalization failed');
|
||||
}
|
||||
|
||||
const world = createWorld();
|
||||
expect(applyNationSettingMutation({ world, command: normalized, acceptedAt })).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta[metaKey]).toBe('');
|
||||
if (structuredMetaKey) {
|
||||
expect(world.getNationById(1)?.meta[structuredMetaKey]).toMatchObject({ msg: '' });
|
||||
}
|
||||
});
|
||||
|
||||
it('rechecks owner, nation, and permission at execution time without mutating nation metadata on rejection', () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
world: InMemoryTurnWorld;
|
||||
command: SetNationSettingCommand;
|
||||
code: 'FORBIDDEN' | 'PRECONDITION_FAILED';
|
||||
}> = [
|
||||
{
|
||||
name: 'owner changed',
|
||||
world: createWorld(),
|
||||
command: command({ kind: 'rate', amount: 20 }, { userId: 'other-owner' }),
|
||||
code: 'FORBIDDEN',
|
||||
},
|
||||
{
|
||||
name: 'nation changed',
|
||||
world: createWorld({ general: { nationId: 2 } }),
|
||||
command: command({ kind: 'rate', amount: 20 }),
|
||||
code: 'PRECONDITION_FAILED',
|
||||
},
|
||||
{
|
||||
name: 'permission revoked',
|
||||
world: createWorld({ general: { officerLevel: 2 } }),
|
||||
command: command({ kind: 'rate', amount: 20 }),
|
||||
code: 'FORBIDDEN',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const before = structuredClone(testCase.world.getNationById(1)?.meta);
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world: testCase.world,
|
||||
command: testCase.command,
|
||||
acceptedAt,
|
||||
}),
|
||||
testCase.name
|
||||
).toMatchObject({ type: 'setNationSetting', ok: false, code: testCase.code });
|
||||
expect(testCase.world.getNationById(1)?.meta, testCase.name).toEqual(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the special editable-permission rules for high officers and low ambassadors', () => {
|
||||
const highOfficer = createWorld({
|
||||
general: { officerLevel: 5, penalty: { noChief: true } },
|
||||
});
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world: highOfficer,
|
||||
command: command({ kind: 'rate', amount: 20 }, { requestId: 'high-officer' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true });
|
||||
expect(highOfficer.getNationById(1)?.meta).toMatchObject({ rate: 20, preserved: 'yes' });
|
||||
|
||||
const lowAmbassador = createWorld({
|
||||
general: { officerLevel: 2, meta: { killturn: 24, permission: 'ambassador' } },
|
||||
});
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world: lowAmbassador,
|
||||
command: command({ kind: 'bill', amount: 100 }, { requestId: 'low-ambassador' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true });
|
||||
expect(lowAmbassador.getNationById(1)?.meta).toMatchObject({ bill: 100, preserved: 'yes' });
|
||||
});
|
||||
|
||||
it('stores the notice text and author snapshot at logical game time', () => {
|
||||
const world = createWorld();
|
||||
const result = applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'notice', message: '새 국가 방침' }, { requestId: 'notice-logical-time' }),
|
||||
acceptedAt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'setNationSetting',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({
|
||||
preserved: 'yes',
|
||||
notice: '새 국가 방침',
|
||||
nationNotice: {
|
||||
date: '0185-01-01 09:00:00',
|
||||
msg: '새 국가 방침',
|
||||
author: '테스트군주',
|
||||
authorID: 1,
|
||||
},
|
||||
_updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an absent war-setting counter as zero and leaves metadata unchanged', () => {
|
||||
const world = createWorld();
|
||||
const before = structuredClone(world.getNationById(1)?.meta);
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: true }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toEqual({
|
||||
type: 'setNationSetting',
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '잔여 횟수가 부족합니다.',
|
||||
nationId: 1,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toEqual(before);
|
||||
});
|
||||
|
||||
it('consumes the current war-setting counter sequentially and rejects after exhaustion', () => {
|
||||
const world = createWorld({ nationMeta: { available_war_setting_cnt: 2 } });
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-1' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 1 });
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({ war: 1, available_war_setting_cnt: 1 });
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: false }, { requestId: 'block-war-2' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 0 });
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({ war: 0, available_war_setting_cnt: 0 });
|
||||
|
||||
const beforeRejected = structuredClone(world.getNationById(1)?.meta);
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-3' }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: false, code: 'BAD_REQUEST' });
|
||||
expect(world.getNationById(1)?.meta).toEqual(beforeRejected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['null', null],
|
||||
['false', false],
|
||||
['zero', 0],
|
||||
['empty string', ''],
|
||||
['string zero', '0'],
|
||||
['empty array', []],
|
||||
])('allows scout changes when the legacy lock value is falsey: %s', (_name, lockValue) => {
|
||||
const world = createWorld({
|
||||
worldMeta: lockValue === undefined ? {} : { block_change_scout: lockValue },
|
||||
});
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockScout', value: true }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toMatchObject({ type: 'setNationSetting', ok: true });
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({ scout: 1, preserved: 'yes' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['true', true],
|
||||
['one', 1],
|
||||
['string one', '1'],
|
||||
['non-empty array', [0]],
|
||||
['object', {}],
|
||||
])('rejects scout changes without mutation when the legacy lock value is truthy: %s', (_name, lockValue) => {
|
||||
const world = createWorld({ worldMeta: { block_change_scout: lockValue } });
|
||||
const before = structuredClone(world.getNationById(1)?.meta);
|
||||
|
||||
expect(
|
||||
applyNationSettingMutation({
|
||||
world,
|
||||
command: command({ kind: 'blockScout', value: true }),
|
||||
acceptedAt,
|
||||
})
|
||||
).toEqual({
|
||||
type: 'setNationSetting',
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
|
||||
nationId: 1,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const general: TurnGeneral = {
|
||||
@@ -83,7 +83,7 @@ const snapshot: TurnWorldSnapshot = {
|
||||
meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
troops: [{ id: 101, nationId: 1, name: '선봉부대' }],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
@@ -176,28 +176,71 @@ const unitSet: UnitSetDefinition = {
|
||||
};
|
||||
|
||||
describe('NPC policy lifecycle', () => {
|
||||
it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => {
|
||||
it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => {
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const updates = {
|
||||
const first = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
requestId: 'npc-policy-values',
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } },
|
||||
},
|
||||
});
|
||||
expect(first).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
if (!first.ok) {
|
||||
throw new Error(first.reason);
|
||||
}
|
||||
|
||||
world.updateNation(1, {
|
||||
meta: {
|
||||
...world.getNationById(1)!.meta,
|
||||
_updatedAt: '2026-02-03T04:05:06.500Z#unrelated-setting',
|
||||
},
|
||||
});
|
||||
|
||||
const second = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:07.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
requestId: 'npc-policy-priority',
|
||||
expectedUpdatedAt: first.updatedAt,
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
if (!second.ok) {
|
||||
throw new Error(second.reason);
|
||||
}
|
||||
|
||||
const nation = world.getNationById(1)!;
|
||||
expect(nation.meta).toMatchObject({
|
||||
preserved: 'yes',
|
||||
npc_nation_policy: {
|
||||
values: { reqNationGold: 4_321 },
|
||||
priority: ['천도'],
|
||||
valueSetter: '정책담당',
|
||||
valueSetter: 'NPC군주',
|
||||
prioritySetter: 'NPC군주',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 });
|
||||
|
||||
const nation = world.getNationById(1)!;
|
||||
expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy });
|
||||
});
|
||||
const policy = new AutorunNationPolicy({
|
||||
general: world.getGeneralById(1)!,
|
||||
aiOptions: null,
|
||||
@@ -214,17 +257,214 @@ describe('NPC policy lifecycle', () => {
|
||||
expect(policy.reqNpcWarGold).toBe(3_900);
|
||||
expect(policy.reqNpcWarRice).toBe(3_900);
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } },
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
const beforeConflict = structuredClone(world.getNationById(1)?.meta);
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:08.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPolicy', values: { reqNationGold: 9_999 } },
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' });
|
||||
).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: false,
|
||||
code: 'CONFLICT',
|
||||
currentUpdatedAt: second.updatedAt,
|
||||
});
|
||||
expect(world.getNationById(1)?.meta).toEqual(beforeConflict);
|
||||
expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({
|
||||
reqNationGold: 4_321,
|
||||
});
|
||||
expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 });
|
||||
});
|
||||
|
||||
it('requires an exact nullable revision when policy metadata has never been versioned', () => {
|
||||
const noRevisionSnapshot = structuredClone(snapshot);
|
||||
delete noRevisionSnapshot.nations[0]!.meta._npcPolicyUpdatedAt;
|
||||
delete noRevisionSnapshot.nations[0]!.meta._updatedAt;
|
||||
const world = new InMemoryTurnWorld(state, noRevisionSnapshot, { schedule });
|
||||
const initialMeta = structuredClone(world.getNationById(1)?.meta);
|
||||
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ type: 'setNpcPolicy', ok: false, code: 'CONFLICT', currentUpdatedAt: null });
|
||||
expect(world.getNationById(1)?.meta).toEqual(initialMeta);
|
||||
|
||||
const accepted = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:07.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
requestId: 'initial-null-revision',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: null,
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
});
|
||||
expect(accepted).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
if (!accepted.ok) {
|
||||
throw new Error(accepted.reason);
|
||||
}
|
||||
expect(world.getNationById(1)?.meta).toMatchObject({
|
||||
preserved: 'yes',
|
||||
_npcPolicyUpdatedAt: accepted.updatedAt,
|
||||
npc_nation_policy: { priority: ['천도'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates and merges policy intent against current ENGINE state without materialising defaults', () => {
|
||||
const world = new InMemoryTurnWorld(
|
||||
{
|
||||
...state,
|
||||
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
},
|
||||
snapshot,
|
||||
{ schedule }
|
||||
);
|
||||
world.updateNation(1, {
|
||||
meta: {
|
||||
...world.getNationById(1)!.meta,
|
||||
npc_nation_policy: { values: { reqNationRice: 456 }, preserved: 'root' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt: new Date('2026-02-03T04:05:06.000Z'),
|
||||
command: {
|
||||
type: 'setNpcPolicy',
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
mutation: {
|
||||
kind: 'nationPolicy',
|
||||
values: {
|
||||
reqNationGold: -100,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: {},
|
||||
SupportForce: [101],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'setNpcPolicy',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/),
|
||||
});
|
||||
expect(asRecord(world.getNationById(1)?.meta).npc_nation_policy).toEqual({
|
||||
values: {
|
||||
reqNationRice: 456,
|
||||
reqNationGold: 0,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: {},
|
||||
SupportForce: [101],
|
||||
},
|
||||
preserved: 'root',
|
||||
valueSetter: 'NPC군주',
|
||||
valueSetTime: '0185-01-01 09:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale authority, empty input, malformed combat targets, and lost CAS inside ENGINE', () => {
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const baseCommand = {
|
||||
type: 'setNpcPolicy' as const,
|
||||
userId: 'owner-1',
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
const acceptedAt = new Date('2026-02-03T04:05:06.000Z');
|
||||
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: {} } },
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'BAD_REQUEST' });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: {
|
||||
...baseCommand,
|
||||
mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1, 1] } } },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'BAD_REQUEST', reason: '101의 입력양식이 올바르지 않습니다.' });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: {
|
||||
...baseCommand,
|
||||
mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1] } } },
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
reason: '101의 도시 , 가 올바른 도시 번호가 아닙니다.',
|
||||
});
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: [] } },
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'BAD_REQUEST' });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: {
|
||||
...baseCommand,
|
||||
expectedUpdatedAt: '1999-01-01T00:00:00.000Z',
|
||||
mutation: { kind: 'nationPriority', priority: ['천도'] },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'CONFLICT' });
|
||||
|
||||
world.updateGeneral(1, { officerLevel: 2 });
|
||||
expect(
|
||||
applyNpcPolicyMutation({
|
||||
world,
|
||||
acceptedAt,
|
||||
command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: ['천도'] } },
|
||||
})
|
||||
).toMatchObject({ ok: false, code: 'FORBIDDEN' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,67 @@ describe('old nation archive data', () => {
|
||||
aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
|
||||
generals: [7, 8],
|
||||
history: ['위가 멸망'],
|
||||
msg: '',
|
||||
scout_msg: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers current notice fields and preserves empty strings', () => {
|
||||
const nation: Nation = {
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000ff',
|
||||
capitalCityId: 3,
|
||||
chiefGeneralId: 7,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
power: 8_000,
|
||||
level: 5,
|
||||
typeCode: 'che_법가',
|
||||
meta: {
|
||||
notice: '',
|
||||
infoText: '',
|
||||
nationNotice: { msg: 'legacy notice' },
|
||||
msg: 'legacy flat notice',
|
||||
scout_msg: 'legacy scout message',
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildOldNationArchiveData({ nation, generalIds: [], history: [] })).toMatchObject({
|
||||
msg: '',
|
||||
scout_msg: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to both legacy notice shapes and legacy scout text', () => {
|
||||
const baseNation: Nation = {
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000ff',
|
||||
capitalCityId: 3,
|
||||
chiefGeneralId: 7,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
power: 8_000,
|
||||
level: 5,
|
||||
typeCode: 'che_법가',
|
||||
meta: {
|
||||
nationNotice: { msg: 'legacy notice' },
|
||||
msg: 'legacy flat notice',
|
||||
scout_msg: 'legacy scout message',
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildOldNationArchiveData({ nation: baseNation, generalIds: [], history: [] })).toMatchObject({
|
||||
msg: 'legacy notice',
|
||||
scout_msg: 'legacy scout message',
|
||||
});
|
||||
expect(
|
||||
buildOldNationArchiveData({
|
||||
nation: { ...baseNation, meta: { msg: 'legacy flat notice' } },
|
||||
generalIds: [],
|
||||
history: [],
|
||||
})
|
||||
).toMatchObject({ msg: 'legacy flat notice', scout_msg: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }]
|
||||
|
||||
const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id,
|
||||
userId: `user-${id}`,
|
||||
name: `장수${id}`,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
@@ -132,7 +133,9 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toEqual({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
|
||||
).resolves.toEqual({
|
||||
type: 'troopJoin',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
@@ -159,7 +162,9 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 1 });
|
||||
@@ -177,7 +182,9 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 2 });
|
||||
@@ -188,7 +195,9 @@ describe('troop management world commands', () => {
|
||||
const world = buildWorld({});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({
|
||||
await expect(
|
||||
handler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' 백마대 ' })
|
||||
).resolves.toEqual({
|
||||
type: 'troopCreate',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
@@ -202,7 +211,12 @@ describe('troop management world commands', () => {
|
||||
const escapedWorld = buildWorld({ generals: [buildGeneral(2)] });
|
||||
const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld });
|
||||
await expect(
|
||||
escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' })
|
||||
escapedHandler.handle({
|
||||
type: 'troopCreate',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopName: '<백마대>',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, troopName: '<백마대>' });
|
||||
});
|
||||
|
||||
@@ -213,13 +227,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const assignedHandler = createTurnDaemonCommandHandler({ world: assigned });
|
||||
await expect(
|
||||
assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' })
|
||||
assignedHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: '신규대' })
|
||||
).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' });
|
||||
|
||||
const blank = buildWorld({});
|
||||
const blankHandler = createTurnDaemonCommandHandler({ world: blank });
|
||||
await expect(
|
||||
blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' })
|
||||
blankHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' ' })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: '부대 이름이 없습니다.',
|
||||
@@ -244,6 +258,7 @@ describe('troop management world commands', () => {
|
||||
await expect(
|
||||
forbiddenHandler.handle({
|
||||
type: 'troopKick',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopId: 1,
|
||||
targetGeneralId: 3,
|
||||
@@ -256,6 +271,7 @@ describe('troop management world commands', () => {
|
||||
await expect(
|
||||
allowedHandler.handle({
|
||||
type: 'troopKick',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
targetGeneralId: 3,
|
||||
@@ -266,6 +282,7 @@ describe('troop management world commands', () => {
|
||||
await expect(
|
||||
allowedHandler.handle({
|
||||
type: 'troopKick',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
targetGeneralId: 1,
|
||||
@@ -280,7 +297,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld });
|
||||
await expect(
|
||||
leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' })
|
||||
leaderHandler.handle({
|
||||
type: 'troopRename',
|
||||
userId: 'user-1',
|
||||
generalId: 1,
|
||||
troopId: 1,
|
||||
troopName: '신대',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, troopName: '신대' });
|
||||
|
||||
const managerWorld = buildWorld({
|
||||
@@ -292,7 +315,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld });
|
||||
await expect(
|
||||
managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
|
||||
managerHandler.handle({
|
||||
type: 'troopRename',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopId: 1,
|
||||
troopName: '신대',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, troopName: '신대' });
|
||||
|
||||
const penalizedWorld = buildWorld({
|
||||
@@ -307,7 +336,13 @@ describe('troop management world commands', () => {
|
||||
});
|
||||
const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld });
|
||||
await expect(
|
||||
penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
|
||||
penalizedHandler.handle({
|
||||
type: 'troopRename',
|
||||
userId: 'user-2',
|
||||
generalId: 2,
|
||||
troopId: 1,
|
||||
troopName: '신대',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' });
|
||||
expect(penalizedWorld.getTroopById(1)?.name).toBe('구대');
|
||||
});
|
||||
|
||||
@@ -80,6 +80,8 @@ integration('unification finalization transaction', () => {
|
||||
meta: {
|
||||
power: 3_000,
|
||||
max_power: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
|
||||
notice: '통일 공지',
|
||||
infoText: '통일 임관 안내',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -268,6 +270,7 @@ integration('unification finalization transaction', () => {
|
||||
await expect(
|
||||
bidder.bid({
|
||||
type: 'auctionBid',
|
||||
userId,
|
||||
auctionId: uniqueAuction.id,
|
||||
generalId: fixtureId,
|
||||
amount: 30,
|
||||
@@ -277,6 +280,7 @@ integration('unification finalization transaction', () => {
|
||||
await expect(
|
||||
bidder.bid({
|
||||
type: 'auctionBid',
|
||||
userId,
|
||||
auctionId: uniqueAuction.id,
|
||||
generalId: fixtureId,
|
||||
amount: 50,
|
||||
@@ -404,6 +408,8 @@ integration('unification finalization transaction', () => {
|
||||
maxCities: ['원자도시'],
|
||||
aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
|
||||
generals: [fixtureId],
|
||||
msg: '통일 공지',
|
||||
scout_msg: '통일 임관 안내',
|
||||
});
|
||||
expect(legacyOfficerPicture.length).toBeGreaterThan(32);
|
||||
expect(await db.emperor.findFirstOrThrow({ where: { serverId } })).toMatchObject({
|
||||
|
||||
@@ -245,10 +245,16 @@ describe('unification handler', () => {
|
||||
auctionBidder: { bid },
|
||||
});
|
||||
await expect(
|
||||
commands.handle({ type: 'auctionBid', auctionId: 77, generalId: 1, amount: 100 })
|
||||
commands.handle({ type: 'auctionBid', userId: 'user-1', auctionId: 77, generalId: 1, amount: 100 })
|
||||
).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' });
|
||||
await expect(
|
||||
commands.handle({ type: 'auctionOpen', auctionType: 'UNIQUE_ITEM', generalId: 1, amount: 100 })
|
||||
commands.handle({
|
||||
type: 'auctionOpen',
|
||||
userId: 'user-1',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: 1,
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' });
|
||||
expect(bid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ const buildWorld = (): InMemoryTurnWorld => {
|
||||
power: 3000,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
meta: { notice: '통일 공지', infoText: '통일 임관 안내' },
|
||||
};
|
||||
const city: City = {
|
||||
id: 1,
|
||||
@@ -184,6 +184,7 @@ describe('persistUnificationFinalization', () => {
|
||||
const gameHistoryUpdate = vi.fn().mockResolvedValue({});
|
||||
const emperorCreate = vi.fn().mockResolvedValue({});
|
||||
const oldGeneralUpsert = vi.fn().mockResolvedValue({});
|
||||
const oldNationUpsert = vi.fn().mockResolvedValue({});
|
||||
const transaction = Object.assign({} as GamePrisma.TransactionClient, {
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
$queryRaw: vi.fn().mockResolvedValue([]),
|
||||
@@ -233,7 +234,7 @@ describe('persistUnificationFinalization', () => {
|
||||
),
|
||||
},
|
||||
oldNation: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
upsert: oldNationUpsert,
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
oldGeneral: { upsert: oldGeneralUpsert },
|
||||
@@ -282,6 +283,14 @@ describe('persistUnificationFinalization', () => {
|
||||
expect(gameHistoryUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
|
||||
);
|
||||
expect(oldNationUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
nation: 1,
|
||||
data: expect.objectContaining({ msg: '통일 공지', scout_msg: '통일 임관 안내' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(emperorCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../sr
|
||||
|
||||
const buildGeneral = (id: number): TurnGeneral => ({
|
||||
id,
|
||||
userId: `user-${id}`,
|
||||
name: `General_${id}`,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
@@ -46,6 +47,12 @@ const buildGeneral = (id: number): TurnGeneral => ({
|
||||
npcState: 0,
|
||||
});
|
||||
|
||||
const actorBindingDb = (userId = 'user-1') => ({
|
||||
inputEvent: {
|
||||
findUnique: async () => ({ actorUserId: userId, target: 'ENGINE', eventType: 'voteReward' }),
|
||||
},
|
||||
});
|
||||
|
||||
describe('voteReward command', () => {
|
||||
it('keeps the wall-time fallback open at exact deadline equality', () => {
|
||||
const deadline = new Date('0180-01-01T00:00:00.000Z');
|
||||
@@ -67,6 +74,7 @@ describe('voteReward command', () => {
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'voteReward',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
@@ -224,6 +232,7 @@ describe('voteReward command', () => {
|
||||
let voteQueryCount = 0;
|
||||
let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined;
|
||||
const commandDb = {
|
||||
...actorBindingDb(),
|
||||
auction: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
@@ -249,6 +258,8 @@ describe('voteReward command', () => {
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward-1',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
@@ -269,9 +280,7 @@ describe('voteReward command', () => {
|
||||
expect(
|
||||
voteInsertQuery?.values.filter(
|
||||
(value) =>
|
||||
value instanceof Date &&
|
||||
value.getTime() >= writerWindowStart &&
|
||||
value.getTime() <= writerWindowEnd
|
||||
value instanceof Date && value.getTime() >= writerWindowStart && value.getTime() <= writerWindowEnd
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
@@ -311,6 +320,7 @@ describe('voteReward command', () => {
|
||||
const duplicateHandler = createTurnDaemonCommandHandler({ world: duplicateWorld });
|
||||
const duplicateResult = await duplicateHandler.handle(command, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
@@ -346,6 +356,7 @@ describe('voteReward command', () => {
|
||||
const mismatchHandler = createTurnDaemonCommandHandler({ world: mismatchWorld });
|
||||
const mismatchResult = await mismatchHandler.handle(command, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
@@ -383,6 +394,7 @@ describe('voteReward command', () => {
|
||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
@@ -456,6 +468,7 @@ describe('voteReward command', () => {
|
||||
});
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const commandDb = {
|
||||
...actorBindingDb(),
|
||||
auction: {
|
||||
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
|
||||
},
|
||||
@@ -476,6 +489,8 @@ describe('voteReward command', () => {
|
||||
const result = await handler.handle(
|
||||
{
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-reward-occupied',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
'turn/monthlyDisasterAction': 'src/turn/monthlyDisasterAction.ts',
|
||||
'turn/monthlyEventHandler': 'src/turn/monthlyEventHandler.ts',
|
||||
'turn/monthlyNationBettingAction': 'src/turn/monthlyNationBettingAction.ts',
|
||||
'turn/npcPolicyMutation': 'src/turn/npcPolicyMutation.ts',
|
||||
'turn/npcPossessionService': 'src/turn/npcPossessionService.ts',
|
||||
'turn/rankData': 'src/turn/rankData.ts',
|
||||
'turn/reservedTurnHandler': 'src/turn/reservedTurnHandler.ts',
|
||||
|
||||
@@ -23,7 +23,7 @@ transaction을 만든다. `engineAuthedProcedure`, `accessEngineAuthedProcedure`
|
||||
- **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부
|
||||
소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다.
|
||||
|
||||
현재 합계는 **ENGINE 전환 26 + 혼합 13 + 기존 ENGINE 9 + 불필요한 API
|
||||
현재 합계는 **ENGINE 전환 36 + 혼합 3 + 기존 ENGINE 9 + 불필요한 API
|
||||
outer 1 = 49**다.
|
||||
|
||||
ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청
|
||||
@@ -45,19 +45,19 @@ selection-pool create/reselect는 client request ID가 있을 때
|
||||
| `inherit.openUniqueAuction` | `engineAuthedProcedure`; world/general/minimum bid 조기 validation (`app/game-api/src/router/inherit/index.ts:389-428`) | 공통 `auctionOpen` ENGINE handler가 mutation을 소유하고 API는 Redis timer index만 갱신 (`app/game-api/src/auction/open.ts:22-51`) |
|
||||
| `join.getSelectionPool` | `engineAuthedProcedure`; actor와 accepted game time만 전달 (`app/game-api/src/router/join/index.ts:390-406`) | `selectPoolReserve` ENGINE handler가 world/DB 상태에서 예약을 재검증하고 저장 (`app/game-engine/src/turn/worldCommandHandler.ts:401-437`) |
|
||||
| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique`, `inherit.checkOwner` | 모두 `engineAuthedProcedure`; 인증 user ID와 action 입력만 전달 (`app/game-api/src/router/inherit/index.ts:107-124`, `:301-388`, `:429-445`) | `inheritanceAction` ENGINE handler가 general/user 소유권, 통일 상태, 잔액, 대상, RNG, general patch, inheritance point/log/message를 한 transaction에서 처리 (`app/game-engine/src/turn/worldCommandHandler.ts:807-818`, `app/game-engine/src/turn/inheritanceActionService.ts:313-669`) |
|
||||
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `engineAuthedProcedure`; API는 인증 actor, 현재 국가와 조기 권한만 읽고 semantic mutation을 전달 (`app/game-api/src/router/nation/endpoints/setNotice.ts`, `setScoutMsg.ts`, `setSecretLimit.ts`, `setRate.ts`, `setBlockWar.ts`, `setBill.ts`, `setBlockScout.ts`) | `setNationSetting`이 실행 시점 owner/nation/직책·permission을 다시 검사한다. 전쟁 설정 잔여 횟수 차감과 임관 잠금 검사를 현재 ENGINE state에서 수행하고, 공지는 logical game time과 author snapshot을 국가 meta와 같은 transaction에 저장한다 (`app/game-engine/src/turn/nationSettingMutation.ts`, `worldCommandHandler.ts`). |
|
||||
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessEngineAuthedInputProcedure`; API는 현재 화면값과 unit-set 기반 입력 보조만 수행하고 actor-bound semantic delta와 명시적 nullable revision을 전달 (`app/game-api/src/router/npc/index.ts`) | `setNpcPolicy`가 실행 시점 owner/nation/permission, strict CAS, 현재 troop/city membership, priority와 numeric policy를 검증하고 logical setter snapshot과 delta만 반영한다 (`app/game-engine/src/turn/npcPolicyMutation.ts`, `worldCommandHandler.ts`). |
|
||||
|
||||
합계 **26개 route**다.
|
||||
합계 **36개 route**다.
|
||||
|
||||
## 혼합 또는 validation 이관이 먼저 필요한 route
|
||||
|
||||
| route | 현재 procedure / outer transaction | 보류 근거 |
|
||||
| --- | --- | --- |
|
||||
| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts:318-372`) | `scout`/`raiseInvader`는 `messageRespond` ENGINE command가 처리하지만 `noAggression`/`cancelNA`/`stopWar`는 API transaction의 `respondToDiplomaticMessage` 경로가 처리한다. route 전체를 ENGINE-owned로 보지 않는다. |
|
||||
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, outer 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. `setNationMeta`는 `_updatedAt` CAS만 검사하므로 actor/permission과 합성 의미를 ENGINE으로 옮겨야 한다 (`app/game-api/src/router/nation/shared.ts:431-458`, `app/game-engine/src/turn/worldCommandHandler.ts:499-542`). |
|
||||
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, outer 있음 (`app/game-api/src/router/npc/index.ts:545-812`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 `setNationMeta` CAS를 사용한다. ENGINE은 권한/합성 의미를 소유하지 않는다. |
|
||||
| `tournament.join`, `tournament.placeBet` | `authedProcedure`, outer 있음 (`app/game-api/src/router/tournament/index.ts:393-458`, `:515-620`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다. 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. |
|
||||
|
||||
합계 **13개 route**다.
|
||||
합계 **3개 route**다.
|
||||
|
||||
## 기존에 API outer transaction이 없던 ENGINE route
|
||||
|
||||
@@ -84,6 +84,12 @@ selection-pool create/reselect는 client request ID가 있을 때
|
||||
`$transaction`을 호출하지 않고 기존 형식의 stable ENGINE request ID를 전달한다.
|
||||
- `app/game-api/test/nationPersonnelRouter.test.ts`: nation personnel route의 동일 계약을
|
||||
검증한다.
|
||||
- `app/game-api/test/nationSettingRouter.test.ts`, `nationHtmlRouter.test.ts`,
|
||||
`scoutBlockRouter.test.ts`: 일곱 국가 설정이 API outer transaction 없이 인증 actor와
|
||||
semantic intent를 전달하고 ENGINE 거절을 그대로 매핑하는지 검증한다.
|
||||
- `app/game-api/test/npcPolicyRouter.test.ts`,
|
||||
`app/game-engine/test/npcPolicyLifecycle.test.ts`: NPC policy의 명시적 nullable CAS,
|
||||
실행 시점 권한과 delta merge/replay 경계를 검증한다.
|
||||
- `app/game-api/test/troopRouter.test.ts`: troop mutation의 동일 계약을 검증한다.
|
||||
- `app/game-api/test/auctionRouter.test.ts`: auction mutation이 API transaction 없이
|
||||
daemon command와 Redis timer projection을 완료하는 계약을 검증한다.
|
||||
@@ -94,7 +100,8 @@ selection-pool create/reselect는 client request ID가 있을 때
|
||||
validation과 ENGINE의 vote insert/reward/idempotency 경계를 검증한다. API outer/journal
|
||||
제거는 아직 검증 대상이 아니다.
|
||||
- raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`와
|
||||
`openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationMeta`,
|
||||
`openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationSetting`,
|
||||
`requestNpcPolicyMutation`,
|
||||
`adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`,
|
||||
`requestNpcPossessionCommand` caller 검색을 함께 실행해 helper 경유 route를 놓치지
|
||||
않는다.
|
||||
|
||||
@@ -33,10 +33,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
|
||||
|
||||
| 분류 | 수 | route |
|
||||
| --- | ---: | --- |
|
||||
| durable journal | 23 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
|
||||
| durable journal | 13 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
|
||||
| separate access journal | 1 | `public.recordAccess` |
|
||||
| explicit no realtime consumer | 14 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` |
|
||||
| engine owned | 27 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
|
||||
| engine owned | 37 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`, `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
|
||||
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
|
||||
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
|
||||
@@ -55,7 +55,6 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
|
||||
| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:<mailbox>` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
|
||||
| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
|
||||
| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
|
||||
| nation metadata 7개 및 NPC policy 3개 | `nation.content:<nation>`, `dashboard.global:0`; notice만 추가로 `front.nation:<nation>` | 해당 국가 context/command/board, notice front status | `updateNationMeta()` 성공 뒤 API 표식을 사용하고, ENGINE 소유 transaction도 nation 변화와 `dashboard.global`을 함께 기록한다. 두 input-event의 업무 원자성은 기존 saga지만 source coverage는 ENGINE commit으로 보존된다. |
|
||||
| general reserved turn 4개 | `reserved.general:<general>`, `dashboard.global:0` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. |
|
||||
| vote 4개 | 기존 `front.general`/`front.global` | front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. |
|
||||
| `public.recordAccess` | `access.general:<general>` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. |
|
||||
@@ -81,9 +80,9 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
|
||||
|
||||
## 남은 업무 원자성 gap과 coverage 판정
|
||||
|
||||
1. nation metadata/NPC policy의 API input-event와 ENGINE input-event는 여전히 하나의
|
||||
업무 transaction이 아니다. 다만 실제 state 소유 ENGINE commit이 precise entity와
|
||||
`dashboard.global`을 기록하므로 revision-first source coverage는 빠지지 않는다.
|
||||
1. 국가 설정 7개와 NPC policy 3개는 API outer input-event를 제거했다. semantic ENGINE
|
||||
command가 actor 권한, 현재 state와 mutation을 검증하고 국가 저장·ENGINE input-event·
|
||||
`nation.content`/`front.nation`/`dashboard.global` journal을 한 transaction에서 commit한다.
|
||||
2. inheritance/tournament command 일부는 보상 가능한 saga다. tournament payload와
|
||||
profile source revision 자체는 API store, 월 자동 개막, runtime clock shift 모두 공통
|
||||
Lua writer 한 번으로 원자화했다.
|
||||
|
||||
@@ -130,17 +130,25 @@ export type TurnDaemonCommand =
|
||||
deltaMinutes: number;
|
||||
}
|
||||
| { type: 'getStatus'; requestId?: string }
|
||||
| { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string }
|
||||
| { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number }
|
||||
| { type: 'troopExit'; requestId?: string; generalId: number }
|
||||
| { type: 'troopCreate'; requestId?: string; userId: string; generalId: number; troopName: string }
|
||||
| { type: 'troopJoin'; requestId?: string; userId: string; generalId: number; troopId: number }
|
||||
| { type: 'troopExit'; requestId?: string; userId: string; generalId: number }
|
||||
| {
|
||||
type: 'troopKick';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
troopId: number;
|
||||
targetGeneralId: number;
|
||||
}
|
||||
| { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string }
|
||||
| {
|
||||
type: 'troopRename';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
troopId: number;
|
||||
troopName: string;
|
||||
}
|
||||
| { type: 'ensureDieOnPrestartStatus'; requestId?: string; userId: string; generalId: number }
|
||||
| { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number }
|
||||
| { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number }
|
||||
@@ -153,10 +161,11 @@ export type TurnDaemonCommand =
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
}
|
||||
| { type: 'vacation'; requestId?: string; generalId: number }
|
||||
| { type: 'vacation'; requestId?: string; userId: string; generalId: number }
|
||||
| {
|
||||
type: 'setMySetting';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
settings: {
|
||||
tnmt?: number;
|
||||
@@ -170,7 +179,13 @@ export type TurnDaemonCommand =
|
||||
use_auto_nation_capital?: number;
|
||||
};
|
||||
}
|
||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||
| {
|
||||
type: 'dropItem';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
itemType: 'horse' | 'weapon' | 'book' | 'item';
|
||||
}
|
||||
| {
|
||||
type: 'auctionFinalize';
|
||||
requestId?: string;
|
||||
@@ -181,6 +196,7 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||
amount: number;
|
||||
@@ -192,14 +208,16 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'changePermission';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
isAmbassador: boolean;
|
||||
targetGeneralIds: number[];
|
||||
}
|
||||
| { type: 'kick'; requestId?: string; generalId: number; destGeneralId: number }
|
||||
| { type: 'kick'; requestId?: string; userId: string; generalId: number; destGeneralId: number }
|
||||
| {
|
||||
type: 'appoint';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
destGeneralId: number;
|
||||
destCityId: number;
|
||||
@@ -239,17 +257,38 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'voteReward';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
voteId: number;
|
||||
generalId: number;
|
||||
selection: number[];
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
type: 'setNationSetting';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
nationId: number;
|
||||
updates: Record<string, unknown>;
|
||||
expectedUpdatedAt?: string;
|
||||
mutation:
|
||||
| { kind: 'notice'; message: string }
|
||||
| { kind: 'scoutMessage'; message: string }
|
||||
| { kind: 'rate'; amount: number }
|
||||
| { kind: 'bill'; amount: number }
|
||||
| { kind: 'secretLimit'; amount: number }
|
||||
| { kind: 'blockWar'; value: boolean }
|
||||
| { kind: 'blockScout'; value: boolean };
|
||||
}
|
||||
| {
|
||||
type: 'setNpcPolicy';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
nationId: number;
|
||||
expectedUpdatedAt: string | null;
|
||||
mutation:
|
||||
| { kind: 'nationPolicy'; values: Record<string, unknown> }
|
||||
| { kind: 'nationPriority'; priority: string[] }
|
||||
| { kind: 'generalPriority'; priority: string[] };
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
@@ -377,6 +416,7 @@ export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
auctionId: number;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
@@ -605,17 +645,33 @@ export type TurnDaemonCommandResult =
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
type: 'setNationSetting';
|
||||
ok: true;
|
||||
nationId: number;
|
||||
updatedAt: string;
|
||||
availableCnt?: number;
|
||||
}
|
||||
| {
|
||||
type: 'setNationSetting';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'NOT_FOUND' | 'PRECONDITION_FAILED';
|
||||
nationId?: number;
|
||||
reason: string;
|
||||
currentUpdatedAt?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'setNpcPolicy';
|
||||
ok: true;
|
||||
nationId: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
type: 'setNpcPolicy';
|
||||
ok: false;
|
||||
nationId: number;
|
||||
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT';
|
||||
reason: string;
|
||||
currentUpdatedAt?: string;
|
||||
nationId?: number;
|
||||
currentUpdatedAt?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
|
||||
@@ -46,10 +46,10 @@ export interface AcceptScoutResolveContext<
|
||||
|
||||
const differentDestGeneral = (destGeneralId: number): Constraint => ({
|
||||
name: 'differentDestGeneral',
|
||||
requires: (ctx) => [
|
||||
{ kind: 'general', id: ctx.actorId },
|
||||
{ kind: 'destGeneral', id: destGeneralId },
|
||||
],
|
||||
// Ref keeps only the recruiter id/nation snapshot in the letter. The
|
||||
// recruiter can be deleted before the receiver accepts it, so this
|
||||
// self-check must not turn the missing live recruiter into a dependency.
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx) =>
|
||||
ctx.actorId === destGeneralId
|
||||
? { kind: 'deny', reason: '본인의 등용장을 수락할 수 없습니다.' }
|
||||
@@ -74,7 +74,6 @@ export class ActionResolver<
|
||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||
|
||||
if (!destNation) throw new Error('Target nation not found.');
|
||||
if (!destGeneral) throw new Error('Recruiter not found.');
|
||||
|
||||
// 1. Logs
|
||||
const destNationName = destNation.name;
|
||||
@@ -96,79 +95,102 @@ export class ActionResolver<
|
||||
format: LogFormat.MONTH,
|
||||
});
|
||||
|
||||
// 2. Recruiter Rewards
|
||||
const recruiterExperience = destGeneral.experience + 100;
|
||||
const recruiterDedication = destGeneral.dedication + 100;
|
||||
// 2. Recruiter Rewards. Ref resolves a deleted recruiter as a dummy
|
||||
// general: joining still succeeds, while recruiter-only rewards and
|
||||
// logs are discarded.
|
||||
const belong = readMetaNumberFromUnknown(general.meta, 'belong') ?? 0;
|
||||
const maxBelong = readMetaNumberFromUnknown(general.meta, 'max_belong') ?? 0;
|
||||
const recruiterExpLevel = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
|
||||
recruiterExperience < 1_000
|
||||
? Math.trunc(recruiterExperience / 100)
|
||||
: Math.trunc(Math.sqrt(recruiterExperience / 10))
|
||||
)
|
||||
);
|
||||
const recruiterDedicationLevel = Math.max(
|
||||
0,
|
||||
Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10))
|
||||
);
|
||||
const previousRecruiterExpLevel = typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0;
|
||||
const previousRecruiterDedicationLevel =
|
||||
typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0;
|
||||
effects.push(
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
experience: recruiterExperience,
|
||||
dedication: recruiterDedication,
|
||||
meta: {
|
||||
...destGeneral.meta,
|
||||
explevel: recruiterExpLevel,
|
||||
dedlevel: recruiterDedicationLevel,
|
||||
},
|
||||
},
|
||||
destGeneral.id
|
||||
)
|
||||
);
|
||||
if (recruiterExpLevel !== previousRecruiterExpLevel) {
|
||||
const josaRo = JosaUtil.pick(String(recruiterExpLevel), '로');
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterExpLevel > previousRecruiterExpLevel
|
||||
? `<C>Lv ${recruiterExpLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${recruiterExpLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
if (destGeneral) {
|
||||
const recruiterExperience = destGeneral.experience + 100;
|
||||
const recruiterDedication = destGeneral.dedication + 100;
|
||||
const recruiterExpLevel = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
|
||||
recruiterExperience < 1_000
|
||||
? Math.trunc(recruiterExperience / 100)
|
||||
: Math.trunc(Math.sqrt(recruiterExperience / 10))
|
||||
)
|
||||
);
|
||||
}
|
||||
if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) {
|
||||
const maxDedicationLevel = this.env.maxDedicationLevel ?? 30;
|
||||
const dedicationLevelText =
|
||||
recruiterDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`;
|
||||
const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400);
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
const recruiterDedicationLevel = Math.max(
|
||||
0,
|
||||
Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10))
|
||||
);
|
||||
const previousRecruiterExpLevel =
|
||||
typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0;
|
||||
const previousRecruiterDedicationLevel =
|
||||
typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0;
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterDedicationLevel > previousRecruiterDedicationLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
createGeneralPatchEffect(
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
experience: recruiterExperience,
|
||||
dedication: recruiterDedication,
|
||||
meta: {
|
||||
...destGeneral.meta,
|
||||
explevel: recruiterExpLevel,
|
||||
dedlevel: recruiterDedicationLevel,
|
||||
},
|
||||
},
|
||||
destGeneral.id
|
||||
)
|
||||
);
|
||||
if (recruiterExpLevel !== previousRecruiterExpLevel) {
|
||||
const recruiterLevelJosaRo = JosaUtil.pick(String(recruiterExpLevel), '로');
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterExpLevel > previousRecruiterExpLevel
|
||||
? `<C>Lv ${recruiterExpLevel}</>${recruiterLevelJosaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${recruiterExpLevel}</>${recruiterLevelJosaRo} <R>레벨다운</>!`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) {
|
||||
const maxDedicationLevel = this.env.maxDedicationLevel ?? 30;
|
||||
const dedicationLevelText =
|
||||
recruiterDedicationLevel === 0
|
||||
? '무품관'
|
||||
: `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`;
|
||||
const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400);
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
recruiterDedicationLevel > previousRecruiterDedicationLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
legacyFlushGroup: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
effects.push(
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Betrayal Logic
|
||||
@@ -285,22 +307,6 @@ export class ActionResolver<
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
effects.push(
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
}),
|
||||
createLogEffect(`<Y>${generalName}</> 등용에 성공`, {
|
||||
scope: LogScope.GENERAL,
|
||||
generalId: destGeneral.id,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
legacyFlushGroup: 1,
|
||||
})
|
||||
);
|
||||
|
||||
const deletedTroopIds: number[] = [];
|
||||
if (general.troopId === general.id) {
|
||||
|
||||
@@ -81,6 +81,14 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport)
|
||||
|
||||
const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0);
|
||||
|
||||
const isAssignedToOfficerCity = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
cityId: number
|
||||
): boolean =>
|
||||
['officerCity', 'officer_city', 'officerCityId'].some(
|
||||
(key) => getMetaNumber(general.meta, key, Number.NaN) === cityId
|
||||
);
|
||||
|
||||
// REF-COMPAT:BEGIN ref-dead-split-int-binding
|
||||
const increaseDeadCounter = (city: City, delta: number): void => {
|
||||
// Ref binds each `dead + %i` increment as an integer before MariaDB adds
|
||||
@@ -452,6 +460,21 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
affectedNations.add(attackerNation);
|
||||
}
|
||||
|
||||
if (!nationCollapsed) {
|
||||
// Ref updates every row assigned to the captured city without filtering
|
||||
// by nation, current city, or existing officer level.
|
||||
for (const general of generals) {
|
||||
if (!isAssignedToOfficerCity(general, defenderCity.id)) {
|
||||
continue;
|
||||
}
|
||||
general.officerLevel = 1;
|
||||
general.meta.officerCity = 0;
|
||||
general.meta.officer_city = 0;
|
||||
general.meta.officerCityId = 0;
|
||||
affectedGenerals.add(general);
|
||||
}
|
||||
}
|
||||
|
||||
// 수도 함락 시 수도 이전 및 내부 사기/자원 페널티.
|
||||
if (!nationCollapsed && defenderNation && defenderNation.capitalCityId === defenderCity.id) {
|
||||
const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, input.map);
|
||||
@@ -528,6 +551,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
defenderCity.supplyState = 1;
|
||||
defenderCity.frontState = 0;
|
||||
defenderCity.meta.term = 0;
|
||||
defenderCity.meta.officer_set = 0;
|
||||
defenderCity.agriculture = round(defenderCity.agriculture * 0.7);
|
||||
defenderCity.commerce = round(defenderCity.commerce * 0.7);
|
||||
defenderCity.security = round(defenderCity.security * 0.7);
|
||||
|
||||
@@ -323,6 +323,76 @@ describe('war aftermath', () => {
|
||||
expect(defenderCity.meta.dead).toBe(111);
|
||||
});
|
||||
|
||||
it('clears every captured-city officer assignment when the defending nation survives', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
const foreignNation = buildNation(3);
|
||||
defenderNation.capitalCityId = 3;
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
const defenderCapital = buildCity(3, 2);
|
||||
const foreignCity = buildCity(4, 3);
|
||||
defenderCity.meta.officer_set = 7;
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
const camelAssigned = buildGeneral(2, 2, 3);
|
||||
camelAssigned.officerLevel = 4;
|
||||
camelAssigned.meta.officerCity = defenderCity.id;
|
||||
const snakeAssigned = buildGeneral(3, 1, 1);
|
||||
snakeAssigned.officerLevel = 10;
|
||||
snakeAssigned.meta.officer_city = defenderCity.id;
|
||||
const idAssigned = buildGeneral(4, 3, 4);
|
||||
idAssigned.officerLevel = 0;
|
||||
idAssigned.meta.officerCityId = defenderCity.id;
|
||||
const unrelated = buildGeneral(5, 2, 2);
|
||||
unrelated.officerLevel = 3;
|
||||
unrelated.meta.officerCity = defenderCapital.id;
|
||||
unrelated.meta.officer_city = defenderCapital.id;
|
||||
unrelated.meta.officerCityId = defenderCapital.id;
|
||||
|
||||
const outcome = resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: true,
|
||||
reports: [],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation, foreignNation],
|
||||
cities: [attackerCity, defenderCity, defenderCapital, foreignCity],
|
||||
generals: [attacker, camelAssigned, snakeAssigned, idAssigned, unrelated],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
expect(outcome.conquest?.nationCollapsed).toBe(false);
|
||||
expect(defenderCity.meta.officer_set).toBe(0);
|
||||
for (const assigned of [camelAssigned, snakeAssigned, idAssigned]) {
|
||||
expect(assigned).toMatchObject({
|
||||
officerLevel: 1,
|
||||
meta: { officerCity: 0, officer_city: 0, officerCityId: 0 },
|
||||
});
|
||||
}
|
||||
expect(unrelated).toMatchObject({
|
||||
officerLevel: 3,
|
||||
meta: {
|
||||
officerCity: defenderCapital.id,
|
||||
officer_city: defenderCapital.id,
|
||||
officerCityId: defenderCapital.id,
|
||||
},
|
||||
});
|
||||
expect(outcome.generals.map((general) => general.id)).toEqual(
|
||||
expect.arrayContaining([attacker.id, camelAssigned.id, snakeAssigned.id, idAssigned.id])
|
||||
);
|
||||
});
|
||||
|
||||
it('logs emergency relocation when a surviving nation loses its capital', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
|
||||
Reference in New Issue
Block a user