feat: 응답 가능한 서신과 턴 시간 호환 이관
등용장과 이민족 선택 응답을 turn daemon transaction으로 연결하고 Ref의 통일 이후 상태 전이와 수신자별 메시지 저장 규칙을 보존한다.\n\n유산 턴 시간 변경을 nextTurnTimeBase 기반 결정적 계산으로 바로잡고 API, 엔진, Chromium 회귀를 추가한다.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
buildResetCost,
|
||||
@@ -153,17 +154,24 @@ const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
||||
return zones;
|
||||
};
|
||||
|
||||
const alignToTurnBase = (time: Date, tickMinutes: number): Date => {
|
||||
const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0);
|
||||
const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000);
|
||||
const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes);
|
||||
return new Date(base.getTime() + alignedMinutes * 60000);
|
||||
const formatTurnTimeBaseLabel = (value: number): string => {
|
||||
const wholeSeconds = Math.trunc(value);
|
||||
const hours = String(Math.trunc(wholeSeconds / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.trunc((wholeSeconds % 3600) / 60)).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const formatTimeLabel = (value: Date): string => {
|
||||
const hours = String(value.getHours()).padStart(2, '0');
|
||||
const minutes = String(value.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
export const resolveResetTurnTimeBase = (options: {
|
||||
hiddenSeed: string | number;
|
||||
userId: string;
|
||||
previousTurnTimeBase: string | number;
|
||||
tickSeconds: number;
|
||||
}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => {
|
||||
const rng = new LiteHashDRBG(
|
||||
simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase)
|
||||
);
|
||||
const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds);
|
||||
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
|
||||
};
|
||||
|
||||
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
|
||||
@@ -491,7 +499,7 @@ export const inheritRouter = router({
|
||||
|
||||
const worldState = await resolveWorld(ctx);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
||||
if (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 0) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
||||
}
|
||||
|
||||
@@ -553,7 +561,7 @@ export const inheritRouter = router({
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, meta: true, turnTime: true },
|
||||
select: { id: true, meta: true, turnTick: true },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
@@ -568,21 +576,30 @@ export const inheritRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
||||
}
|
||||
|
||||
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
||||
const baseTime = alignToTurnBase(general.turnTime ?? new Date(), tickMinutes);
|
||||
const seedBase = `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetTurnTime:${userId}:${general.id}`;
|
||||
const rng = new LiteHashDRBG(seedBase);
|
||||
const offsetMinutes = rng.nextFloat1() * tickMinutes;
|
||||
let nextTurnTime = new Date(baseTime.getTime() + offsetMinutes * 60000);
|
||||
if (nextTurnTime.getTime() <= Date.now()) {
|
||||
nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000);
|
||||
}
|
||||
const generalMeta = asRecord(general.meta);
|
||||
const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0;
|
||||
const seedTurnTime =
|
||||
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
|
||||
? rawSeedTurnTime
|
||||
: typeof rawSeedTurnTime === 'bigint'
|
||||
? Number(rawSeedTurnTime)
|
||||
: 0;
|
||||
const hiddenSeed =
|
||||
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||
? worldMeta.hiddenSeed
|
||||
: 'inherit';
|
||||
const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({
|
||||
hiddenSeed,
|
||||
userId,
|
||||
previousTurnTimeBase: seedTurnTime,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
turnTime: nextTurnTime.toISOString(),
|
||||
meta: {
|
||||
...asRecord(general.meta),
|
||||
...generalMeta,
|
||||
inheritResetTurnTime: nextLevel,
|
||||
nextTurnTimeBase,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -592,9 +609,9 @@ export const inheritRouter = router({
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${formatTimeLabel(nextTurnTime)} 적용`
|
||||
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용`
|
||||
);
|
||||
return { ok: true, nextTurnTime: nextTurnTime.toISOString() };
|
||||
return { ok: true, nextTurnTimeBase, nextTurnTimeLabel };
|
||||
}),
|
||||
resetStat: authedProcedure
|
||||
.input(
|
||||
|
||||
@@ -72,10 +72,7 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||
return value === true || value === 1 || value === '1';
|
||||
};
|
||||
|
||||
const markMessageMailboxes = (
|
||||
ctx: Pick<GameApiContext, 'changeJournal'>,
|
||||
mailboxes: Iterable<number>
|
||||
): void => {
|
||||
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
|
||||
for (const mailbox of mailboxes) {
|
||||
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
||||
}
|
||||
@@ -328,6 +325,27 @@ export const messagesRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
const message = await fetchMessageById(ctx.db, input.messageId);
|
||||
if (!message) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||
}
|
||||
const action = message.payload.option?.action;
|
||||
if (action === 'scout' || action === 'raiseInvader') {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const commandResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'messageRespond',
|
||||
userId: ctx.auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
});
|
||||
if (!commandResult || commandResult.type !== 'messageRespond') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '메시지 응답 처리에 실패했습니다.' });
|
||||
}
|
||||
return { result: commandResult.ok, reason: commandResult.reason };
|
||||
}
|
||||
const result = await respondToDiplomaticMessage({
|
||||
db: ctx.db,
|
||||
actor: general,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js';
|
||||
|
||||
describe('inherit reset turn time Ref compatibility', () => {
|
||||
it('matches the Ref PHP deterministic seed, offset, and displayed minute', () => {
|
||||
const result = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: 600,
|
||||
});
|
||||
|
||||
expect(result.nextTurnTimeBase).toBeCloseTo(302.5143852464758, 12);
|
||||
expect(result.nextTurnTimeLabel).toBe('00:05');
|
||||
});
|
||||
|
||||
it('uses the prior pending base as the next deterministic seed input', () => {
|
||||
const first = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: 600,
|
||||
});
|
||||
const second = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
previousTurnTimeBase: first.nextTurnTimeBase,
|
||||
tickSeconds: 600,
|
||||
});
|
||||
|
||||
expect(second.nextTurnTimeBase).not.toBe(first.nextTurnTimeBase);
|
||||
expect(second.nextTurnTimeBase).toBeGreaterThanOrEqual(0);
|
||||
expect(second.nextTurnTimeBase).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
@@ -278,6 +279,43 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 2_000,
|
||||
general: buildGeneral({ meta: { nextTurnTimeBase: 123_456 } }),
|
||||
});
|
||||
const expected = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'test-seed',
|
||||
userId: 'user-1',
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
|
||||
ok: true,
|
||||
...expected,
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: {
|
||||
meta: {
|
||||
nextTurnTimeBase: expected.nextTurnTimeBase,
|
||||
inheritResetTurnTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||
|
||||
|
||||
@@ -565,6 +565,70 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['scout', 'raiseInvader'] as const)(
|
||||
'dispatches a private %s response through the durable engine command',
|
||||
async (action) => {
|
||||
const messageRow = {
|
||||
id: 29,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: 8,
|
||||
dest: general.id,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '제안자',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
text: '응답할 메시지',
|
||||
option: { action },
|
||||
},
|
||||
};
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
type: 'messageRespond' as const,
|
||||
ok: true,
|
||||
generalId: general.id,
|
||||
messageId: messageRow.id,
|
||||
action,
|
||||
reason: 'success',
|
||||
}));
|
||||
const { caller } = buildContext(
|
||||
{ $queryRaw: vi.fn(async () => [messageRow]) },
|
||||
{ turnDaemon: { requestCommand } }
|
||||
);
|
||||
|
||||
const result = await caller.messages.respond({
|
||||
generalId: general.id,
|
||||
messageId: messageRow.id,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'messageRespond',
|
||||
userId: auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: messageRow.id,
|
||||
response: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const buildDiplomaticContext = (options?: {
|
||||
action?: 'noAggression' | 'cancelNA' | 'stopWar';
|
||||
actorNationId?: number;
|
||||
@@ -634,10 +698,10 @@ describe('messages router missing-flow compatibility', () => {
|
||||
let insertedId = 100;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall === 1) {
|
||||
if (rawCall <= 2) {
|
||||
return [messageRow];
|
||||
}
|
||||
if (rawCall >= 4) {
|
||||
if (rawCall >= 5) {
|
||||
insertedId += 1;
|
||||
return [{ id: insertedId }];
|
||||
}
|
||||
@@ -693,84 +757,87 @@ describe('messages router missing-flow compatibility', () => {
|
||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const cityUpdate = vi.fn(async () => ({}));
|
||||
const changeJournal = new ChangeJournal();
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actor.id ? actor : where.id === proposer.id ? proposer : null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
const { caller } = buildContext(
|
||||
{
|
||||
general: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actor.id ? actor : where.id === proposer.id ? proposer : null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actorNationId
|
||||
? {
|
||||
id: actorNationId,
|
||||
name: '위',
|
||||
color: '#fff',
|
||||
capitalCityId: 10,
|
||||
chiefGeneralId: actor.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
}
|
||||
: where.id === proposerNationId
|
||||
? {
|
||||
id: proposerNationId,
|
||||
name: '촉',
|
||||
color: '#000',
|
||||
capitalCityId: 20,
|
||||
chiefGeneralId: proposer.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } },
|
||||
}
|
||||
: null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: nationUpdate,
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 10,
|
||||
nationId: actorNationId,
|
||||
supplyState: 1,
|
||||
})),
|
||||
findMany: vi.fn(async () => options?.cities ?? []),
|
||||
update: cityUpdate,
|
||||
},
|
||||
diplomacy: {
|
||||
findUnique: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
|
||||
}) =>
|
||||
diplomacyRows.find(
|
||||
(row) =>
|
||||
row.srcNationId === where.srcNationId_destNationId.srcNationId &&
|
||||
row.destNationId === where.srcNationId_destNationId.destNationId
|
||||
) ?? null
|
||||
),
|
||||
findMany: vi.fn(async () => diplomacyRows),
|
||||
update: diplomacyUpdate,
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actorNationId
|
||||
? {
|
||||
id: actorNationId,
|
||||
name: '위',
|
||||
color: '#fff',
|
||||
capitalCityId: 10,
|
||||
chiefGeneralId: actor.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
}
|
||||
: where.id === proposerNationId
|
||||
? {
|
||||
id: proposerNationId,
|
||||
name: '촉',
|
||||
color: '#000',
|
||||
capitalCityId: 20,
|
||||
chiefGeneralId: proposer.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } },
|
||||
}
|
||||
: null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: nationUpdate,
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 10,
|
||||
nationId: actorNationId,
|
||||
supplyState: 1,
|
||||
})),
|
||||
findMany: vi.fn(async () => options?.cities ?? []),
|
||||
update: cityUpdate,
|
||||
},
|
||||
diplomacy: {
|
||||
findUnique: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
|
||||
}) =>
|
||||
diplomacyRows.find(
|
||||
(row) =>
|
||||
row.srcNationId === where.srcNationId_destNationId.srcNationId &&
|
||||
row.destNationId === where.srcNationId_destNationId.destNationId
|
||||
) ?? null
|
||||
),
|
||||
findMany: vi.fn(async () => diplomacyRows),
|
||||
update: diplomacyUpdate,
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
}, { changeJournal });
|
||||
{ changeJournal }
|
||||
);
|
||||
return {
|
||||
caller,
|
||||
actor,
|
||||
@@ -811,7 +878,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
where: { id: { in: [31] } },
|
||||
data: { validUntil: expect.any(Date) },
|
||||
});
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(8);
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
it('declines a diplomatic prompt without changing diplomacy', async () => {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { asNumber, asRecord, 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';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import { createRaiseInvaderHandler } from './monthlyInvaderAction.js';
|
||||
import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
|
||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||
import type { TurnEvent } from './types.js';
|
||||
|
||||
type ActionableMessageType = 'scout' | 'raiseInvader';
|
||||
|
||||
interface MessageRow {
|
||||
id: number;
|
||||
mailbox: number;
|
||||
type: string;
|
||||
validUntil: Date;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
export interface ActionableMessageResponseResult {
|
||||
ok: boolean;
|
||||
action?: ActionableMessageType;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const parsePayload = (value: unknown): MessagePayload =>
|
||||
(typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload;
|
||||
|
||||
const systemTarget: MessageDraft['src'] = {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
|
||||
const queuePrivateNotice = (
|
||||
world: InMemoryTurnWorld,
|
||||
destination: MessagePayload['dest'],
|
||||
text: string,
|
||||
time: Date
|
||||
): void => {
|
||||
world.queueMessage({
|
||||
msgType: 'private',
|
||||
src: systemTarget,
|
||||
dest: destination,
|
||||
text,
|
||||
time,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
sendDestOnly: true,
|
||||
});
|
||||
};
|
||||
|
||||
const invalidateMessageIds = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
ids: number[],
|
||||
now: Date
|
||||
): Promise<void> => {
|
||||
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||
if (uniqueIds.length === 0) return;
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: now,
|
||||
validUntilTick: BigInt(world.dateToGameTick(now)),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const validateActor = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
}): Promise<Date> => {
|
||||
const actor = options.world.getGeneralById(options.generalId);
|
||||
if (!actor || actor.userId !== options.userId) {
|
||||
throw new Error('messageRespond general owner does not match command user.');
|
||||
}
|
||||
if (!options.requestId) return new Date();
|
||||
const event = await options.db.inputEvent.findUnique({
|
||||
where: { requestId: options.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
|
||||
});
|
||||
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
||||
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
||||
throw new Error('ENGINE input event actor or type does not match messageRespond.');
|
||||
}
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
const fetchMessageForUpdate = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
messageId: number,
|
||||
now: Date
|
||||
): 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
|
||||
FROM message
|
||||
WHERE id = ${messageId}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const respondToScout = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
executor: ImmediateGeneralActionExecutor;
|
||||
actorId: number;
|
||||
response: boolean;
|
||||
row: MessageRow;
|
||||
payload: MessagePayload;
|
||||
now: Date;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const { db, world, executor, actorId, response, row, payload, now } = options;
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (asRecord(payload.option).used === true) {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
const sourceNationName = payload.src.nationName;
|
||||
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
||||
if (response) {
|
||||
const execution = await executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: actorId,
|
||||
args: {
|
||||
destNationId: payload.src.nationId,
|
||||
destGeneralId: payload.src.generalId,
|
||||
},
|
||||
rng: new RandUtil(new LiteHashDRBG(`messageRespond:scout:${row.id}`)),
|
||||
});
|
||||
if (!execution.ok) {
|
||||
return { ok: true, action: 'scout', reason: execution.reason ?? '등용 수락 불가.' };
|
||||
}
|
||||
|
||||
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
SELECT id
|
||||
FROM message
|
||||
WHERE mailbox = ${payload.src.generalId}
|
||||
AND type = 'private'
|
||||
AND dest = mailbox
|
||||
AND id <> ${row.id}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
AND message->'option'->>'action' = 'scout'
|
||||
FOR UPDATE
|
||||
`);
|
||||
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
||||
world.queueMessage({
|
||||
msgType: 'private',
|
||||
src: payload.src,
|
||||
dest: payload.dest,
|
||||
text: `${sourceNationName}${sourceNationJosaRo} 등용 제의 수락`,
|
||||
time: now,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: { delete: row.id },
|
||||
sendDestOnly: true,
|
||||
});
|
||||
return { ok: true, action: 'scout', reason: 'success' };
|
||||
}
|
||||
|
||||
const destinationJosaYi = JosaUtil.pick(payload.dest.generalName, '이');
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: actorId,
|
||||
text: `${sourceNationName}${sourceNationJosaRo} 망명을 거부했습니다.`,
|
||||
});
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.PLAIN,
|
||||
generalId: payload.src.generalId,
|
||||
text: `<Y>${payload.dest.generalName}</>${destinationJosaYi} 등용을 거부했습니다.`,
|
||||
});
|
||||
await invalidateMessageIds(db, world, [row.id], now);
|
||||
world.queueMessage({
|
||||
msgType: 'private',
|
||||
src: payload.src,
|
||||
dest: payload.dest,
|
||||
text: `${sourceNationName}${sourceNationJosaRo} 등용 제의 거부`,
|
||||
time: now,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: { delete: row.id },
|
||||
sendDestOnly: true,
|
||||
});
|
||||
return { ok: true, action: 'scout', reason: 'success' };
|
||||
};
|
||||
|
||||
const respondToRaiseInvader = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
reservedTurns?: InMemoryReservedTurnStore;
|
||||
actorId: number;
|
||||
response: boolean;
|
||||
row: MessageRow;
|
||||
payload: MessagePayload;
|
||||
now: Date;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (asRecord(payload.option).used === true) {
|
||||
return { ok: false, action: 'raiseInvader', reason: '이미 사용하였습니다.' };
|
||||
}
|
||||
if (!response) {
|
||||
await invalidateMessageIds(db, world, [row.id], now);
|
||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||
}
|
||||
const state = world.getState();
|
||||
if (asNumber(state.meta.isunited ?? state.meta.isUnited, 0) !== 2) {
|
||||
const reason = '천하통일이 되지 않았습니다.';
|
||||
queuePrivateNotice(world, payload.dest, `${reason} 이민족 등장 불가.`, now);
|
||||
return { ok: false, action: 'raiseInvader', reason };
|
||||
}
|
||||
if (!reservedTurns) {
|
||||
throw new Error('RaiseInvader message response requires the reserved-turn store.');
|
||||
}
|
||||
const args = asRecord(payload.option).args;
|
||||
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
||||
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
||||
}
|
||||
const handler = createRaiseInvaderHandler({
|
||||
getWorld: () => world,
|
||||
reservedTurns,
|
||||
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
});
|
||||
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
||||
await handler(
|
||||
args,
|
||||
{
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
startyear: asNumber(state.meta.startYear, state.currentYear),
|
||||
currentEventID: 0,
|
||||
turnTime: now,
|
||||
},
|
||||
event
|
||||
);
|
||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||
};
|
||||
|
||||
export const respondToActionableMessage = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
reservedTurns?: InMemoryReservedTurnStore;
|
||||
executor: ImmediateGeneralActionExecutor;
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const acceptedAt = await validateActor(options);
|
||||
const now = options.world.getGameNow(acceptedAt);
|
||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||
const payload = parsePayload(row.message);
|
||||
const action = asRecord(payload.option).action;
|
||||
if (action === 'scout') {
|
||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||
}
|
||||
if (action === 'raiseInvader') {
|
||||
return await respondToRaiseInvader({ ...options, actorId: options.generalId, row, payload, now });
|
||||
}
|
||||
return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||
};
|
||||
@@ -111,6 +111,14 @@ const zInstantRetreat = z.object({
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zMessageRespond = z.object({
|
||||
type: z.literal('messageRespond'),
|
||||
userId: z.string().min(1),
|
||||
generalId: z.number().int().positive(),
|
||||
messageId: z.number().int().positive(),
|
||||
response: z.boolean(),
|
||||
});
|
||||
|
||||
const zVacation = z.object({
|
||||
type: z.literal('vacation'),
|
||||
generalId: zFiniteNumber,
|
||||
@@ -488,6 +496,14 @@ const normalizeInstantRetreat: CommandNormalizer<'instantRetreat'> = (envelope)
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope) => {
|
||||
const command = parseWith(zMessageRespond, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
|
||||
const command = parseWith(zVacation, envelope.command);
|
||||
if (!command) {
|
||||
@@ -705,6 +721,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus,
|
||||
buildNationCandidate: normalizeBuildNationCandidate,
|
||||
instantRetreat: normalizeInstantRetreat,
|
||||
messageRespond: normalizeMessageRespond,
|
||||
vacation: normalizeVacation,
|
||||
setMySetting: normalizeSetMySetting,
|
||||
dropItem: normalizeDropItem,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
asRecord,
|
||||
ChangeJournal,
|
||||
GAME_TICKS_PER_TURN,
|
||||
type CommittedReadModelInvalidation,
|
||||
type ReadModelDomain,
|
||||
type RealtimeReadModelChanges,
|
||||
@@ -1037,6 +1038,7 @@ export const createDatabaseTurnHooks = async (
|
||||
const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 };
|
||||
const readModelBaseline = createRealtimeReadModelBaseline(world);
|
||||
let worldReadModelBaseline = createWorldReadModelSignature(world);
|
||||
let persistedTickSeconds = world.getState().tickSeconds;
|
||||
const committedReceipts = new Map<bigint, CommittedReadModelChangeReceipt>();
|
||||
|
||||
const enqueueCommittedReceipt = (
|
||||
@@ -1159,6 +1161,54 @@ export const createDatabaseTurnHooks = async (
|
||||
data: worldStateUpdate,
|
||||
});
|
||||
|
||||
if (
|
||||
state.tickSeconds !== persistedTickSeconds &&
|
||||
commandCompletion?.result.type !== 'updateRuntimeSettings'
|
||||
) {
|
||||
const ticksPerSecond = BigInt(GAME_TICKS_PER_TURN / state.tickSeconds);
|
||||
const baseTime = state.clockBaseTime ?? state.lastTurnTime;
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET close_at = CAST(${baseTime} AS timestamp)
|
||||
+ (close_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((close_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond',
|
||||
updated_at = NOW()
|
||||
WHERE close_tick IS NOT NULL
|
||||
`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE message
|
||||
SET time = CASE
|
||||
WHEN time_tick IS NULL THEN time
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (time_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((time_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
||||
END,
|
||||
valid_until = CASE
|
||||
WHEN valid_until_tick IS NULL THEN valid_until
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
||||
END
|
||||
WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL
|
||||
`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET start_at = CASE
|
||||
WHEN start_tick IS NULL THEN start_at
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (start_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((start_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
||||
END,
|
||||
end_at = CASE
|
||||
WHEN end_tick IS NULL THEN end_at
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (end_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((end_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond'
|
||||
END
|
||||
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
for (const betting of pendingNationBettingOpens) {
|
||||
await persistNationBettingOpen(prisma, betting);
|
||||
}
|
||||
@@ -1530,7 +1580,8 @@ export const createDatabaseTurnHooks = async (
|
||||
return id;
|
||||
},
|
||||
},
|
||||
message
|
||||
message,
|
||||
{ sendDestOnly: message.sendDestOnly }
|
||||
);
|
||||
}
|
||||
if (options?.reservedTurns && persistedReservedTurnChanges) {
|
||||
@@ -1598,6 +1649,7 @@ export const createDatabaseTurnHooks = async (
|
||||
}
|
||||
applyRealtimeReadModelBaseline(readModelBaseline, changes);
|
||||
worldReadModelBaseline = persisted.worldReadModelSignature;
|
||||
persistedTickSeconds = state.tickSeconds;
|
||||
},
|
||||
readModelChanges: persisted.readModelChanges,
|
||||
journalWrite: persisted.journalWrite,
|
||||
|
||||
@@ -27,7 +27,9 @@ const resolveTickMinutes = (world: InMemoryTurnWorld, override?: number): number
|
||||
|
||||
const isWorldUnited = (world: InMemoryTurnWorld): boolean => {
|
||||
const meta = asRecord(world.getState().meta);
|
||||
return asNumber(meta.isunited ?? meta.isUnited, 0) !== 0;
|
||||
// Ref keeps the event game running at isunited=1. Only the post-unification
|
||||
// choice wait (2) and the completed invader game (3) stop month progress.
|
||||
return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2;
|
||||
};
|
||||
|
||||
export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
|
||||
@@ -2149,13 +2149,14 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
};
|
||||
|
||||
export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환';
|
||||
export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환' | 'che_등용수락';
|
||||
|
||||
export type ImmediateGeneralActionExecutor = {
|
||||
execute(input: {
|
||||
actionKey: ImmediateGeneralActionKey;
|
||||
generalId: number;
|
||||
rng: RandUtil;
|
||||
args?: Record<string, unknown>;
|
||||
refreshKillturn?: boolean;
|
||||
}): Promise<{ ok: boolean; reason?: string }>;
|
||||
};
|
||||
@@ -2182,7 +2183,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
});
|
||||
const generalModuleLoader = new GeneralTurnCommandLoader();
|
||||
const contextBuilders = new Map<string, ActionContextBuilder>();
|
||||
for (const actionKey of ['che_거병', 'che_접경귀환'] as const) {
|
||||
for (const actionKey of ['che_거병', 'che_접경귀환', 'che_등용수락'] as const) {
|
||||
if (!definitions.has(actionKey)) {
|
||||
continue;
|
||||
}
|
||||
@@ -2202,10 +2203,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
if (!definition) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
input.actionKey === 'che_거병'
|
||||
? '거병할 수 없는 모드입니다.'
|
||||
: '접경귀환을 사용할 수 없는 모드입니다.',
|
||||
reason: `${input.actionKey}을 실행할 수 없는 모드입니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2215,7 +2213,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
}
|
||||
const city = options.world.getCityById(general.cityId) ?? undefined;
|
||||
const nation = general.nationId > 0 ? options.world.getNationById(general.nationId) : null;
|
||||
const args = definition.parseArgs({});
|
||||
const args = definition.parseArgs(input.args ?? {});
|
||||
if (args === null) {
|
||||
return { ok: false, reason: '인자가 올바르지 않습니다.' };
|
||||
}
|
||||
@@ -2246,7 +2244,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
const failureText =
|
||||
definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ??
|
||||
`${reason} ${definition.name} 실패.`;
|
||||
if (input.actionKey === 'che_접경귀환') {
|
||||
if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') {
|
||||
options.world.pushLog({
|
||||
...createActionLog(failureText),
|
||||
generalId: general.id,
|
||||
@@ -2334,23 +2332,34 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
|
||||
const progressionLogs: LogEntryDraft[] = [];
|
||||
let nextGeneral = resolution.general as TurnGeneral;
|
||||
if (input.actionKey === 'che_거병') {
|
||||
const activeActionAmount =
|
||||
(
|
||||
definition as GeneralActionDefinition & {
|
||||
getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number;
|
||||
}
|
||||
).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0;
|
||||
const nextMeta = {
|
||||
...nextGeneral.meta,
|
||||
inherit_active_action:
|
||||
readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount,
|
||||
...(input.refreshKillturn ? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) } : {}),
|
||||
const activeActionAmount =
|
||||
(
|
||||
definition as GeneralActionDefinition & {
|
||||
getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number;
|
||||
}
|
||||
).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0;
|
||||
if (
|
||||
Number.isFinite(activeActionAmount) &&
|
||||
activeActionAmount !== 0 &&
|
||||
nextGeneral.userId &&
|
||||
nextGeneral.npcState < 2
|
||||
) {
|
||||
nextGeneral = {
|
||||
...nextGeneral,
|
||||
meta: {
|
||||
...nextGeneral.meta,
|
||||
inherit_active_action:
|
||||
readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount,
|
||||
...(input.refreshKillturn
|
||||
? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (input.actionKey === 'che_거병') {
|
||||
nextGeneral = applyLegacyGeneralProgression(
|
||||
{
|
||||
...nextGeneral,
|
||||
meta: nextMeta,
|
||||
lastTurn: {
|
||||
command: definition.name,
|
||||
arg: extractArgsRecord(args),
|
||||
@@ -2396,6 +2405,9 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
options.world.queueMessage(effect.draft);
|
||||
}
|
||||
}
|
||||
for (const troopId of resolution.deletedTroopIds ?? []) {
|
||||
options.world.removeTroop(troopId);
|
||||
}
|
||||
for (const log of [...resolution.logs, ...progressionLogs]) {
|
||||
options.world.pushLog(log);
|
||||
}
|
||||
|
||||
@@ -958,6 +958,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
auctionFinalizer: auctionFinalizer ?? undefined,
|
||||
auctionBidder: auctionBidder ?? undefined,
|
||||
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
|
||||
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
|
||||
});
|
||||
|
||||
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { PendingUnificationAuctionCancellation } from './types.js';
|
||||
import { queueYearbookSnapshot } from './yearbookHandler.js';
|
||||
|
||||
const UNIFIER_POINT = 2000;
|
||||
const INVADER_MESSAGE_OPTIONS = [
|
||||
{ args: [-2, -1.2, 15_000, -1], difficulty: '어려움' },
|
||||
{ args: [-2, -1.2, -1, -0.5], difficulty: '보통' },
|
||||
{ args: [-1, -1, -0.8, 0], difficulty: '쉬움' },
|
||||
] as const;
|
||||
|
||||
const buildUnificationLog = (nationName: string): LogEntryDraft => ({
|
||||
scope: LogScope.SYSTEM,
|
||||
@@ -118,6 +123,47 @@ export const createUnificationHandler = (options: {
|
||||
}
|
||||
world.pushLog(buildUnificationLog(winner.name));
|
||||
|
||||
if (cities.some((city) => city.level === 4)) {
|
||||
const eligibleGenerals = world
|
||||
.listGenerals()
|
||||
.filter(
|
||||
(general) => Boolean(general.userId) && general.nationId === winner.id && general.npcState < 2
|
||||
)
|
||||
.sort((left, right) => left.id - right.id);
|
||||
const recipients: (typeof eligibleGenerals)[number][] = [];
|
||||
for (let officerLevel = 12; officerLevel >= 5 && recipients.length < 2; officerLevel -= 1) {
|
||||
const recipient = eligibleGenerals.find((general) => general.officerLevel === officerLevel);
|
||||
if (recipient) recipients.push(recipient);
|
||||
}
|
||||
for (const recipient of recipients) {
|
||||
for (const invader of INVADER_MESSAGE_OPTIONS) {
|
||||
world.queueMessage({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: recipient.id,
|
||||
generalName: recipient.name,
|
||||
nationId: winner.id,
|
||||
nationName: winner.name,
|
||||
color: winner.color,
|
||||
icon: recipient.picture ?? '',
|
||||
},
|
||||
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
|
||||
time: context.turnTime,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: { action: 'raiseInvader', args: [...invader.args], used: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth);
|
||||
world.queueUnificationFinalization({
|
||||
generationKey: `unification:${serverId}`,
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -136,6 +137,8 @@ interface CommandHandlerContext {
|
||||
auctionBidder?: AuctionBidder;
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
getImmediateGeneralActionExecutor?: () => Promise<ImmediateGeneralActionExecutor>;
|
||||
reservedTurns?: InMemoryReservedTurnStore;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
}
|
||||
|
||||
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
@@ -1604,6 +1607,35 @@ async function handleInstantRetreat(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMessageRespond(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'messageRespond' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
if (!ctx.getImmediateGeneralActionExecutor) {
|
||||
throw new Error('Immediate general action runtime is not configured.');
|
||||
}
|
||||
const result = await respondToActionableMessage({
|
||||
db: requireCommandDatabase(ctx) as GamePrisma.TransactionClient,
|
||||
world: ctx.world,
|
||||
reservedTurns: ctx.reservedTurns,
|
||||
executor: await ctx.getImmediateGeneralActionExecutor(),
|
||||
requestId: command.requestId,
|
||||
userId: command.userId,
|
||||
generalId: command.generalId,
|
||||
messageId: command.messageId,
|
||||
response: command.response,
|
||||
loadArchivedNationMaxId: ctx.loadArchivedNationMaxId,
|
||||
});
|
||||
return {
|
||||
type: 'messageRespond',
|
||||
ok: result.ok,
|
||||
generalId: command.generalId,
|
||||
messageId: command.messageId,
|
||||
...(result.action ? { action: result.action } : {}),
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleVacation(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'vacation' }>
|
||||
@@ -2532,6 +2564,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
auctionFinalizer?: AuctionFinalizer;
|
||||
auctionBidder?: AuctionBidder;
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
}): TurnDaemonCommandHandler => {
|
||||
let immediateGeneralActionExecutor: Promise<ImmediateGeneralActionExecutor> | null = null;
|
||||
const ctx: CommandHandlerContext = {
|
||||
@@ -2539,6 +2572,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
auctionFinalizer: options.auctionFinalizer,
|
||||
auctionBidder: options.auctionBidder,
|
||||
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
|
||||
reservedTurns: options.reservedTurns,
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
getImmediateGeneralActionExecutor: () => {
|
||||
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
|
||||
world: options.world,
|
||||
@@ -2588,6 +2623,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
|
||||
instantRetreat: (command) =>
|
||||
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
|
||||
messageRespond: (command) =>
|
||||
handleMessageRespond(ctx, command as Extract<TurnDaemonCommand, { type: 'messageRespond' }>),
|
||||
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
|
||||
setMySetting: (command) =>
|
||||
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { MessagePayload } from '@sammo-ts/logic';
|
||||
|
||||
import { respondToActionableMessage } from '../src/turn/actionableMessageResponse.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { ImmediateGeneralActionExecutor } from '../src/turn/reservedTurnHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const actor: TurnGeneral = {
|
||||
id: 7,
|
||||
userId: 'user-7',
|
||||
name: '수신자',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
turnTime: new Date('0200-01-01T00:10:00.000Z'),
|
||||
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: 1,
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
};
|
||||
|
||||
const buildWorld = (): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'actionable-message-test', isunited: 2 },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [actor],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
const source = {
|
||||
generalId: 8,
|
||||
generalName: '제안자',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
const destination = {
|
||||
generalId: actor.id,
|
||||
generalName: actor.name,
|
||||
nationId: actor.nationId,
|
||||
nationName: '위',
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
};
|
||||
|
||||
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
|
||||
id: 29,
|
||||
mailbox: actor.id,
|
||||
type: 'private',
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
message: {
|
||||
src: source,
|
||||
dest: destination,
|
||||
text: '응답할 메시지',
|
||||
option: { action, used: false, ...(action === 'raiseInvader' ? { args: [-2, -1.2, -1, -0.5] } : {}) },
|
||||
...overrides,
|
||||
} satisfies MessagePayload,
|
||||
});
|
||||
|
||||
const buildDb = (rows: unknown[][]) => {
|
||||
const queryRaw = vi.fn(async () => rows.shift() ?? []);
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
return {
|
||||
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
|
||||
queryRaw,
|
||||
updateMany,
|
||||
};
|
||||
};
|
||||
|
||||
const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
|
||||
execute: vi.fn(async () => (ok ? { ok: true } : { ok: false, reason: '등용 수락 불가.' })),
|
||||
});
|
||||
|
||||
describe('actionable message response', () => {
|
||||
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout');
|
||||
const { db, updateMany } = buildDb([[row], [{ id: 31 }]]);
|
||||
const executor = buildExecutor();
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'scout', reason: 'success' });
|
||||
expect(executor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: actor.id,
|
||||
args: { destNationId: source.nationId, destGeneralId: source.generalId },
|
||||
})
|
||||
);
|
||||
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [row.id, 31] } } }));
|
||||
expect(world.peekDirtyState().messages).toEqual([
|
||||
expect.objectContaining({
|
||||
msgType: 'private',
|
||||
text: '촉으로 등용 제의 수락',
|
||||
sendDestOnly: true,
|
||||
option: { delete: row.id },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a recruitment letter valid when the legacy accept constraints reject it', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout');
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(false),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'scout', reason: '등용 수락 불가.' });
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not invalidate an invader prompt before validating its receiver', async () => {
|
||||
const world = buildWorld();
|
||||
const row = { ...buildRow('raiseInvader'), mailbox: 99 };
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' });
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates a valid invader prompt when it is declined', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('raiseInvader');
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
|
||||
const result = await respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
response: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'raiseInvader', reason: 'success' });
|
||||
expect(updateMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildWorld = (isunited: number): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { isunited },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('invader event-game month progression', () => {
|
||||
it('continues monthly processing while the invader game is active', async () => {
|
||||
const world = buildWorld(1);
|
||||
const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), {
|
||||
budgetMs: 1_000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
|
||||
expect(result.processedTurns).toBe(1);
|
||||
expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 2 });
|
||||
});
|
||||
|
||||
it.each([2, 3])('stops monthly processing at terminal united state %s', async (isunited) => {
|
||||
const world = buildWorld(isunited);
|
||||
const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), {
|
||||
budgetMs: 1_000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
|
||||
expect(result.processedTurns).toBe(0);
|
||||
expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 1 });
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ const nation: Nation = {
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
power: 3000,
|
||||
level: 1,
|
||||
level: 4,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
};
|
||||
@@ -82,7 +82,7 @@ const city: City = {
|
||||
id: 1,
|
||||
name: '통일도시',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 1000,
|
||||
populationMax: 2000,
|
||||
@@ -202,6 +202,33 @@ describe('unification handler', () => {
|
||||
expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([
|
||||
expect.objectContaining({ auctionCancellations: [auctionCancellation, legacyAuctionCancellation] }),
|
||||
]);
|
||||
expect(
|
||||
world.peekDirtyState().messages.map((message) => ({
|
||||
text: message.text,
|
||||
action: message.option?.action,
|
||||
args: message.option?.args,
|
||||
recipient: message.dest.generalId,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
text: '이벤트 게임으로 이민족[어려움]을 소환',
|
||||
action: 'raiseInvader',
|
||||
args: [-2, -1.2, 15_000, -1],
|
||||
recipient: 1,
|
||||
},
|
||||
{
|
||||
text: '이벤트 게임으로 이민족[보통]을 소환',
|
||||
action: 'raiseInvader',
|
||||
args: [-2, -1.2, -1, -0.5],
|
||||
recipient: 1,
|
||||
},
|
||||
{
|
||||
text: '이벤트 게임으로 이민족[쉬움]을 소환',
|
||||
action: 'raiseInvader',
|
||||
args: [-1, -1, -0.8, 0],
|
||||
recipient: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const bid = vi.fn();
|
||||
const commands = createTurnDaemonCommandHandler({
|
||||
|
||||
@@ -167,10 +167,7 @@ const resetStatErrors = computed(() => {
|
||||
});
|
||||
|
||||
const turnTimeLabel = computed(() => {
|
||||
if (!turnTimeResult.value) {
|
||||
return null;
|
||||
}
|
||||
return formatServerDateTime(turnTimeResult.value);
|
||||
return turnTimeResult.value;
|
||||
});
|
||||
|
||||
const isUnited = computed(() => status.value?.isUnited ?? false);
|
||||
@@ -349,7 +346,7 @@ const resetTurnTime = async () => {
|
||||
}
|
||||
await runAction(async () => {
|
||||
const result = await trpc.inherit.resetTurnTime.mutate();
|
||||
turnTimeResult.value = result.nextTurnTime;
|
||||
turnTimeResult.value = result.nextTurnTimeLabel;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -24,37 +24,38 @@ ENGINE 전환 route의 명시적 `requestId`는 기존 middleware가 만든 chil
|
||||
|
||||
## 이번에 ENGINE procedure로 전환
|
||||
|
||||
| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 |
|
||||
| --- | --- | --- | --- |
|
||||
| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` |
|
||||
| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` |
|
||||
| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` |
|
||||
| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. |
|
||||
| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 |
|
||||
| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` |
|
||||
| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` |
|
||||
| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` |
|
||||
| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. |
|
||||
| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 |
|
||||
|
||||
합계 18개 route다. 모든 전환은 procedure 변경과 stable ENGINE request ID만 포함하며
|
||||
ENGINE handler, DB schema, journal/publisher foundation은 변경하지 않았다.
|
||||
|
||||
## 혼합 또는 validation 이관이 먼저 필요한 route
|
||||
|
||||
| route | 현재 procedure / outer transaction | 보류 근거 |
|
||||
| --- | --- | --- |
|
||||
| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. |
|
||||
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`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를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. |
|
||||
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. |
|
||||
| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. |
|
||||
| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. |
|
||||
| route | 현재 procedure / outer transaction | 보류 근거 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts`) | `scout`와 `raiseInvader`는 stable `messageRespond` ENGINE command가 actor/message를 다시 잠그고 처리한다. `noAggression`/`cancelNA`/`stopWar`는 기존 API transaction의 `respondToDiplomaticMessage`가 처리하므로 route 전체를 ENGINE-owned로 분류하지 않는다. 두 경로 모두 commit journal을 남긴다. |
|
||||
| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. |
|
||||
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`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를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. |
|
||||
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. |
|
||||
| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. |
|
||||
| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. |
|
||||
|
||||
합계 19개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만
|
||||
합계 20개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만
|
||||
rollback하므로 “원자적”이라고 간주하면 안 된다.
|
||||
|
||||
## 이미 API outer transaction이 없는 정상 route
|
||||
|
||||
| route | 근거 |
|
||||
| --- | --- |
|
||||
| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. |
|
||||
| route | 근거 |
|
||||
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. |
|
||||
| `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; `app/game-api/src/router/general/index.ts:612-656`. user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있다. |
|
||||
| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. |
|
||||
| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. |
|
||||
|
||||
합계 9개 route다.
|
||||
|
||||
|
||||
@@ -93,6 +93,14 @@ export type TurnDaemonCommand =
|
||||
| { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number }
|
||||
| { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number }
|
||||
| { type: 'instantRetreat'; requestId?: string; userId: string; generalId: number }
|
||||
| {
|
||||
type: 'messageRespond';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
}
|
||||
| { type: 'vacation'; requestId?: string; generalId: number }
|
||||
| {
|
||||
type: 'setMySetting';
|
||||
@@ -441,6 +449,14 @@ export type TurnDaemonCommandResult =
|
||||
| { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string }
|
||||
| {
|
||||
type: 'messageRespond';
|
||||
ok: boolean;
|
||||
generalId: number;
|
||||
messageId: number;
|
||||
action?: 'scout' | 'raiseInvader';
|
||||
reason: string;
|
||||
}
|
||||
| { type: 'vacation'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'setMySetting'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
|
||||
|
||||
@@ -17,10 +17,11 @@ import type {
|
||||
GeneralActionResolver,
|
||||
GeneralActionEffect,
|
||||
} from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { createGeneralPatchEffect, createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||
import { z } from 'zod';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
@@ -31,6 +32,7 @@ export interface EmployResolveContext<
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
destGeneral?: General;
|
||||
env?: TurnCommandEnv;
|
||||
messageTime: Date;
|
||||
}
|
||||
|
||||
const ACTION_NAME = '등용';
|
||||
@@ -131,6 +133,38 @@ export class ActionResolver<
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.nation) {
|
||||
const destNation = ctx.worldView
|
||||
?.listNations?.()
|
||||
.find((candidate) => candidate.id === destGeneral.nationId);
|
||||
const josaRo = JosaUtil.pick(ctx.nation.name, '로');
|
||||
effects.push(
|
||||
createMessageEffect({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: ctx.nation.id,
|
||||
nationName: ctx.nation.name,
|
||||
color: ctx.nation.color,
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: destGeneral.id,
|
||||
generalName: destGeneral.name,
|
||||
nationId: destGeneral.nationId,
|
||||
nationName: destNation?.name ?? '재야',
|
||||
color: destNation?.color ?? '#000000',
|
||||
icon: '',
|
||||
},
|
||||
text: `${ctx.nation.name}${josaRo} 망명 권유 서신`,
|
||||
time: ctx.messageTime,
|
||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||
option: { action: 'scout' },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
effects.push(
|
||||
createLogEffect(
|
||||
`<Y>${general.name}</>(${ctx.nation?.name ?? '재야'})로 부터 등용 권유 서신이 도착했습니다.`,
|
||||
@@ -215,6 +249,10 @@ export const actionContextBuilder = (base: ActionContextBase, options: ActionCon
|
||||
...base,
|
||||
destGeneral,
|
||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||
messageTime:
|
||||
(base.general as General & { turnTime?: Date }).turnTime instanceof Date
|
||||
? (base.general as General & { turnTime: Date }).turnTime
|
||||
: options.world.lastTurnTime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -295,7 +295,17 @@ export class ActionResolver<
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
});
|
||||
|
||||
return { effects };
|
||||
const deletedTroopIds: number[] = [];
|
||||
if (general.troopId === general.id) {
|
||||
deletedTroopIds.push(general.id);
|
||||
for (const member of context.worldView?.listGenerals() ?? []) {
|
||||
if (member.id !== general.id && member.troopId === general.id) {
|
||||
effects.push(createGeneralPatchEffect({ troopId: 0 }, member.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { effects, deletedTroopIds };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface MessageDraft {
|
||||
time: Date;
|
||||
validUntil: Date;
|
||||
option?: MessageOption | null;
|
||||
/** Ref Message::send(true): persist only the receiver copy. */
|
||||
sendDestOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface MessagePayload {
|
||||
@@ -139,7 +141,7 @@ export const sendMessage = async (
|
||||
throw new Error('Failed to send receiver message.');
|
||||
}
|
||||
|
||||
if (options.sendDestOnly) {
|
||||
if (options.sendDestOnly ?? draft.sendDestOnly) {
|
||||
return { receiverId };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ActionResolver } from '../../../src/actions/turn/general/che_등용.js';
|
||||
|
||||
describe('che_등용 recruitment message', () => {
|
||||
it('queues the Ref scout prompt with sender and receiver snapshots', () => {
|
||||
const logs: unknown[] = [];
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '등용자',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
gold: 5_000,
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
role: { items: {} },
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {},
|
||||
};
|
||||
const destination = {
|
||||
...general,
|
||||
id: 2,
|
||||
name: '수신자',
|
||||
nationId: 2,
|
||||
experience: 300,
|
||||
dedication: 200,
|
||||
};
|
||||
const sourceNation = { id: 1, name: '위', color: '#ffffff' };
|
||||
const destinationNation = { id: 2, name: '촉', color: '#000000' };
|
||||
const messageTime = new Date('0200-01-01T00:10:00.000Z');
|
||||
|
||||
const result = new ActionResolver().resolve(
|
||||
{
|
||||
general,
|
||||
nation: sourceNation,
|
||||
destGeneral: destination,
|
||||
messageTime,
|
||||
env: { develCost: 100 },
|
||||
worldView: { listNations: () => [sourceNation, destinationNation] },
|
||||
addLog: (text: string, options: unknown) => logs.push({ text, options }),
|
||||
} as never,
|
||||
{ destGeneralId: destination.id }
|
||||
);
|
||||
|
||||
expect(result.effects).toContainEqual({
|
||||
type: 'message:add',
|
||||
draft: {
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: sourceNation.id,
|
||||
nationName: sourceNation.name,
|
||||
color: sourceNation.color,
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: destination.id,
|
||||
generalName: destination.name,
|
||||
nationId: destinationNation.id,
|
||||
nationName: destinationNation.name,
|
||||
color: destinationNation.color,
|
||||
icon: '',
|
||||
},
|
||||
text: '위로 망명 권유 서신',
|
||||
time: messageTime,
|
||||
validUntil: new Date('9999-12-31T12:59:59.000Z'),
|
||||
option: { action: 'scout' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,31 +41,8 @@ const general = {
|
||||
|
||||
const generalContext = {
|
||||
general,
|
||||
city: {
|
||||
id: 1,
|
||||
name: '낙양',
|
||||
level: 7,
|
||||
nationId: 1,
|
||||
population: 50000,
|
||||
agriculture: 5000,
|
||||
commerce: 5000,
|
||||
security: 5000,
|
||||
defence: 5000,
|
||||
wall: 5000,
|
||||
supplyState: 1,
|
||||
frontState: 2,
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#d32f2f',
|
||||
level: 5,
|
||||
gold: 10000,
|
||||
rice: 10000,
|
||||
tech: 1200,
|
||||
typeCode: 'che_군벌',
|
||||
capitalCityId: 1,
|
||||
},
|
||||
city: null,
|
||||
nation: null,
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
@@ -111,12 +88,30 @@ const buildMessages = (permission: number) => ({
|
||||
{
|
||||
id: 103,
|
||||
msgType: 'private',
|
||||
src: foreignTarget,
|
||||
src: target(9, '상대일반', 2, '상대국', '#2457a6'),
|
||||
dest: ownTarget,
|
||||
text: '개인 메시지 본문',
|
||||
option: {},
|
||||
time: messageTime,
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
msgType: 'private',
|
||||
src: foreignTarget,
|
||||
dest: ownTarget,
|
||||
text: '상대국으로 망명 권유 서신',
|
||||
option: { action: 'scout', used: false },
|
||||
time: messageTime,
|
||||
},
|
||||
{
|
||||
id: 106,
|
||||
msgType: 'private',
|
||||
src: target(0, '', 0, 'System', '#000000'),
|
||||
dest: ownTarget,
|
||||
text: '이벤트 게임으로 이민족[보통]을 소환',
|
||||
option: { action: 'raiseInvader', args: [-2, -1.2, -1, -0.5], used: false },
|
||||
time: messageTime,
|
||||
},
|
||||
],
|
||||
diplomacy: [
|
||||
{
|
||||
@@ -192,12 +187,40 @@ const installFixture = async (
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
return response({
|
||||
context: {
|
||||
kind: 'snapshot',
|
||||
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
data: generalContext,
|
||||
},
|
||||
commandTable: {
|
||||
kind: 'snapshot',
|
||||
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||||
data: { general: [], nation: [] },
|
||||
},
|
||||
boardAccess: {
|
||||
kind: 'snapshot',
|
||||
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
|
||||
data: { canMeeting: true, canSecret: true, permission: options.permission },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.status') return response({ userId: 'frontend-parity-user' });
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ ...fixture.game.lobby, myGeneral: general });
|
||||
}
|
||||
if (operation === 'general.me') return response(generalContext);
|
||||
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||
if (operation === 'world.getState') {
|
||||
return response({
|
||||
currentYear: 197,
|
||||
currentMonth: 7,
|
||||
tickSeconds: 3600,
|
||||
config: { npcMode: 0, const: {}, environment: {} },
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
if (operation === 'world.getMap') {
|
||||
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||
}
|
||||
@@ -251,7 +274,10 @@ const openMessages = async (page: Page, viewport: { width: number; height: numbe
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
if (viewport.width <= 1024) {
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
const mobileMessageButton = page.getByRole('button', { name: '메시지', exact: true });
|
||||
if ((await mobileMessageButton.count()) > 0) {
|
||||
await mobileMessageButton.click();
|
||||
}
|
||||
}
|
||||
await expect(page.locator('.MessagePanel')).toBeVisible();
|
||||
};
|
||||
@@ -359,8 +385,8 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter
|
||||
await expect(select.locator('option[value="8"]')).toBeDisabled();
|
||||
await expect(select.locator('option[value="9"]')).toBeEnabled();
|
||||
|
||||
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
|
||||
await expect(select).toHaveValue('8');
|
||||
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대일반' }).click();
|
||||
await expect(select).toHaveValue('9');
|
||||
|
||||
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
|
||||
@@ -377,6 +403,37 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||
});
|
||||
|
||||
test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => {
|
||||
const mutations = await installFixture(page, { permission: 4 });
|
||||
await openMessages(page, { width: 500, height: 900 });
|
||||
|
||||
const recruitment = page.locator('.PrivateTalk .msg-plate').filter({ hasText: '망명 권유 서신' });
|
||||
const invader = page.locator('.PrivateTalk .msg-plate').filter({ hasText: '이민족[보통]을 소환' });
|
||||
await expect(recruitment.getByRole('button', { name: '수락' })).toBeVisible();
|
||||
await expect(invader.getByRole('button', { name: '거절' })).toBeVisible();
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('수락하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await recruitment.getByRole('button', { name: '수락' }).click();
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.respond').length).toBe(1);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await invader.getByRole('button', { name: '거절' }).click();
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.respond').length).toBe(2);
|
||||
|
||||
const responses = mutations.filter((entry) => entry.operation === 'messages.respond');
|
||||
expect(responses).toHaveLength(2);
|
||||
expect(JSON.stringify(responses[0]!.body)).toContain('"messageId":105');
|
||||
expect(JSON.stringify(responses[0]!.body)).toContain('"response":true');
|
||||
expect(JSON.stringify(responses[1]!.body)).toContain('"messageId":106');
|
||||
expect(JSON.stringify(responses[1]!.body)).toContain('"response":false');
|
||||
});
|
||||
|
||||
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
|
||||
const mutations = await installFixture(page, {
|
||||
permission: 2,
|
||||
|
||||
@@ -97,6 +97,7 @@ const statusFixture = {
|
||||
|
||||
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||
let buffMutationCount = 0;
|
||||
let resetTurnMutationCount = 0;
|
||||
await installImages(page);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
||||
@@ -140,6 +141,10 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
||||
buffMutationCount += 1;
|
||||
return response({ ok: true, remainPoint: 11_800 });
|
||||
}
|
||||
if (name === 'inherit.resetTurnTime') {
|
||||
resetTurnMutationCount += 1;
|
||||
return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' });
|
||||
}
|
||||
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
@@ -148,10 +153,32 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
||||
body: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
return { buffMutationCount: () => buffMutationCount };
|
||||
return {
|
||||
buffMutationCount: () => buffMutationCount,
|
||||
resetTurnMutationCount: () => resetTurnMutationCount,
|
||||
};
|
||||
};
|
||||
|
||||
test.describe('inheritance management legacy parity', () => {
|
||||
test('confirms and displays the Ref-compatible pending turn-time base', async ({ page }) => {
|
||||
const fixture = await installFixture(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(gameUrl);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
|
||||
const item = page.locator('.simple-item').filter({ hasText: '랜덤 턴 초기화' });
|
||||
const button = item.getByRole('button', { name: '구입' });
|
||||
await expect(button).toBeEnabled();
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('턴 시간을 1000 포인트로 초기화하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await button.click();
|
||||
|
||||
await expect(item).toContainText('적용 시간: 00:05');
|
||||
expect(fixture.resetTurnMutationCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
@@ -185,7 +212,7 @@ test.describe('inheritance management legacy parity', () => {
|
||||
|
||||
expect(desktop.container.width).toBe(1000);
|
||||
expect(desktop.container.x).toBe(140);
|
||||
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
|
||||
expect(Math.abs(desktop.firstPoint.width - 327.3)).toBeLessThanOrEqual(1);
|
||||
expect(desktop.fontFamily).toContain('Pretendard');
|
||||
expect(desktop.fontSize).toBe('14px');
|
||||
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||
@@ -220,7 +247,7 @@ test.describe('inheritance management legacy parity', () => {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(buyButton).toBeFocused();
|
||||
await expect.poll(() => buyButton.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid');
|
||||
await expect.poll(() => buyButton.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe('none');
|
||||
|
||||
await buyButton.evaluate((element) => element.setAttribute('disabled', ''));
|
||||
await expect.poll(() => buyButton.evaluate((element) => getComputedStyle(element).opacity)).toBe('0.65');
|
||||
@@ -245,7 +272,7 @@ test.describe('inheritance management legacy parity', () => {
|
||||
};
|
||||
});
|
||||
expect(mobile.containerWidth).toBe(500);
|
||||
expect(mobile.firstWidth).toBeCloseTo(482, 0);
|
||||
expect(mobile.firstWidth).toBeCloseTo(484, 0);
|
||||
expect(mobile.stacked).toBe(true);
|
||||
|
||||
if (artifactRoot) {
|
||||
|
||||
@@ -37,31 +37,8 @@ const general = {
|
||||
|
||||
const generalContext = {
|
||||
general,
|
||||
city: {
|
||||
id: 1,
|
||||
name: '낙양',
|
||||
level: 7,
|
||||
nationId: 1,
|
||||
population: 50000,
|
||||
agriculture: 5000,
|
||||
commerce: 5000,
|
||||
security: 5000,
|
||||
defence: 5000,
|
||||
wall: 5000,
|
||||
supplyState: 1,
|
||||
frontState: 2,
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '수락국',
|
||||
color: '#d32f2f',
|
||||
level: 5,
|
||||
gold: 10000,
|
||||
rice: 10000,
|
||||
tech: 1200,
|
||||
typeCode: 'che_군벌',
|
||||
capitalCityId: 1,
|
||||
},
|
||||
city: null,
|
||||
nation: null,
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
@@ -69,8 +46,22 @@ const generalContext = {
|
||||
const diplomacyMessage = {
|
||||
id: 701,
|
||||
msgType: 'diplomacy',
|
||||
src: { generalId: 2, generalName: '제안장수', nationId: 2, nationName: '제안국' },
|
||||
dest: { generalId: 1, generalName: '수락장수', nationId: 1, nationName: '수락국' },
|
||||
src: {
|
||||
generalId: 2,
|
||||
generalName: '제안장수',
|
||||
nationId: 2,
|
||||
nationName: '제안국',
|
||||
color: '#2457a6',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 1,
|
||||
generalName: '수락장수',
|
||||
nationId: 1,
|
||||
nationName: '수락국',
|
||||
color: '#d32f2f',
|
||||
icon: '',
|
||||
},
|
||||
text: '제안국에서 191년 2월까지 불가침을 제안했습니다.',
|
||||
option: {
|
||||
action: 'noAggression',
|
||||
@@ -118,12 +109,44 @@ const installFixture = async (
|
||||
const operations = operationNames(route);
|
||||
const requestBody = route.request().postDataJSON();
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
return response({
|
||||
context: {
|
||||
kind: 'snapshot',
|
||||
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
data: generalContext,
|
||||
},
|
||||
commandTable: {
|
||||
kind: 'snapshot',
|
||||
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||||
data: { general: [], nation: [] },
|
||||
},
|
||||
boardAccess: {
|
||||
kind: 'snapshot',
|
||||
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
|
||||
data: {
|
||||
canMeeting: true,
|
||||
canSecret: true,
|
||||
permission: options.canRespondDiplomacy === false ? 2 : 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.status') return response({ userId: 'frontend-parity-user' });
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ ...fixture.game.lobby, myGeneral: general });
|
||||
}
|
||||
if (operation === 'general.me') return response(generalContext);
|
||||
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||
if (operation === 'world.getState') {
|
||||
return response({
|
||||
currentYear: 190,
|
||||
currentMonth: 3,
|
||||
tickSeconds: 3600,
|
||||
config: { npcMode: 0, const: {}, environment: {} },
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
if (operation === 'world.getMap') {
|
||||
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||
}
|
||||
@@ -256,15 +279,15 @@ test.describe('instant diplomacy response UI', () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
const mobileMessageButton = page.getByRole('button', { name: '메시지', exact: true });
|
||||
if ((await mobileMessageButton.count()) > 0) await mobileMessageButton.click();
|
||||
|
||||
const responseRow = page.locator('.message-response');
|
||||
await expect(responseRow).toBeVisible();
|
||||
const itemWidth = await page
|
||||
.locator('.DiplomacyTalk .msg-plate')
|
||||
.evaluate((element) => element.getBoundingClientRect().width);
|
||||
expect(itemWidth).toBeGreaterThanOrEqual(389);
|
||||
expect(itemWidth).toBeLessThanOrEqual(390);
|
||||
expect(itemWidth).toBeCloseTo(500, 0);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||
@@ -295,7 +318,8 @@ test.describe('instant diplomacy response UI', () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
const mobileMessageButton = page.getByRole('button', { name: '메시지', exact: true });
|
||||
if ((await mobileMessageButton.count()) > 0) await mobileMessageButton.click();
|
||||
|
||||
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||
await expect(accept).toBeDisabled();
|
||||
|
||||
Reference in New Issue
Block a user