merge: 최신 main을 NPC 천통 계측 브랜치에 통합
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
|
||||||
|
const profileStatuses = new Set<string>(GATEWAY_PROFILE_STATUSES);
|
||||||
|
|
||||||
|
export interface ProfileStatusSource {
|
||||||
|
get(profileName: string): Promise<GatewayProfileStatus | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deriveInternalToken = (secret: string): string =>
|
||||||
|
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||||
|
|
||||||
|
const parseProfileStatus = (value: unknown, profileName: string): GatewayProfileStatus => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error('invalid gateway profile status projection');
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
Object.keys(record).sort().join(',') !== 'profileName,status' ||
|
||||||
|
record.profileName !== profileName ||
|
||||||
|
typeof record.status !== 'string' ||
|
||||||
|
!profileStatuses.has(record.status)
|
||||||
|
) {
|
||||||
|
throw new Error('invalid gateway profile status projection');
|
||||||
|
}
|
||||||
|
return record.status as GatewayProfileStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GatewayHttpProfileStatusSource implements ProfileStatusSource {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
baseUrl: string,
|
||||||
|
private readonly secret: string,
|
||||||
|
private readonly timeoutMs = 2_000
|
||||||
|
) {
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(profileName: string): Promise<GatewayProfileStatus | null> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/internal/profile-status/${encodeURIComponent(profileName)}`, {
|
||||||
|
headers: {
|
||||||
|
'x-sammo-internal-token': deriveInternalToken(this.secret),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Gateway profile status request failed with HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
return parseProfileStatus(await response.json(), profileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type { BattleSimTransport } from './battleSim/transport.js';
|
|||||||
import type { FlushStore } from './auth/flushStore.js';
|
import type { FlushStore } from './auth/flushStore.js';
|
||||||
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
import type { AccountIconSource } from './auth/accountIconSource.js';
|
import type { AccountIconSource } from './auth/accountIconSource.js';
|
||||||
|
import type { ProfileStatusSource } from './auth/profileStatusSource.js';
|
||||||
import type { ContentImageUploadStore } from './services/remoteContentImageStore.js';
|
import type { ContentImageUploadStore } from './services/remoteContentImageStore.js';
|
||||||
|
|
||||||
export interface GameProfile {
|
export interface GameProfile {
|
||||||
@@ -99,6 +100,9 @@ export interface GameApiContext {
|
|||||||
flushStore: FlushStore;
|
flushStore: FlushStore;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
accountIconSource?: AccountIconSource;
|
accountIconSource?: AccountIconSource;
|
||||||
|
// Runtime context always supplies this. Partial router fixtures may omit it;
|
||||||
|
// access scoring then fails open and never penalizes a test-only request.
|
||||||
|
profileStatusSource?: ProfileStatusSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createGameApiContext = (options: {
|
export const createGameApiContext = (options: {
|
||||||
@@ -118,6 +122,7 @@ export const createGameApiContext = (options: {
|
|||||||
flushStore: FlushStore;
|
flushStore: FlushStore;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
accountIconSource?: AccountIconSource;
|
accountIconSource?: AccountIconSource;
|
||||||
|
profileStatusSource: ProfileStatusSource;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
return {
|
return {
|
||||||
requestId: options.requestId,
|
requestId: options.requestId,
|
||||||
@@ -137,5 +142,6 @@ export const createGameApiContext = (options: {
|
|||||||
flushStore: options.flushStore,
|
flushStore: options.flushStore,
|
||||||
gameTokenSecret: options.gameTokenSecret,
|
gameTokenSecret: options.gameTokenSecret,
|
||||||
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
|
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
|
||||||
|
profileStatusSource: options.profileStatusSource,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { accessLimitAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
|
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
|
||||||
import { getBoardAccess } from '../board/index.js';
|
import { getBoardAccess } from '../board/index.js';
|
||||||
import { getGeneralContext } from '../general/index.js';
|
import { getGeneralContext } from '../general/index.js';
|
||||||
@@ -31,7 +31,7 @@ const zContextBundleInput = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const dashboardRouter = router({
|
export const dashboardRouter = router({
|
||||||
getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||||
const viewerId = ctx.auth?.user.id;
|
const viewerId = ctx.auth?.user.id;
|
||||||
if (!viewerId) {
|
if (!viewerId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
accessAuthedInputProcedure,
|
accessAuthedInputProcedure,
|
||||||
accessEngineAuthedProcedure,
|
accessEngineAuthedProcedure,
|
||||||
accessEngineAuthedInputProcedure,
|
accessEngineAuthedInputProcedure,
|
||||||
|
accessLimitAuthedProcedure,
|
||||||
authedProcedure,
|
authedProcedure,
|
||||||
engineAuthedProcedure,
|
engineAuthedProcedure,
|
||||||
router,
|
router,
|
||||||
@@ -735,7 +736,7 @@ export const generalRouter = router({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getRecentRecords: authedProcedure
|
getRecentRecords: accessLimitAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
lastGeneralRecordId: z.number().int().nonnegative().default(0),
|
lastGeneralRecordId: z.number().int().nonnegative().default(0),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||||
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||||
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { procedure, router } from '../../trpc.js';
|
import { procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
export const lobbyRouter = router({
|
export const lobbyRouter = router({
|
||||||
@@ -26,6 +27,7 @@ export const lobbyRouter = router({
|
|||||||
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
||||||
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
||||||
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
|
|
||||||
let myGeneral = null;
|
let myGeneral = null;
|
||||||
if (ctx.auth?.user.id) {
|
if (ctx.auth?.user.id) {
|
||||||
@@ -54,6 +56,8 @@ export const lobbyRouter = router({
|
|||||||
starttime: worldState.meta.starttime ?? '',
|
starttime: worldState.meta.starttime ?? '',
|
||||||
opentime: worldState.meta.opentime ?? '',
|
opentime: worldState.meta.opentime ?? '',
|
||||||
turntime: worldState.meta.turntime ?? '',
|
turntime: worldState.meta.turntime ?? '',
|
||||||
|
serverTime: gameTime.now.toISOString(),
|
||||||
|
clockMode: gameTime.mode ?? 'realtime',
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
import {
|
import {
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
MESSAGE_MAILBOX_PUBLIC,
|
MESSAGE_MAILBOX_PUBLIC,
|
||||||
@@ -73,116 +73,114 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const messagesRouter = router({
|
export const messagesRouter = router({
|
||||||
getRecent: authedProcedure
|
getRecent: accessLimitAuthedInputProcedure(
|
||||||
.input(
|
z.object({
|
||||||
z.object({
|
generalId: z.number().int().positive(),
|
||||||
generalId: z.number().int().positive(),
|
sequence: z.number().int().optional(),
|
||||||
sequence: z.number().int().optional(),
|
})
|
||||||
})
|
).query(async ({ ctx, input }) => {
|
||||||
)
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
|
||||||
|
|
||||||
const sequence = input.sequence ?? -1;
|
const sequence = input.sequence ?? -1;
|
||||||
const nationId = general.nationId;
|
const nationId = general.nationId;
|
||||||
const mailboxes = {
|
const mailboxes = {
|
||||||
private: general.id,
|
private: general.id,
|
||||||
public: MESSAGE_MAILBOX_PUBLIC,
|
public: MESSAGE_MAILBOX_PUBLIC,
|
||||||
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||||
} satisfies Record<MessageType, number>;
|
} satisfies Record<MessageType, number>;
|
||||||
|
|
||||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
|
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchMessagesFromMailbox({
|
fetchMessagesFromMailbox({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
mailbox: mailboxes.private,
|
mailbox: mailboxes.private,
|
||||||
msgType: 'private',
|
msgType: 'private',
|
||||||
limit: 15,
|
limit: 15,
|
||||||
fromSeq: sequence,
|
fromSeq: sequence,
|
||||||
}),
|
}),
|
||||||
fetchMessagesFromMailbox({
|
fetchMessagesFromMailbox({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
mailbox: mailboxes.public,
|
mailbox: mailboxes.public,
|
||||||
msgType: 'public',
|
msgType: 'public',
|
||||||
limit: 15,
|
limit: 15,
|
||||||
fromSeq: sequence,
|
fromSeq: sequence,
|
||||||
}),
|
}),
|
||||||
fetchMessagesFromMailbox({
|
fetchMessagesFromMailbox({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
mailbox: mailboxes.national,
|
mailbox: mailboxes.national,
|
||||||
msgType: 'national',
|
msgType: 'national',
|
||||||
limit: 15,
|
limit: 15,
|
||||||
fromSeq: sequence,
|
fromSeq: sequence,
|
||||||
}),
|
}),
|
||||||
fetchMessagesFromMailbox({
|
fetchMessagesFromMailbox({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
mailbox: mailboxes.diplomacy,
|
mailbox: mailboxes.diplomacy,
|
||||||
msgType: 'diplomacy',
|
msgType: 'diplomacy',
|
||||||
limit: 15,
|
limit: 15,
|
||||||
fromSeq: sequence,
|
fromSeq: sequence,
|
||||||
}),
|
}),
|
||||||
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
|
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
|
||||||
nationId > 0
|
nationId > 0
|
||||||
? ctx.db.nation.findUnique({
|
? ctx.db.nation.findUnique({
|
||||||
where: { id: nationId },
|
where: { id: nationId },
|
||||||
select: { meta: true },
|
select: { meta: true },
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||||
private: privateMessages,
|
private: privateMessages,
|
||||||
public: publicMessages,
|
public: publicMessages,
|
||||||
national: nationalMessages,
|
national: nationalMessages,
|
||||||
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
||||||
};
|
};
|
||||||
|
|
||||||
let nextSequence = sequence;
|
let nextSequence = sequence;
|
||||||
let minSequence = sequence;
|
let minSequence = sequence;
|
||||||
let lastType: MessageType | null = null;
|
let lastType: MessageType | null = null;
|
||||||
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
|
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.id > nextSequence) {
|
if (message.id > nextSequence) {
|
||||||
nextSequence = message.id;
|
nextSequence = message.id;
|
||||||
}
|
}
|
||||||
if (message.id <= minSequence) {
|
if (message.id <= minSequence) {
|
||||||
minSequence = message.id;
|
minSequence = message.id;
|
||||||
lastType = type;
|
lastType = type;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
updateSequence('private', privateMessages);
|
|
||||||
updateSequence('public', publicMessages);
|
|
||||||
updateSequence('national', nationalMessages);
|
|
||||||
updateSequence('diplomacy', diplomacyMessages);
|
|
||||||
|
|
||||||
if (lastType === 'private' && messageBuckets.private.length > 0) {
|
|
||||||
messageBuckets.private.pop();
|
|
||||||
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
|
|
||||||
messageBuckets.public.pop();
|
|
||||||
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
|
|
||||||
messageBuckets.national.pop();
|
|
||||||
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
|
|
||||||
messageBuckets.diplomacy.pop();
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
updateSequence('private', privateMessages);
|
||||||
result: true,
|
updateSequence('public', publicMessages);
|
||||||
...messageBuckets,
|
updateSequence('national', nationalMessages);
|
||||||
sequence: nextSequence,
|
updateSequence('diplomacy', diplomacyMessages);
|
||||||
nationId: nationId,
|
|
||||||
generalName: general.name,
|
if (lastType === 'private' && messageBuckets.private.length > 0) {
|
||||||
permission,
|
messageBuckets.private.pop();
|
||||||
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
|
||||||
latestRead: {
|
messageBuckets.public.pop();
|
||||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
|
||||||
private: readState?.latestPrivateMessage ?? 0,
|
messageBuckets.national.pop();
|
||||||
},
|
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
|
||||||
};
|
messageBuckets.diplomacy.pop();
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: true,
|
||||||
|
...messageBuckets,
|
||||||
|
sequence: nextSequence,
|
||||||
|
nationId: nationId,
|
||||||
|
generalName: general.name,
|
||||||
|
permission,
|
||||||
|
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||||
|
latestRead: {
|
||||||
|
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||||
|
private: readState?.latestPrivateMessage ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
getContacts: authedProcedure
|
getContacts: authedProcedure
|
||||||
.input(z.object({ generalId: z.number().int().positive() }))
|
.input(z.object({ generalId: z.number().int().positive() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { authedProcedure } from '../../../trpc.js';
|
import { accessLimitAuthedInputProcedure } from '../../../trpc.js';
|
||||||
import { getMyGeneral } from '../../shared/general.js';
|
import { getMyGeneral } from '../../shared/general.js';
|
||||||
import {
|
import {
|
||||||
assertNationAccess,
|
assertNationAccess,
|
||||||
@@ -13,77 +13,75 @@ import {
|
|||||||
type GeneralLogType,
|
type GeneralLogType,
|
||||||
} from '../shared.js';
|
} from '../shared.js';
|
||||||
|
|
||||||
export const getGeneralLog = authedProcedure
|
export const getGeneralLog = accessLimitAuthedInputProcedure(
|
||||||
.input(
|
z.object({
|
||||||
z.object({
|
generalId: z.number().int().positive(),
|
||||||
generalId: z.number().int().positive(),
|
type: zGeneralLogType,
|
||||||
type: zGeneralLogType,
|
beforeId: z.number().int().positive().optional(),
|
||||||
beforeId: z.number().int().positive().optional(),
|
})
|
||||||
})
|
).query(async ({ ctx, input }) => {
|
||||||
)
|
const me = await getMyGeneral(ctx);
|
||||||
.query(async ({ ctx, input }) => {
|
assertNationAccess(me);
|
||||||
const me = await getMyGeneral(ctx);
|
|
||||||
assertNationAccess(me);
|
|
||||||
|
|
||||||
const [nation, target] = await Promise.all([
|
const [nation, target] = await Promise.all([
|
||||||
ctx.db.nation.findUnique({
|
ctx.db.nation.findUnique({
|
||||||
where: { id: me.nationId },
|
where: { id: me.nationId },
|
||||||
select: { meta: true },
|
select: { meta: true },
|
||||||
}),
|
}),
|
||||||
ctx.db.general.findUnique({
|
ctx.db.general.findUnique({
|
||||||
where: { id: input.generalId },
|
where: { id: input.generalId },
|
||||||
select: { id: true, nationId: true, npcState: true },
|
select: { id: true, nationId: true, npcState: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||||
}
|
}
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionLevel = resolveNationPermission(me, nation.meta, true);
|
const permissionLevel = resolveNationPermission(me, nation.meta, true);
|
||||||
if (permissionLevel < 1) {
|
if (permissionLevel < 1) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
}
|
}
|
||||||
if (target.nationId !== me.nationId) {
|
if (target.nationId !== me.nationId) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
|
||||||
}
|
}
|
||||||
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
|
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
|
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryMap: Record<GeneralLogType, LogCategory> = {
|
|
||||||
generalHistory: LogCategory.HISTORY,
|
|
||||||
generalAction: LogCategory.ACTION,
|
|
||||||
battleResult: LogCategory.BATTLE_BRIEF,
|
|
||||||
battleDetail: LogCategory.BATTLE_DETAIL,
|
|
||||||
};
|
|
||||||
|
|
||||||
const logs = await ctx.db.logEntry.findMany({
|
|
||||||
where: {
|
|
||||||
generalId: target.id,
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: categoryMap[input.type],
|
|
||||||
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
|
|
||||||
},
|
|
||||||
orderBy: { id: 'desc' },
|
|
||||||
...(input.type === 'generalHistory' ? {} : { take: 30 }),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const categoryMap: Record<GeneralLogType, LogCategory> = {
|
||||||
type: input.type,
|
generalHistory: LogCategory.HISTORY,
|
||||||
|
generalAction: LogCategory.ACTION,
|
||||||
|
battleResult: LogCategory.BATTLE_BRIEF,
|
||||||
|
battleDetail: LogCategory.BATTLE_DETAIL,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logs = await ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
logs: logs.map((entry) => ({
|
scope: LogScope.GENERAL,
|
||||||
id: entry.id,
|
category: categoryMap[input.type],
|
||||||
text: entry.text,
|
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
|
||||||
year: entry.year,
|
},
|
||||||
month: entry.month,
|
orderBy: { id: 'desc' },
|
||||||
createdAt: formatDateTime(entry.createdAt),
|
...(input.type === 'generalHistory' ? {} : { take: 30 }),
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: input.type,
|
||||||
|
generalId: target.id,
|
||||||
|
logs: logs.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
text: entry.text,
|
||||||
|
year: entry.year,
|
||||||
|
month: entry.month,
|
||||||
|
createdAt: formatDateTime(entry.createdAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import type { GameApiContext } from '../../context.js';
|
|||||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||||
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
import {
|
||||||
|
accessPages,
|
||||||
|
formatGeneralAccessLimitMessage,
|
||||||
|
generalAccessLimitPages,
|
||||||
|
getGeneralAccessState,
|
||||||
|
recordGeneralAccess,
|
||||||
|
} from '../../services/generalAccess.js';
|
||||||
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||||
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||||
import { loadTraitNames } from '../nation/shared.js';
|
import { loadTraitNames } from '../nation/shared.js';
|
||||||
@@ -248,9 +254,19 @@ const sortNpcList = <
|
|||||||
export const publicRouter = router({
|
export const publicRouter = router({
|
||||||
recordAccess: sessionActivityProcedure
|
recordAccess: sessionActivityProcedure
|
||||||
.input(z.object({ page: z.enum(accessPages) }))
|
.input(z.object({ page: z.enum(accessPages) }))
|
||||||
.mutation(async ({ ctx, input }) => ({
|
.mutation(async ({ ctx, input }) => {
|
||||||
recorded: await recordGeneralAccess(ctx, input.page),
|
const recorded = await recordGeneralAccess(ctx, input.page);
|
||||||
})),
|
if (ctx.generalAccessTracking === true && generalAccessLimitPages.has(input.page)) {
|
||||||
|
const state = await getGeneralAccessState(ctx);
|
||||||
|
if (state?.level === 2) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: formatGeneralAccessLimitMessage(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { recorded };
|
||||||
|
}),
|
||||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||||
return loadMapLayout(ctx.profile.scenario);
|
return loadMapLayout(ctx.profile.scenario);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
import { loadActionModuleBundle } from '@sammo-ts/logic';
|
import { loadActionModuleBundle } from '@sammo-ts/logic';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
||||||
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
||||||
import {
|
import {
|
||||||
@@ -283,13 +283,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const turnsRouter = router({
|
export const turnsRouter = router({
|
||||||
getCommandTable: authedProcedure
|
getCommandTable: accessAuthedInputProcedure(
|
||||||
.input(
|
z.object({
|
||||||
z.object({
|
generalId: z.number().int().positive(),
|
||||||
generalId: z.number().int().positive(),
|
})
|
||||||
})
|
).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
|
||||||
)
|
|
||||||
.query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
|
|
||||||
reserved: router({
|
reserved: router({
|
||||||
getGeneral: authedProcedure
|
getGeneral: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { LogCategory, LogScope } from '@sammo-ts/logic';
|
|||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
||||||
import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js';
|
import {
|
||||||
|
formatGeneralAccessLimitMessage,
|
||||||
|
generalAccessEndpointWeights,
|
||||||
|
getGeneralAccessState,
|
||||||
|
recordGeneralAccessWeight,
|
||||||
|
} from '../../services/generalAccess.js';
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
@@ -31,6 +36,10 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
|
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
|
||||||
|
const state = ctx.generalAccessTracking === true ? await getGeneralAccessState(ctx) : null;
|
||||||
|
if (state?.level === 2) {
|
||||||
|
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: formatGeneralAccessLimitMessage(state) });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTextArray = (value: unknown): string[] =>
|
const parseTextArray = (value: unknown): string[] =>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
|||||||
import { formatSseFrame } from './realtime/sse.js';
|
import { formatSseFrame } from './realtime/sse.js';
|
||||||
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
|
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
|
||||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||||
|
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||||
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
|
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
|
||||||
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
||||||
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
|
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
|
||||||
@@ -92,6 +93,10 @@ export const createGameApiServer = async () => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret);
|
const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret);
|
||||||
|
const profileStatusSource = new GatewayHttpProfileStatusSource(
|
||||||
|
config.gatewayInternalApiUrl,
|
||||||
|
config.gameTokenSecret
|
||||||
|
);
|
||||||
|
|
||||||
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
||||||
const accountIconResetReconciler = new AccountIconResetReconciler(
|
const accountIconResetReconciler = new AccountIconResetReconciler(
|
||||||
@@ -214,6 +219,7 @@ export const createGameApiServer = async () => {
|
|||||||
flushStore,
|
flushStore,
|
||||||
gameTokenSecret: config.gameTokenSecret,
|
gameTokenSecret: config.gameTokenSecret,
|
||||||
accountIconSource,
|
accountIconSource,
|
||||||
|
profileStatusSource,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { GameApiContext } from '../context.js';
|
import type { GameApiContext } from '../context.js';
|
||||||
@@ -56,6 +56,7 @@ export const generalAccessEndpointWeights = {
|
|||||||
'general.dieOnPrestart': 1,
|
'general.dieOnPrestart': 1,
|
||||||
'general.instantRetreat': 1,
|
'general.instantRetreat': 1,
|
||||||
'messages.send': 1,
|
'messages.send': 1,
|
||||||
|
'turns.getCommandTable': 1,
|
||||||
'general.setMySetting': 0,
|
'general.setMySetting': 0,
|
||||||
'npc.setNationPolicy': 0,
|
'npc.setNationPolicy': 0,
|
||||||
'npc.setNationPriority': 0,
|
'npc.setNationPriority': 0,
|
||||||
@@ -65,6 +66,40 @@ export const generalAccessEndpointWeights = {
|
|||||||
|
|
||||||
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
|
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
|
||||||
|
|
||||||
|
export const generalAccessLimitPages = new Set<AccessPage>(['nation-list', 'npc-control']);
|
||||||
|
|
||||||
|
export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
|
||||||
|
'world.getGeneralDirectory',
|
||||||
|
'tournament.getSnapshot',
|
||||||
|
'nation.getSecretGeneralList',
|
||||||
|
'nation.getGeneralList',
|
||||||
|
'nation.getStratFinan',
|
||||||
|
'nation.getBattleCenter',
|
||||||
|
'nation.getChiefCenter',
|
||||||
|
'board.getArticles',
|
||||||
|
'board.writeArticle',
|
||||||
|
'board.writeComment',
|
||||||
|
'diplomacy.getLetters',
|
||||||
|
'diplomacy.sendLetter',
|
||||||
|
'diplomacy.respondLetter',
|
||||||
|
'diplomacy.rollbackLetter',
|
||||||
|
'diplomacy.destroyLetter',
|
||||||
|
'betting.getList',
|
||||||
|
'general.getFrontStatus',
|
||||||
|
'yearbook.getHistory',
|
||||||
|
'messages.send',
|
||||||
|
'turns.getCommandTable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
|
||||||
|
|
||||||
|
export type GeneralAccessState = {
|
||||||
|
refreshScore: number;
|
||||||
|
refreshLimit: number;
|
||||||
|
level: AccessLimitLevel;
|
||||||
|
nextAccessAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
export const resolveGeneralAccessEndpointWeight = (
|
export const resolveGeneralAccessEndpointWeight = (
|
||||||
path: string,
|
path: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -114,6 +149,58 @@ export const resolveAccessWindows = (
|
|||||||
return { periodStartedAt: scoreStartedAt, scoreStartedAt };
|
return { periodStartedAt: scoreStartedAt, scoreStartedAt };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveGeneralScoreStartedAt = (tickSeconds: number, nextTurnAt: Date): Date =>
|
||||||
|
new Date(nextTurnAt.getTime() - Math.max(1, Math.floor(tickSeconds)) * 1_000);
|
||||||
|
|
||||||
|
const formatAccessTime = (value: Date): string => {
|
||||||
|
const kst = new Date(value.getTime() + 9 * 60 * 60 * 1_000);
|
||||||
|
return kst.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatGeneralAccessLimitMessage = (state: Pick<GeneralAccessState, 'nextAccessAt'>): string =>
|
||||||
|
`접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ` +
|
||||||
|
`(다음 접속 가능 시각: ${formatAccessTime(state.nextAccessAt)}) ` +
|
||||||
|
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.';
|
||||||
|
|
||||||
|
export const getGeneralAccessState = async (
|
||||||
|
ctx: Pick<GameApiContext, 'auth' | 'db'>
|
||||||
|
): Promise<GeneralAccessState | null> => {
|
||||||
|
const user = ctx.auth?.user;
|
||||||
|
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [general, worldState] = await Promise.all([
|
||||||
|
ctx.db.general.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, turnTime: true },
|
||||||
|
}),
|
||||||
|
ctx.db.worldState.findFirst({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { tickSeconds: true, meta: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!general || !worldState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await ctx.db.generalAccessLog.findUnique({
|
||||||
|
where: { generalId: general.id },
|
||||||
|
select: { lastRefresh: true, refreshScore: true },
|
||||||
|
});
|
||||||
|
const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime);
|
||||||
|
const refreshScore =
|
||||||
|
access?.lastRefresh && access.lastRefresh.getTime() < scoreStartedAt.getTime()
|
||||||
|
? 0
|
||||||
|
: (access?.refreshScore ?? 0);
|
||||||
|
const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit);
|
||||||
|
return {
|
||||||
|
refreshScore,
|
||||||
|
refreshLimit,
|
||||||
|
level: resolveAccessLimitLevel(refreshScore, refreshLimit),
|
||||||
|
nextAccessAt: general.turnTime,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const upsertGeneralAccess = async (
|
export const upsertGeneralAccess = async (
|
||||||
db: Pick<GameApiContext['db'], '$transaction'>,
|
db: Pick<GameApiContext['db'], '$transaction'>,
|
||||||
input: {
|
input: {
|
||||||
@@ -286,13 +373,13 @@ export const upsertGeneralAccess = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const recordGeneralAccess = async (
|
export const recordGeneralAccess = async (
|
||||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
|
||||||
page: AccessPage,
|
page: AccessPage,
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
|
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
|
||||||
|
|
||||||
export const recordGeneralAccessWeight = async (
|
export const recordGeneralAccessWeight = async (
|
||||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
|
||||||
weight: number,
|
weight: number,
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
@@ -304,11 +391,25 @@ export const recordGeneralAccessWeight = async (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileStatusSource = ctx.profileStatusSource;
|
||||||
|
if (!profileStatusSource) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ((await profileStatusSource.get(ctx.profile.name)) !== 'RUNNING') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 상태를 확인하지 못한 요청으로 사용자를 벌주지 않는다. 업무 요청은
|
||||||
|
// 계속 진행하고 다음 요청에서 gateway 상태를 다시 확인한다.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const [general, worldState] = await Promise.all([
|
const [general, worldState] = await Promise.all([
|
||||||
ctx.db.general.findFirst({
|
ctx.db.general.findFirst({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, userId: true },
|
select: { id: true, userId: true, turnTime: true },
|
||||||
}),
|
}),
|
||||||
ctx.db.worldState.findFirst({
|
ctx.db.worldState.findFirst({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
@@ -332,7 +433,8 @@ export const recordGeneralAccessWeight = async (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
const { periodStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||||
|
const scoreStartedAt = resolveGeneralScoreStartedAt(worldState.tickSeconds, general.turnTime);
|
||||||
|
|
||||||
await upsertGeneralAccess(ctx.db, {
|
await upsertGeneralAccess(ctx.db, {
|
||||||
worldStateId: worldState.id,
|
worldStateId: worldState.id,
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
|||||||
import type { GameApiContext } from './context.js';
|
import type { GameApiContext } from './context.js';
|
||||||
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
|
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
|
||||||
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
|
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
|
||||||
import { recordGeneralAccessWeight, resolveGeneralAccessEndpointWeight } from './services/generalAccess.js';
|
import {
|
||||||
|
formatGeneralAccessLimitMessage,
|
||||||
|
generalAccessLimitBeforeRecordEndpoints,
|
||||||
|
generalAccessLimitEndpoints,
|
||||||
|
getGeneralAccessState,
|
||||||
|
recordGeneralAccessWeight,
|
||||||
|
resolveGeneralAccessEndpointWeight,
|
||||||
|
type GeneralAccessEndpoint,
|
||||||
|
} from './services/generalAccess.js';
|
||||||
|
|
||||||
const t = initTRPC.context<GameApiContext>().create();
|
const t = initTRPC.context<GameApiContext>().create();
|
||||||
|
|
||||||
@@ -84,7 +92,40 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
|
|||||||
if (weight === null) {
|
if (weight === null) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
const endpoint = path as GeneralAccessEndpoint;
|
||||||
|
if (generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
|
||||||
|
const state = await getGeneralAccessState(ctx);
|
||||||
|
if (state?.level === 2) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: formatGeneralAccessLimitMessage(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
await recordGeneralAccessWeight(ctx, weight);
|
await recordGeneralAccessWeight(ctx, weight);
|
||||||
|
if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
|
||||||
|
const state = await getGeneralAccessState(ctx);
|
||||||
|
if (state?.level === 2) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: formatGeneralAccessLimitMessage(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||||
|
if (ctx.generalAccessTracking !== true) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const state = await getGeneralAccessState(ctx);
|
||||||
|
if (state?.level === 2) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: formatGeneralAccessLimitMessage(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +157,9 @@ export const sessionActivityProcedure = t.procedure;
|
|||||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||||
|
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.use(generalAccessLimitMiddleware);
|
||||||
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
||||||
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
||||||
export const accessInputProcedure: typeof procedure.input = (input) =>
|
export const accessInputProcedure: typeof procedure.input = (input) =>
|
||||||
@@ -126,3 +170,5 @@ export const accessEngineAuthedInputProcedure: typeof procedure.input = (input)
|
|||||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
|
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
|
||||||
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
|
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
|
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
|
||||||
|
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
|
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware);
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ describe('buildTurnCommandTable', () => {
|
|||||||
'che_단련',
|
'che_단련',
|
||||||
'che_숙련전환',
|
'che_숙련전환',
|
||||||
'che_견문',
|
'che_견문',
|
||||||
|
'che_은퇴',
|
||||||
'che_장비매매',
|
'che_장비매매',
|
||||||
'che_군량매매',
|
'che_군량매매',
|
||||||
'che_내정특기초기화',
|
'che_내정특기초기화',
|
||||||
@@ -135,9 +136,58 @@ describe('buildTurnCommandTable', () => {
|
|||||||
'che_주민선정',
|
'che_주민선정',
|
||||||
],
|
],
|
||||||
군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
|
군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
|
||||||
인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||||
계략: ['che_화계'],
|
계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
||||||
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
||||||
|
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||||
|
buildTurnCommandTable({
|
||||||
|
worldState: buildWorldState(),
|
||||||
|
general,
|
||||||
|
city: buildCity(),
|
||||||
|
nation,
|
||||||
|
nationGenerals: null,
|
||||||
|
});
|
||||||
|
const findCommand = (table: Awaited<ReturnType<typeof buildTable>>, key: string) =>
|
||||||
|
table.general.flatMap((group) => group.values).find((command) => command.key === key);
|
||||||
|
|
||||||
|
const ordinary = await buildTable(buildGeneral());
|
||||||
|
expect(findCommand(ordinary, 'che_강행')).toMatchObject({
|
||||||
|
name: '강행',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
inputFields: [{ key: 'destCityId', optionSource: 'cities' }],
|
||||||
|
});
|
||||||
|
expect(findCommand(ordinary, 'che_은퇴')).toMatchObject({
|
||||||
|
name: '은퇴',
|
||||||
|
possible: false,
|
||||||
|
status: 'blocked',
|
||||||
|
reason: '나이가 60세 이상이어야 합니다.',
|
||||||
|
});
|
||||||
|
expect(findCommand(ordinary, 'che_하야')).toMatchObject({
|
||||||
|
name: '하야',
|
||||||
|
possible: true,
|
||||||
|
status: 'available',
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldEnough = await buildTable({ ...buildGeneral(), age: 60 } as GeneralRow);
|
||||||
|
expect(findCommand(oldEnough, 'che_은퇴')).toMatchObject({ possible: true, status: 'available' });
|
||||||
|
|
||||||
|
const ruler = await buildTable({ ...buildGeneral(), officerLevel: 12 } as GeneralRow);
|
||||||
|
expect(findCommand(ruler, 'che_하야')).toMatchObject({
|
||||||
|
possible: false,
|
||||||
|
status: 'blocked',
|
||||||
|
reason: expect.stringContaining('군주'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const neutral = await buildTable({ ...buildGeneral(), nationId: 0, officerLevel: 0 } as GeneralRow, null);
|
||||||
|
expect(findCommand(neutral, 'che_하야')).toMatchObject({
|
||||||
|
possible: false,
|
||||||
|
status: 'blocked',
|
||||||
|
reason: '재야입니다.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean) => {
|
|||||||
},
|
},
|
||||||
city: { findUnique: async () => null },
|
city: { findUnique: async () => null },
|
||||||
nation: { findUnique: async () => null },
|
nation: { findUnique: async () => null },
|
||||||
|
generalAccessLog: { findUnique: async () => null },
|
||||||
worldState: { findFirst: async () => ({ config: { const: {} } }) },
|
worldState: { findFirst: async () => ({ config: { const: {} } }) },
|
||||||
},
|
},
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ integration('general access tracking persistence', () => {
|
|||||||
name: yearbookProfile,
|
name: yearbookProfile,
|
||||||
scenario: 'default',
|
scenario: 'default',
|
||||||
},
|
},
|
||||||
|
profileStatusSource: { get: async () => 'RUNNING' as const },
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
|
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,28 @@ import { z } from 'zod';
|
|||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import type { DatabaseClient } from '../src/context.js';
|
import type { DatabaseClient } from '../src/context.js';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
|
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js';
|
||||||
import {
|
import {
|
||||||
accessPageWeights,
|
accessPageWeights,
|
||||||
generalAccessEndpointWeights,
|
generalAccessEndpointWeights,
|
||||||
|
getGeneralAccessState,
|
||||||
recordGeneralAccess,
|
recordGeneralAccess,
|
||||||
recordGeneralAccessWeight,
|
recordGeneralAccessWeight,
|
||||||
resolveGeneralAccessEndpointWeight,
|
resolveGeneralAccessEndpointWeight,
|
||||||
resolveAccessWindows,
|
resolveAccessWindows,
|
||||||
|
resolveGeneralScoreStartedAt,
|
||||||
} from '../src/services/generalAccess.js';
|
} from '../src/services/generalAccess.js';
|
||||||
|
|
||||||
|
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
|
||||||
|
const profileStatusSource = { get: vi.fn(async () => 'RUNNING' as const) };
|
||||||
|
const accessContext = (db: DatabaseClient, token: GameSessionTokenPayload | null = auth()) => ({
|
||||||
|
auth: token,
|
||||||
|
db,
|
||||||
|
profile,
|
||||||
|
profileStatusSource,
|
||||||
|
generalAccessTracking: true as const,
|
||||||
|
});
|
||||||
|
|
||||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: 'che:default',
|
profile: 'che:default',
|
||||||
@@ -30,7 +42,10 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
|||||||
sanctions: {},
|
sanctions: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
const buildDb = (
|
||||||
|
meta: Record<string, unknown> = {},
|
||||||
|
access: { lastRefresh: Date | null; refreshScore: number } | null = null
|
||||||
|
) => {
|
||||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||||
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
|
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
|
||||||
const transaction = vi.fn(
|
const transaction = vi.fn(
|
||||||
@@ -38,7 +53,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
|||||||
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
|
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
|
||||||
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
|
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
|
||||||
);
|
);
|
||||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
const findGeneral = vi.fn(async () => ({
|
||||||
|
id: 7,
|
||||||
|
userId: 'user-7',
|
||||||
|
turnTime: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
|
}));
|
||||||
const findWorld = vi.fn(async () => ({
|
const findWorld = vi.fn(async () => ({
|
||||||
id: 3,
|
id: 3,
|
||||||
currentYear: 185,
|
currentYear: 185,
|
||||||
@@ -53,6 +72,7 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
|||||||
const db = {
|
const db = {
|
||||||
$transaction: transaction,
|
$transaction: transaction,
|
||||||
general: { findFirst: findGeneral },
|
general: { findFirst: findGeneral },
|
||||||
|
generalAccessLog: { findUnique: vi.fn(async () => access) },
|
||||||
worldState: { findFirst: findWorld },
|
worldState: { findFirst: findWorld },
|
||||||
} as unknown as DatabaseClient;
|
} as unknown as DatabaseClient;
|
||||||
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
|
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
|
||||||
@@ -91,6 +111,7 @@ describe('general access tracking', () => {
|
|||||||
'general.dieOnPrestart': 1,
|
'general.dieOnPrestart': 1,
|
||||||
'general.instantRetreat': 1,
|
'general.instantRetreat': 1,
|
||||||
'messages.send': 1,
|
'messages.send': 1,
|
||||||
|
'turns.getCommandTable': 1,
|
||||||
'general.setMySetting': 0,
|
'general.setMySetting': 0,
|
||||||
'npc.setNationPolicy': 0,
|
'npc.setNationPolicy': 0,
|
||||||
'npc.setNationPriority': 0,
|
'npc.setNationPriority': 0,
|
||||||
@@ -120,17 +141,20 @@ describe('general access tracking', () => {
|
|||||||
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
});
|
});
|
||||||
|
expect(resolveGeneralScoreStartedAt(600, new Date('2026-07-26T03:20:00.000Z'))).toEqual(
|
||||||
|
new Date('2026-07-26T03:10:00.000Z')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||||
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
||||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||||
|
|
||||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
|
await expect(recordGeneralAccess(accessContext(db), 'nation-list', now)).resolves.toBe(true);
|
||||||
expect(findGeneral).toHaveBeenCalledWith({
|
expect(findGeneral).toHaveBeenCalledWith({
|
||||||
where: { userId: 'user-7' },
|
where: { userId: 'user-7' },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, userId: true },
|
select: { id: true, userId: true, turnTime: true },
|
||||||
});
|
});
|
||||||
expect(transaction).toHaveBeenCalledTimes(1);
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||||
@@ -167,7 +191,7 @@ describe('general access tracking', () => {
|
|||||||
const { db, executeRaw, queryRaw, transaction } = buildDb();
|
const { db, executeRaw, queryRaw, transaction } = buildDb();
|
||||||
const now = new Date('2026-07-26T03:06:00.000Z');
|
const now = new Date('2026-07-26T03:06:00.000Z');
|
||||||
|
|
||||||
await expect(recordGeneralAccessWeight({ auth: auth(), db }, 0, now)).resolves.toBe(true);
|
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
|
||||||
expect(transaction).toHaveBeenCalledTimes(1);
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||||
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||||
@@ -175,14 +199,42 @@ describe('general access tracking', () => {
|
|||||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
|
||||||
|
const blocked = buildDb(
|
||||||
|
{ refreshLimit: 120 },
|
||||||
|
{ lastRefresh: new Date('2026-07-26T03:05:00.000Z'), refreshScore: 121 }
|
||||||
|
);
|
||||||
|
await expect(getGeneralAccessState(accessContext(blocked.db))).resolves.toMatchObject({
|
||||||
|
refreshScore: 121,
|
||||||
|
refreshLimit: 120,
|
||||||
|
level: 2,
|
||||||
|
nextAccessAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stale = buildDb(
|
||||||
|
{ refreshLimit: 120 },
|
||||||
|
{ lastRefresh: new Date('2026-07-26T02:59:59.999Z'), refreshScore: 999 }
|
||||||
|
);
|
||||||
|
await expect(getGeneralAccessState(accessContext(stale.db))).resolves.toMatchObject({
|
||||||
|
refreshScore: 0,
|
||||||
|
level: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolver = vi.fn(() => ({ ok: true }));
|
||||||
|
const limitedRouter = router({ read: accessLimitAuthedProcedure.query(resolver) });
|
||||||
|
await expect(
|
||||||
|
limitedRouter.createCaller(accessContext(blocked.db) as unknown as GameApiContext).read()
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: expect.stringContaining('자신의 턴이 되면 다시 접속 가능합니다.'),
|
||||||
|
});
|
||||||
|
expect(resolver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
|
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
|
||||||
const fixture = buildDb();
|
const fixture = buildDb();
|
||||||
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf(
|
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);
|
||||||
RangeError
|
await expect(recordGeneralAccessWeight(accessContext(fixture.db), 0.5)).rejects.toBeInstanceOf(RangeError);
|
||||||
);
|
|
||||||
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, 0.5)).rejects.toBeInstanceOf(
|
|
||||||
RangeError
|
|
||||||
);
|
|
||||||
expect(fixture.findGeneral).not.toHaveBeenCalled();
|
expect(fixture.findGeneral).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,6 +253,13 @@ describe('general access tracking', () => {
|
|||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
id: 7,
|
id: 7,
|
||||||
userId: 'user-7',
|
userId: 'user-7',
|
||||||
|
turnTime: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
generalAccessLog: {
|
||||||
|
findUnique: vi.fn(async () => ({
|
||||||
|
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
|
||||||
|
refreshScore: 1,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
@@ -253,6 +312,7 @@ describe('general access tracking', () => {
|
|||||||
generalAccessTracking: true,
|
generalAccessTracking: true,
|
||||||
requestId: 'access-boundary-test',
|
requestId: 'access-boundary-test',
|
||||||
profile: { id: 'che:default', name: 'che' },
|
profile: { id: 'che:default', name: 'che' },
|
||||||
|
profileStatusSource,
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
|
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
|
||||||
@@ -279,21 +339,37 @@ describe('general access tracking', () => {
|
|||||||
|
|
||||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||||
const anonymous = buildDb();
|
const anonymous = buildDb();
|
||||||
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
await expect(recordGeneralAccess(accessContext(anonymous.db, null), 'traffic')).resolves.toBe(false);
|
||||||
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const admin = buildDb();
|
const admin = buildDb();
|
||||||
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
await expect(recordGeneralAccess(accessContext(admin.db, auth(['admin'])), 'traffic')).resolves.toBe(false);
|
||||||
expect(admin.findGeneral).not.toHaveBeenCalled();
|
expect(admin.findGeneral).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
||||||
await expect(
|
await expect(
|
||||||
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
recordGeneralAccess(accessContext(future.db), 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(false);
|
||||||
expect(future.transaction).not.toHaveBeenCalled();
|
expect(future.transaction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const united = buildDb({ isUnited: 2 });
|
const united = buildDb({ isUnited: 2 });
|
||||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
await expect(recordGeneralAccess(accessContext(united.db), 'traffic')).resolves.toBe(false);
|
||||||
expect(united.transaction).not.toHaveBeenCalled();
|
expect(united.transaction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const paused = buildDb();
|
||||||
|
const pausedContext = {
|
||||||
|
...accessContext(paused.db),
|
||||||
|
profileStatusSource: { get: vi.fn(async () => 'PAUSED' as const) },
|
||||||
|
};
|
||||||
|
await expect(recordGeneralAccess(pausedContext, 'traffic')).resolves.toBe(false);
|
||||||
|
expect(paused.findGeneral).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const unavailable = buildDb();
|
||||||
|
const unavailableContext = {
|
||||||
|
...accessContext(unavailable.db),
|
||||||
|
profileStatusSource: { get: vi.fn(async () => Promise.reject(new Error('gateway unavailable'))) },
|
||||||
|
};
|
||||||
|
await expect(recordGeneralAccess(unavailableContext, 'traffic')).resolves.toBe(false);
|
||||||
|
expect(unavailable.findGeneral).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
const buildContext = (
|
||||||
|
meta: Record<string, unknown>,
|
||||||
|
clock: {
|
||||||
|
baseTime?: Date;
|
||||||
|
tick?: bigint;
|
||||||
|
mode?: string;
|
||||||
|
wallAnchor?: Date;
|
||||||
|
} = {}
|
||||||
|
): GameApiContext =>
|
||||||
({
|
({
|
||||||
auth: null,
|
auth: null,
|
||||||
db: {
|
db: {
|
||||||
@@ -16,6 +24,10 @@ const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
|||||||
tickSeconds: 3_600,
|
tickSeconds: 3_600,
|
||||||
config: {},
|
config: {},
|
||||||
meta,
|
meta,
|
||||||
|
clockBaseTime: clock.baseTime ?? null,
|
||||||
|
clockTick: clock.tick ?? null,
|
||||||
|
clockMode: clock.mode ?? 'realtime',
|
||||||
|
clockWallAnchor: clock.wallAnchor ?? null,
|
||||||
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -36,4 +48,23 @@ describe('lobby season state', () => {
|
|||||||
|
|
||||||
expect(result.isUnited).toBe(isunited);
|
expect(result.isUnited).toBe(isunited);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the projected server game time and whether the clock is running', async () => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(
|
||||||
|
buildContext(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
baseTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||||
|
tick: 72_000_000n,
|
||||||
|
mode: 'manual',
|
||||||
|
wallAnchor: new Date('2026-08-15T17:00:00.000Z'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.lobby.info();
|
||||||
|
|
||||||
|
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||||
|
expect(result.clockMode).toBe('manual');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { GatewayHttpProfileStatusSource } from '../src/auth/profileStatusSource.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GatewayHttpProfileStatusSource', () => {
|
||||||
|
it('uses an encoded path and a purpose-derived credential', async () => {
|
||||||
|
const fetchMock = vi.fn<(input: string | URL, init?: RequestInit) => Promise<Response>>(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ profileName: 'che:default/한글', status: 'RUNNING' }), { status: 200 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const source = new GatewayHttpProfileStatusSource('http://gateway.internal/', 'root-secret');
|
||||||
|
await expect(source.get('che:default/한글')).resolves.toBe('RUNNING');
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]!;
|
||||||
|
expect(url).toBe('http://gateway.internal/internal/profile-status/che%3Adefault%2F%ED%95%9C%EA%B8%80');
|
||||||
|
expect(init?.headers).toMatchObject({
|
||||||
|
'x-sammo-internal-token': expect.not.stringContaining('root-secret'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null only for a missing profile and rejects malformed status values', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response(null, { status: 404 }))
|
||||||
|
);
|
||||||
|
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('missing')).resolves.toBeNull();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response(JSON.stringify({ profileName: 'che', status: 'UNKNOWN' }), { status: 200 }))
|
||||||
|
);
|
||||||
|
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('che')).rejects.toThrow(
|
||||||
|
'invalid'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -914,6 +914,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
let persistedVisibleLogs: PersistedVisibleLogRow[] = [];
|
let persistedVisibleLogs: PersistedVisibleLogRow[] = [];
|
||||||
let visibleLogFloor = directLogFloor;
|
let visibleLogFloor = directLogFloor;
|
||||||
const {
|
const {
|
||||||
|
accessScoreResetGeneralIds,
|
||||||
generals,
|
generals,
|
||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
@@ -1012,6 +1013,13 @@ export const createDatabaseTurnHooks = async (
|
|||||||
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
|
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (accessScoreResetGeneralIds.length > 0) {
|
||||||
|
await prisma.generalAccessLog.updateMany({
|
||||||
|
where: { generalId: { in: accessScoreResetGeneralIds } },
|
||||||
|
data: { refreshScore: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (inheritancePointAdjustments.length > 0) {
|
if (inheritancePointAdjustments.length > 0) {
|
||||||
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
|
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
|
||||||
for (const entry of inheritancePointAdjustments) {
|
for (const entry of inheritancePointAdjustments) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from
|
|||||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import type { TurnGeneral } from './types.js';
|
import type { TurnGeneral } from './types.js';
|
||||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
import { asNumber, asRecord, calculateAccessRefreshLimit } from '@sammo-ts/common';
|
||||||
|
|
||||||
export interface InMemoryTurnProcessorOptions {
|
export interface InMemoryTurnProcessorOptions {
|
||||||
tickMinutes?: number;
|
tickMinutes?: number;
|
||||||
@@ -50,6 +50,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
const isBudgetExpired = () => Date.now() >= deadlineMs;
|
const isBudgetExpired = () => Date.now() >= deadlineMs;
|
||||||
|
|
||||||
this.world.setCheckpoint(checkpoint);
|
this.world.setCheckpoint(checkpoint);
|
||||||
|
this.world.updateWorldMeta({
|
||||||
|
refreshLimit: calculateAccessRefreshLimit(this.world.getState().tickSeconds),
|
||||||
|
});
|
||||||
|
|
||||||
let processedGenerals = 0;
|
let processedGenerals = 0;
|
||||||
let processedTurns = 0;
|
let processedTurns = 0;
|
||||||
@@ -97,6 +100,9 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
if (executionError !== undefined) {
|
if (executionError !== undefined) {
|
||||||
throw executionError;
|
throw executionError;
|
||||||
}
|
}
|
||||||
|
// Ref의 updateTurnTime()은 장수 명령이 성공한 뒤 그 장수의
|
||||||
|
// 순간 벌점을 같은 턴 flush에서 초기화한다.
|
||||||
|
this.world.markGeneralAccessScoreReset(general.id);
|
||||||
processedGenerals += 1;
|
processedGenerals += 1;
|
||||||
nextCheckpoint = {
|
nextCheckpoint = {
|
||||||
turnTime: executedAt.toISOString(),
|
turnTime: executedAt.toISOString(),
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export interface InMemoryGameClockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnWorldChanges {
|
export interface TurnWorldChanges {
|
||||||
|
accessScoreResetGeneralIds: number[];
|
||||||
generals: TurnGeneral[];
|
generals: TurnGeneral[];
|
||||||
cities: City[];
|
cities: City[];
|
||||||
nations: Nation[];
|
nations: Nation[];
|
||||||
@@ -156,6 +157,7 @@ export interface InMemoryTurnWorldStateSnapshot {
|
|||||||
dirtyNationIds: number[];
|
dirtyNationIds: number[];
|
||||||
dirtyTroopIds: number[];
|
dirtyTroopIds: number[];
|
||||||
dirtyDiplomacyKeys: string[];
|
dirtyDiplomacyKeys: string[];
|
||||||
|
accessScoreResetGeneralIds: number[];
|
||||||
createdGeneralIds: number[];
|
createdGeneralIds: number[];
|
||||||
createdNationIds: number[];
|
createdNationIds: number[];
|
||||||
createdTroopIds: number[];
|
createdTroopIds: number[];
|
||||||
@@ -419,6 +421,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly dirtyNationIds = new Set<number>();
|
private readonly dirtyNationIds = new Set<number>();
|
||||||
private readonly dirtyTroopIds = new Set<number>();
|
private readonly dirtyTroopIds = new Set<number>();
|
||||||
private readonly dirtyDiplomacyKeys = new Set<string>();
|
private readonly dirtyDiplomacyKeys = new Set<string>();
|
||||||
|
private readonly accessScoreResetGeneralIds = new Set<number>();
|
||||||
private readonly createdGeneralIds = new Set<number>();
|
private readonly createdGeneralIds = new Set<number>();
|
||||||
private nextLegacyGeneralScanOrder = 0;
|
private nextLegacyGeneralScanOrder = 0;
|
||||||
private readonly createdNationIds = new Set<number>();
|
private readonly createdNationIds = new Set<number>();
|
||||||
@@ -606,6 +609,7 @@ export class InMemoryTurnWorld {
|
|||||||
dirtyNationIds: Array.from(this.dirtyNationIds),
|
dirtyNationIds: Array.from(this.dirtyNationIds),
|
||||||
dirtyTroopIds: Array.from(this.dirtyTroopIds),
|
dirtyTroopIds: Array.from(this.dirtyTroopIds),
|
||||||
dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys),
|
dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys),
|
||||||
|
accessScoreResetGeneralIds: Array.from(this.accessScoreResetGeneralIds),
|
||||||
createdGeneralIds: Array.from(this.createdGeneralIds),
|
createdGeneralIds: Array.from(this.createdGeneralIds),
|
||||||
createdNationIds: Array.from(this.createdNationIds),
|
createdNationIds: Array.from(this.createdNationIds),
|
||||||
createdTroopIds: Array.from(this.createdTroopIds),
|
createdTroopIds: Array.from(this.createdTroopIds),
|
||||||
@@ -644,6 +648,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds);
|
this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds);
|
||||||
this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds);
|
this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds);
|
||||||
this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys);
|
this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys);
|
||||||
|
this.replaceSet(this.accessScoreResetGeneralIds, restored.accessScoreResetGeneralIds ?? []);
|
||||||
this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds);
|
this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds);
|
||||||
this.replaceSet(this.createdNationIds, restored.createdNationIds);
|
this.replaceSet(this.createdNationIds, restored.createdNationIds);
|
||||||
this.replaceSet(this.createdTroopIds, restored.createdTroopIds);
|
this.replaceSet(this.createdTroopIds, restored.createdTroopIds);
|
||||||
@@ -693,6 +698,12 @@ export class InMemoryTurnWorld {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markGeneralAccessScoreReset(generalId: number): void {
|
||||||
|
if (Number.isSafeInteger(generalId) && generalId > 0) {
|
||||||
|
this.accessScoreResetGeneralIds.add(generalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
changeTurnTerm(tickMinutes: number): void {
|
changeTurnTerm(tickMinutes: number): void {
|
||||||
if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) {
|
if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) {
|
||||||
throw new Error('Turn term must be a positive integer.');
|
throw new Error('Turn term must be a positive integer.');
|
||||||
@@ -1512,8 +1523,12 @@ export class InMemoryTurnWorld {
|
|||||||
}));
|
}));
|
||||||
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
|
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
|
||||||
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
|
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
|
||||||
|
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
|
||||||
|
(left, right) => left - right
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
accessScoreResetGeneralIds,
|
||||||
generals,
|
generals,
|
||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
@@ -1542,6 +1557,7 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
acknowledgeDirtyState(changes: TurnWorldChanges): void {
|
acknowledgeDirtyState(changes: TurnWorldChanges): void {
|
||||||
|
for (const id of changes.accessScoreResetGeneralIds) this.accessScoreResetGeneralIds.delete(id);
|
||||||
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
|
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
|
||||||
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
|
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
|
||||||
for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id);
|
for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id);
|
||||||
|
|||||||
@@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
|||||||
return new Date(baseTime + alignedSeconds * 1000);
|
return new Date(baseTime + alignedSeconds * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveTurnTime = (
|
export const resolveJoinTurnTime = (
|
||||||
rng: RandUtil,
|
rng: Pick<RandUtil, 'nextRangeInt'>,
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
acceptedAt: Date,
|
acceptedAt: Date,
|
||||||
runtimeTurnTime: Date,
|
runtimeTurnTime: Date,
|
||||||
@@ -348,7 +348,12 @@ const resolveTurnTime = (
|
|||||||
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
} else {
|
} else {
|
||||||
turnTimeBase = base;
|
// Ref normally uses game_env.turntime as a near-current cursor. Core's
|
||||||
|
// durable daemon can legitimately be catching up from an older cursor,
|
||||||
|
// so scheduling from runtimeTurnTime may put a newly created general
|
||||||
|
// hours behind the game clock. The accepted game time is the equivalent
|
||||||
|
// current-time boundary for a new general.
|
||||||
|
turnTimeBase = acceptedAt;
|
||||||
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
}
|
}
|
||||||
@@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const experience = await resolveCatchupExperience(db, relativeYear);
|
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||||
const turnTime = resolveTurnTime(
|
const turnTime = resolveJoinTurnTime(
|
||||||
rng,
|
rng,
|
||||||
worldState,
|
worldState,
|
||||||
acceptedAt,
|
acceptedAt,
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
|||||||
'che_견문',
|
'che_견문',
|
||||||
'che_무작위건국',
|
'che_무작위건국',
|
||||||
'che_화계',
|
'che_화계',
|
||||||
|
'che_선동',
|
||||||
|
'che_파괴',
|
||||||
|
'che_탈취',
|
||||||
'che_집합',
|
'che_집합',
|
||||||
'cr_건국',
|
'cr_건국',
|
||||||
'che_이동',
|
'che_이동',
|
||||||
|
|||||||
@@ -18,15 +18,23 @@ const GENERAL_AI_ACTIONS = [
|
|||||||
|
|
||||||
const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const;
|
const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const;
|
||||||
const GENERAL_REF_EDITOR_ACTIONS = [
|
const GENERAL_REF_EDITOR_ACTIONS = [
|
||||||
|
'che_은퇴',
|
||||||
'che_임관',
|
'che_임관',
|
||||||
'che_랜덤임관',
|
'che_랜덤임관',
|
||||||
|
'che_강행',
|
||||||
'che_징병',
|
'che_징병',
|
||||||
'che_출병',
|
'che_출병',
|
||||||
'che_농지개간',
|
'che_농지개간',
|
||||||
|
'che_선동',
|
||||||
|
'che_탈취',
|
||||||
|
'che_파괴',
|
||||||
'che_화계',
|
'che_화계',
|
||||||
'che_증여',
|
'che_증여',
|
||||||
|
'che_하야',
|
||||||
'che_장비매매',
|
'che_장비매매',
|
||||||
] as const;
|
] as const;
|
||||||
|
const GENERAL_REF_STRATEGY_ACTIONS = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const;
|
||||||
|
const GENERAL_REF_STRATEGY_ACTION_SET = new Set<string>(GENERAL_REF_STRATEGY_ACTIONS);
|
||||||
const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const;
|
const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const;
|
||||||
|
|
||||||
describe('default turn command profile AI coverage', () => {
|
describe('default turn command profile AI coverage', () => {
|
||||||
@@ -41,6 +49,9 @@ describe('default turn command profile AI coverage', () => {
|
|||||||
const profile = await loadTurnCommandProfile();
|
const profile = await loadTurnCommandProfile();
|
||||||
|
|
||||||
expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_REF_EDITOR_ACTIONS]));
|
expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_REF_EDITOR_ACTIONS]));
|
||||||
|
expect(profile.general.filter((action) => GENERAL_REF_STRATEGY_ACTION_SET.has(action))).toEqual(
|
||||||
|
GENERAL_REF_STRATEGY_ACTIONS
|
||||||
|
);
|
||||||
expect(profile.nation).toEqual(expect.arrayContaining([...NATION_REF_EDITOR_ACTIONS]));
|
expect(profile.nation).toEqual(expect.arrayContaining([...NATION_REF_EDITOR_ACTIONS]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const generalId = 991_815;
|
||||||
|
const scenarioCode = 'general-access-score-reset-persistence';
|
||||||
|
|
||||||
|
integration('general access score reset persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||||
|
await db.general.deleteMany({ where: { id: generalId } });
|
||||||
|
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the own-turn reset marker in the same world flush', async () => {
|
||||||
|
const turnTime = new Date('2026-08-15T00:10:00.000Z');
|
||||||
|
await db.general.create({
|
||||||
|
data: {
|
||||||
|
id: generalId,
|
||||||
|
userId: 'access-reset-persistence-user',
|
||||||
|
name: '접속점수초기화장수',
|
||||||
|
turnTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.generalAccessLog.create({
|
||||||
|
data: {
|
||||||
|
generalId,
|
||||||
|
userId: 'access-reset-persistence-user',
|
||||||
|
lastRefresh: new Date('2026-08-15T00:09:59.000Z'),
|
||||||
|
refresh: 120,
|
||||||
|
refreshTotal: 500,
|
||||||
|
refreshScore: 351,
|
||||||
|
refreshScoreTotal: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
scenarioCode,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: row.id,
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '.',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
scenarioConfig,
|
||||||
|
map: { id: 'test', name: 'test', cities: [] },
|
||||||
|
generals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
},
|
||||||
|
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||||
|
);
|
||||||
|
world.markGeneralAccessScoreReset(generalId);
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await hooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 1,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||||
|
refresh: 120,
|
||||||
|
refreshTotal: 500,
|
||||||
|
refreshScore: 0,
|
||||||
|
refreshScoreTotal: 999,
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildJoinCreateGeneralSeed,
|
buildJoinCreateGeneralSeed,
|
||||||
cutJoinTurnTime,
|
cutJoinTurnTime,
|
||||||
JOIN_WELCOME_MESSAGE,
|
JOIN_WELCOME_MESSAGE,
|
||||||
|
resolveJoinTurnTime,
|
||||||
} from '../src/turn/joinCreateGeneralService.js';
|
} from '../src/turn/joinCreateGeneralService.js';
|
||||||
|
|
||||||
describe('generic join legacy time contracts', () => {
|
describe('generic join legacy time contracts', () => {
|
||||||
@@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => {
|
||||||
|
const calls: Array<[number, number]> = [];
|
||||||
|
const values = [59, 250_000];
|
||||||
|
const rng = {
|
||||||
|
nextRangeInt(min: number, max: number) {
|
||||||
|
calls.push([min, max]);
|
||||||
|
return values.shift() ?? min;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const acceptedAt = new Date('2026-08-15T17:57:05.837Z');
|
||||||
|
const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z');
|
||||||
|
|
||||||
|
const turnTime = resolveJoinTurnTime(
|
||||||
|
rng,
|
||||||
|
{ tickSeconds: 120 } as Parameters<typeof resolveJoinTurnTime>[1],
|
||||||
|
acceptedAt,
|
||||||
|
staleRuntimeTurnTime,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z');
|
||||||
|
expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime());
|
||||||
|
expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
[0, 119],
|
||||||
|
[0, 999_999],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
|
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
|
||||||
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
||||||
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ describe('InMemoryTurnProcessor ordering', () => {
|
|||||||
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget);
|
||||||
expect(tiedGeneralResult.processedTurns).toBe(0);
|
expect(tiedGeneralResult.processedTurns).toBe(0);
|
||||||
expect(executed).toEqual([3, 2]);
|
expect(executed).toEqual([3, 2]);
|
||||||
|
expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([2, 3]);
|
||||||
|
expect(world.getState().meta).toMatchObject({ refreshLimit: 350 });
|
||||||
expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime());
|
expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime());
|
||||||
expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull();
|
expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull();
|
||||||
expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4);
|
expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4);
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const inputOptions = {
|
|||||||
const commandTable = {
|
const commandTable = {
|
||||||
general: [
|
general: [
|
||||||
{
|
{
|
||||||
category: '군사',
|
category: '계략',
|
||||||
values: [
|
values: [
|
||||||
{
|
{
|
||||||
key: 'che_화계',
|
key: 'che_화계',
|
||||||
@@ -133,6 +133,26 @@ const commandTable = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...[
|
||||||
|
{ key: 'che_선동', name: '선동' },
|
||||||
|
{ key: 'che_탈취', name: '탈취' },
|
||||||
|
{ key: 'che_파괴', name: '파괴' },
|
||||||
|
].map(({ key, name }) => ({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'destCityId',
|
||||||
|
label: '대상 도시',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
optionSource: 'cities',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -294,7 +314,7 @@ const chiefCenter = {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const install = async (page: Page, rejectGeneral = false) => {
|
const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => {
|
||||||
const requests: unknown[] = [];
|
const requests: unknown[] = [];
|
||||||
const generalTurns = turns(30);
|
const generalTurns = turns(30);
|
||||||
const nationTurns = turns(12);
|
const nationTurns = turns(12);
|
||||||
@@ -344,7 +364,7 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
? {
|
? {
|
||||||
kind: 'snapshot',
|
kind: 'snapshot',
|
||||||
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||||||
data: commandTable,
|
data: commandTableResponse,
|
||||||
}
|
}
|
||||||
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
|
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
|
||||||
boardAccess: initial
|
boardAccess: initial
|
||||||
@@ -400,7 +420,7 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
myCity: 1,
|
myCity: 1,
|
||||||
myNation: 1,
|
myNation: 1,
|
||||||
});
|
});
|
||||||
if (name === 'turns.getCommandTable') return response(commandTable);
|
if (name === 'turns.getCommandTable') return response(commandTableResponse);
|
||||||
if (name === 'nation.getChiefCenter') return response(chiefCenter);
|
if (name === 'nation.getChiefCenter') return response(chiefCenter);
|
||||||
if (name === 'turns.reserved.getGeneral')
|
if (name === 'turns.reserved.getGeneral')
|
||||||
return response({ turns: generalTurns, revision: generalRevision });
|
return response({ turns: generalTurns, revision: generalRevision });
|
||||||
@@ -461,6 +481,143 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
return requests;
|
return requests;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: '계략', exact: true }).click();
|
||||||
|
const strategies = [
|
||||||
|
{ name: '선동', guidance: '선택한 도시에 선동을 실행합니다.' },
|
||||||
|
{ name: '탈취', guidance: '선택한 도시에 탈취를 실행합니다.' },
|
||||||
|
{ name: '파괴', guidance: '선택한 도시에 파괴를 실행합니다.' },
|
||||||
|
{ name: '화계', guidance: '선택한 도시에 화계를 실행합니다.' },
|
||||||
|
];
|
||||||
|
for (const strategy of strategies) {
|
||||||
|
const button = picker.getByRole('button', { name: strategy.name, exact: true });
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
await button.click();
|
||||||
|
const form = picker.getByTestId('command-argument-form');
|
||||||
|
await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance);
|
||||||
|
await expect(form.locator('select option')).toHaveCount(2);
|
||||||
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
|
}
|
||||||
|
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
|
||||||
|
const specialCommandTable = {
|
||||||
|
general: [
|
||||||
|
{
|
||||||
|
category: '개인',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: 'che_은퇴',
|
||||||
|
name: '은퇴',
|
||||||
|
reqArg: false,
|
||||||
|
possible: false,
|
||||||
|
status: 'blocked',
|
||||||
|
reason: '나이가 60세 이상이어야 합니다.',
|
||||||
|
inputFields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '인사',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: 'che_강행',
|
||||||
|
name: '강행',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'available',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'destCityId',
|
||||||
|
label: '대상 도시',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
optionSource: 'cities',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '국가',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: 'che_하야',
|
||||||
|
name: '하야',
|
||||||
|
reqArg: false,
|
||||||
|
possible: true,
|
||||||
|
status: 'available',
|
||||||
|
inputFields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nation: [],
|
||||||
|
inputOptions,
|
||||||
|
};
|
||||||
|
const requests = await install(page, false, specialCommandTable);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const editor = page.locator('[data-command-scope="general"]');
|
||||||
|
|
||||||
|
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
let picker = page.getByTestId('command-picker');
|
||||||
|
const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
|
||||||
|
await expect(retirement).toHaveClass(/blocked/);
|
||||||
|
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
|
||||||
|
await retirement.hover();
|
||||||
|
await retirement.focus();
|
||||||
|
await expect(retirement).toBeFocused();
|
||||||
|
await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') });
|
||||||
|
await retirement.click();
|
||||||
|
await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴');
|
||||||
|
|
||||||
|
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||||
|
picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '하야', exact: true }).click();
|
||||||
|
await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야');
|
||||||
|
|
||||||
|
await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||||
|
picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '강행', exact: true }).click();
|
||||||
|
const forceMoveForm = picker.getByTestId('command-argument-form');
|
||||||
|
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
|
||||||
|
await forceMoveForm.locator('select').selectOption('2');
|
||||||
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||||
|
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행');
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(requests);
|
||||||
|
expect(serialized).toContain('"action":"che_은퇴","args":{}');
|
||||||
|
expect(serialized).toContain('"action":"che_하야","args":{}');
|
||||||
|
expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
||||||
|
picker = page.getByTestId('command-picker');
|
||||||
|
await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']);
|
||||||
|
const mobileGeometry = await picker.evaluate((element) => ({
|
||||||
|
width: element.getBoundingClientRect().width,
|
||||||
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||||
|
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
|
||||||
|
}));
|
||||||
|
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
|
||||||
|
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
|
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
|
||||||
|
await picker.getByRole('button', { name: '개인', exact: true }).click();
|
||||||
|
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
|
||||||
|
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
|
||||||
|
});
|
||||||
|
|
||||||
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
||||||
const requests = await install(page);
|
const requests = await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|||||||
@@ -69,29 +69,36 @@ test('reserves an argument command in the real game API and reads it back from P
|
|||||||
try {
|
try {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '계략', exact: true }).click();
|
|
||||||
await page.getByRole('button', { name: /화계/ }).click();
|
|
||||||
const form = page.getByTestId('command-argument-form');
|
|
||||||
await expect(form).toBeVisible();
|
|
||||||
const citySelect = form.locator('select');
|
|
||||||
const optionValues = await citySelect
|
|
||||||
.locator('option')
|
|
||||||
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
|
||||||
const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId));
|
|
||||||
expect(targetCityId).toBeGreaterThan(0);
|
|
||||||
await citySelect.selectOption(String(targetCityId));
|
|
||||||
|
|
||||||
const generalSection = page.locator('.reserved-section').filter({ hasText: '일반 예턴' });
|
const generalSection = page.locator('.reserved-section').filter({ hasText: '일반 예턴' });
|
||||||
const lastTurn = generalSection.locator('.reserved-item').nth(29);
|
const lastTurn = generalSection.locator('.reserved-item').nth(29);
|
||||||
await lastTurn.getByRole('button', { name: '배치' }).click();
|
const form = page.getByTestId('command-argument-form');
|
||||||
await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계');
|
for (const strategy of [
|
||||||
|
{ key: 'che_선동', name: '선동' },
|
||||||
|
{ key: 'che_탈취', name: '탈취' },
|
||||||
|
{ key: 'che_파괴', name: '파괴' },
|
||||||
|
{ key: 'che_화계', name: '화계' },
|
||||||
|
]) {
|
||||||
|
await page.getByRole('button', { name: '계략', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: new RegExp(strategy.name) }).click();
|
||||||
|
await expect(form).toBeVisible();
|
||||||
|
const citySelect = form.locator('select');
|
||||||
|
const optionValues = await citySelect
|
||||||
|
.locator('option')
|
||||||
|
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
||||||
|
const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId));
|
||||||
|
expect(targetCityId).toBeGreaterThan(0);
|
||||||
|
await citySelect.selectOption(String(targetCityId));
|
||||||
|
|
||||||
const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29];
|
await lastTurn.getByRole('button', { name: '배치' }).click();
|
||||||
expect(persisted).toEqual({
|
await expect(lastTurn.locator('.turn-action')).toHaveText(strategy.key);
|
||||||
index: 29,
|
|
||||||
action: 'che_화계',
|
const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29];
|
||||||
args: { destCityId: targetCityId },
|
expect(persisted).toEqual({
|
||||||
});
|
index: 29,
|
||||||
|
action: strategy.key,
|
||||||
|
args: { destCityId: targetCityId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: '국가:인사', exact: true }).click();
|
await page.getByRole('button', { name: '국가:인사', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /포상/ }).click();
|
await page.getByRole('button', { name: /포상/ }).click();
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { resolve } from 'node:path';
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32029,
|
||||||
|
data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||||
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||||
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||||
@@ -21,6 +28,8 @@ type NavigationFixture = {
|
|||||||
operations: string[];
|
operations: string[];
|
||||||
generalName?: string;
|
generalName?: string;
|
||||||
generalTurnTime?: string;
|
generalTurnTime?: string;
|
||||||
|
serverTime?: string;
|
||||||
|
clockMode?: 'realtime' | 'manual';
|
||||||
cityDefence?: number;
|
cityDefence?: number;
|
||||||
cityState?: number;
|
cityState?: number;
|
||||||
nationRate?: number;
|
nationRate?: number;
|
||||||
@@ -31,6 +40,7 @@ type NavigationFixture = {
|
|||||||
commandBlockedCount?: number;
|
commandBlockedCount?: number;
|
||||||
forceSnapshotCalls?: number;
|
forceSnapshotCalls?: number;
|
||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
|
accessLimitAfterCalls?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
refCommandCategories?: boolean;
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
@@ -363,11 +373,21 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
year: state.currentYear ?? 185,
|
year: state.currentYear ?? 185,
|
||||||
month: state.currentMonth ?? 1,
|
month: state.currentMonth ?? 1,
|
||||||
turnTerm: 10,
|
turnTerm: 10,
|
||||||
|
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
|
||||||
|
clockMode: state.clockMode ?? 'realtime',
|
||||||
scenarioTitle: state.scenarioTitle ?? '',
|
scenarioTitle: state.scenarioTitle ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (operation === 'dashboard.getContextBundleDelta') {
|
if (operation === 'dashboard.getContextBundleDelta') {
|
||||||
state.generalMeCalls += 1;
|
state.generalMeCalls += 1;
|
||||||
|
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
|
||||||
|
return errorResponse(
|
||||||
|
operation,
|
||||||
|
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
|
||||||
|
'(다음 접속 가능 시각: 2026-08-15 12:34:56) ' +
|
||||||
|
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'
|
||||||
|
);
|
||||||
|
}
|
||||||
const input = operationInput(route, index);
|
const input = operationInput(route, index);
|
||||||
const include = input.include ?? {};
|
const include = input.include ?? {};
|
||||||
const forceSnapshot = input.forceSnapshot === true;
|
const forceSnapshot = input.forceSnapshot === true;
|
||||||
@@ -499,7 +519,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
operations.forEach((operation, index) => {
|
operations.forEach((operation, index) => {
|
||||||
if (operation !== 'dashboard.getContextBundleDelta') return;
|
if (operation !== 'dashboard.getContextBundleDelta') return;
|
||||||
const item = results[index];
|
const item = results[index];
|
||||||
if (!item) return;
|
if (!item || !('result' in item)) return;
|
||||||
const data = item.result.data as {
|
const data = item.result.data as {
|
||||||
context?: { kind: string };
|
context?: { kind: string };
|
||||||
commandTable?: { kind: string };
|
commandTable?: { kind: string };
|
||||||
@@ -787,7 +807,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('main general card and command clock render the next turn with second precision', async ({ page }) => {
|
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 0,
|
officerLevel: 0,
|
||||||
permission: 0,
|
permission: 0,
|
||||||
@@ -797,22 +817,29 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
generalName: 'Administrator',
|
generalName: 'Administrator',
|
||||||
generalTurnTime: '2026-08-13T00:07:06.713Z',
|
generalTurnTime: '2026-08-13T00:09:10.713Z',
|
||||||
|
serverTime: '2026-08-13T00:07:06.250Z',
|
||||||
|
clockMode: 'realtime',
|
||||||
currentYear: 179,
|
currentYear: 179,
|
||||||
currentMonth: 8,
|
currentMonth: 8,
|
||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' });
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await waitForMain(page);
|
await waitForMain(page);
|
||||||
|
|
||||||
const title = page.locator('[data-main-target="general"] .general-title').first();
|
const title = page.locator('[data-main-target="general"] .general-title').first();
|
||||||
await expect(title).toContainText('Administrator');
|
await expect(title).toContainText('Administrator');
|
||||||
await expect(title).toContainText('용장');
|
await expect(title).toContainText('용장');
|
||||||
await expect(title).toContainText('09:07:06');
|
await expect(title).toContainText('09:09:10');
|
||||||
await expect(title).not.toContainText('00:07');
|
await expect(title).not.toContainText('00:09');
|
||||||
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
await expect(commandClock).toHaveText('09:07:06');
|
await expect(commandClock).toHaveText('09:07:06');
|
||||||
await expect(commandClock).not.toHaveText('00:07');
|
await expect(commandClock).not.toHaveText('00:07');
|
||||||
|
await page.clock.runFor(1_000);
|
||||||
|
await expect(commandClock).toHaveText('09:07:07');
|
||||||
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
|
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
|
||||||
await expect(generalCard).toContainText('수비 함(훈사80)');
|
await expect(generalCard).toContainText('수비 함(훈사80)');
|
||||||
await expect(generalCard).toContainText('5 턴');
|
await expect(generalCard).toContainText('5 턴');
|
||||||
@@ -857,9 +884,9 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
const target = resolve(artifactRoot);
|
const target = resolve(artifactRoot);
|
||||||
await mkdir(target, { recursive: true });
|
await mkdir(target, { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
|
page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }),
|
||||||
writeFile(
|
writeFile(
|
||||||
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
|
resolve(target, 'main-turn-time-local-desktop-1200.json'),
|
||||||
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
|
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -867,9 +894,9 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
||||||
await expect(mobileTitle).toContainText('09:07:06');
|
await expect(mobileTitle).toContainText('09:09:10');
|
||||||
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
await expect(mobileCommandClock).toHaveText('09:07:06');
|
await expect(mobileCommandClock).toHaveText('09:07:07');
|
||||||
const mobileGeometry = {
|
const mobileGeometry = {
|
||||||
title: await mobileTitle.evaluate((element) => ({
|
title: await mobileTitle.evaluate((element) => ({
|
||||||
width: element.getBoundingClientRect().width,
|
width: element.getBoundingClientRect().width,
|
||||||
@@ -895,15 +922,23 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.screenshot({
|
page.screenshot({
|
||||||
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
|
path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'),
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
}),
|
}),
|
||||||
writeFile(
|
writeFile(
|
||||||
resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'),
|
resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'),
|
||||||
`${JSON.stringify(mobileGeometry, null, 2)}\n`
|
`${JSON.stringify(mobileGeometry, null, 2)}\n`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.clockMode = 'manual';
|
||||||
|
state.serverTime = '2026-08-13T00:08:30.000Z';
|
||||||
|
await page.reload();
|
||||||
|
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
|
await expect(frozenClock).toHaveText('09:08:30');
|
||||||
|
await page.clock.runFor(2_000);
|
||||||
|
await expect(frozenClock).toHaveText('09:08:30');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
|
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
|
||||||
@@ -2041,7 +2076,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
});
|
});
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
|
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
|
||||||
.toEqual(['general.getFrontStatus']);
|
.toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']);
|
||||||
|
|
||||||
const profile = await page.evaluate(() => {
|
const profile = await page.evaluate(() => {
|
||||||
const probe = (
|
const probe = (
|
||||||
@@ -2199,6 +2234,55 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
|
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
accessLimitAfterCalls: 1,
|
||||||
|
};
|
||||||
|
await installRealtimeHarness(page);
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const operationsBeforeLimit = state.operations.length;
|
||||||
|
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
|
.toBe(false);
|
||||||
|
expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']);
|
||||||
|
|
||||||
|
const operationsAfterLimit = state.operations.length;
|
||||||
|
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
expect(state.operations).toHaveLength(operationsAfterLimit);
|
||||||
|
|
||||||
|
state.accessLimitAfterCalls = undefined;
|
||||||
|
await page.getByRole('button', { name: '갱 신' }).click();
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
@@ -2226,7 +2310,10 @@ test('global activity, world history, and a month boundary refresh their visible
|
|||||||
const operationsBeforeGlobal = state.operations.length;
|
const operationsBeforeGlobal = state.operations.length;
|
||||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||||
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
|
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
|
||||||
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
|
expect(state.operations.slice(operationsBeforeGlobal)).toEqual([
|
||||||
|
'dashboard.getContextBundleDelta',
|
||||||
|
'general.getRecentRecords',
|
||||||
|
]);
|
||||||
|
|
||||||
state.worldHistory = [
|
state.worldHistory = [
|
||||||
{ id: 5, text: '자동 갱신된 중원 정세' },
|
{ id: 5, text: '자동 갱신된 중원 정세' },
|
||||||
@@ -2235,7 +2322,10 @@ test('global activity, world history, and a month boundary refresh their visible
|
|||||||
const operationsBeforeHistory = state.operations.length;
|
const operationsBeforeHistory = state.operations.length;
|
||||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||||
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
|
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
|
||||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
|
expect(state.operations.slice(operationsBeforeHistory)).toEqual([
|
||||||
|
'dashboard.getContextBundleDelta',
|
||||||
|
'general.getRecentRecords',
|
||||||
|
]);
|
||||||
|
|
||||||
state.currentMonth = 2;
|
state.currentMonth = 2;
|
||||||
const operationsBeforeMonth = state.operations.length;
|
const operationsBeforeMonth = state.operations.length;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
import { addMinutes } from 'date-fns';
|
import { addMinutes } from 'date-fns';
|
||||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||||
import type {
|
import type {
|
||||||
CommandMapData,
|
CommandMapData,
|
||||||
CommandMapLayout,
|
CommandMapLayout,
|
||||||
@@ -19,6 +19,8 @@ const props = defineProps<{
|
|||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
turnTermMinutes?: number;
|
turnTermMinutes?: number;
|
||||||
|
serverTime?: string;
|
||||||
|
clockMode?: 'realtime' | 'manual';
|
||||||
autorunLimit?: number | null;
|
autorunLimit?: number | null;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
mapData?: CommandMapData | null;
|
mapData?: CommandMapData | null;
|
||||||
@@ -56,16 +58,48 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
|||||||
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
|
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
|
||||||
time: date
|
time: date
|
||||||
? term >= 5
|
? term >= 5
|
||||||
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
|
? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
: `${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}`
|
: `${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||||
: '--:--',
|
: '--:--',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentTurnTime = computed(() =>
|
const currentServerTime = ref('--:--:--');
|
||||||
props.general?.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '--:--:--'
|
let sampledServerTimeMs: number | null = null;
|
||||||
|
let sampledClientTimeMs = 0;
|
||||||
|
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const updateServerClock = () => {
|
||||||
|
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||||
|
serverClockTimer = undefined;
|
||||||
|
if (sampledServerTimeMs === null) {
|
||||||
|
currentServerTime.value = '--:--:--';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projectedTime = new Date(
|
||||||
|
props.clockMode === 'manual' ? sampledServerTimeMs : sampledServerTimeMs + Date.now() - sampledClientTimeMs
|
||||||
|
);
|
||||||
|
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
|
||||||
|
if (props.clockMode !== 'manual') {
|
||||||
|
serverClockTimer = setTimeout(updateServerClock, 1_000 - projectedTime.getMilliseconds());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.serverTime, props.clockMode] as const,
|
||||||
|
([serverTime]) => {
|
||||||
|
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN;
|
||||||
|
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
|
||||||
|
sampledClientTimeMs = Date.now();
|
||||||
|
updateServerClock();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -75,7 +109,7 @@ const currentTurnTime = computed(() =>
|
|||||||
:command-table="props.commandTable"
|
:command-table="props.commandTable"
|
||||||
:loading="props.loading"
|
:loading="props.loading"
|
||||||
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
||||||
:current-time="currentTurnTime"
|
:current-time="currentServerTime"
|
||||||
:map-data="props.mapData"
|
:map-data="props.mapData"
|
||||||
:map-layout="props.mapLayout"
|
:map-layout="props.mapLayout"
|
||||||
@reserve-bulk="emit('set-general-turns', $event)"
|
@reserve-bulk="emit('set-general-turns', $event)"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||||
@@ -232,7 +232,7 @@ const specialText = computed(() => {
|
|||||||
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
||||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
||||||
<span data-general-turn-time>{{
|
<span data-general-turn-time>{{
|
||||||
props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-'
|
props.general.turnTime ? formatLocalTimeSeconds(props.general.turnTime) : '-'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const realtimeEnabled = ref(true);
|
const realtimeEnabled = ref(true);
|
||||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||||
const realtimeActive = ref(false);
|
const realtimeActive = ref(false);
|
||||||
|
const accessLimited = ref(false);
|
||||||
|
|
||||||
|
const handleDashboardError = (value: unknown) => {
|
||||||
|
const message = resolveErrorMessage(value);
|
||||||
|
error.value = message;
|
||||||
|
if (message.startsWith('접속 제한중입니다.')) {
|
||||||
|
accessLimited.value = true;
|
||||||
|
realtimeStatus.value = 'paused';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const general = ref<PresentGeneralContext['general'] | null>(null);
|
const general = ref<PresentGeneralContext['general'] | null>(null);
|
||||||
const city = ref<PresentGeneralContext['city'] | null>(null);
|
const city = ref<PresentGeneralContext['city'] | null>(null);
|
||||||
@@ -508,6 +518,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
{ context: true, commandTable: true, boardAccess: true },
|
{ context: true, commandTable: true, boardAccess: true },
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
accessLimited.value = false;
|
||||||
applyDashboardPatch(contextPatch);
|
applyDashboardPatch(contextPatch);
|
||||||
const context = contextSnapshot;
|
const context = contextSnapshot;
|
||||||
|
|
||||||
@@ -573,7 +584,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
handleDashboardError(err);
|
||||||
} finally {
|
} finally {
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -626,14 +637,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (plan.records) recordsError.value = null;
|
if (plan.records) recordsError.value = null;
|
||||||
if (plan.frontStatus) frontStatusError.value = null;
|
if (plan.frontStatus) frontStatusError.value = null;
|
||||||
try {
|
try {
|
||||||
const contextBundlePromise =
|
const contextPatch = await fetchContextBundlePatch({
|
||||||
plan.context || plan.commands || plan.boardAccess
|
// Every automatic refresh crosses this access-limit gate. The
|
||||||
? fetchContextBundlePatch({
|
// context delta is usually unchanged and therefore stays small.
|
||||||
context: plan.context,
|
context: true,
|
||||||
commandTable: plan.commands,
|
commandTable: plan.commands,
|
||||||
boardAccess: plan.boardAccess,
|
boardAccess: plan.boardAccess,
|
||||||
})
|
});
|
||||||
: Promise.resolve(undefined);
|
accessLimited.value = false;
|
||||||
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
||||||
const mapPromise = plan.map
|
const mapPromise = plan.map
|
||||||
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
||||||
@@ -659,8 +670,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
})
|
})
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
|
|
||||||
const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||||
contextBundlePromise,
|
|
||||||
lobbyPromise,
|
lobbyPromise,
|
||||||
mapPromise,
|
mapPromise,
|
||||||
contactsPromise,
|
contactsPromise,
|
||||||
@@ -669,7 +679,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
frontPromise,
|
frontPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {};
|
const patch: DashboardReadModelPatch = { ...contextPatch };
|
||||||
if (lobby !== undefined) patch.lobbyInfo = lobby;
|
if (lobby !== undefined) patch.lobbyInfo = lobby;
|
||||||
if (map !== undefined) patch.worldMap = map;
|
if (map !== undefined) patch.worldMap = map;
|
||||||
if (contacts !== undefined) patch.messageContacts = contacts;
|
if (contacts !== undefined) patch.messageContacts = contacts;
|
||||||
@@ -690,7 +700,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
applyDashboardPatch(patch);
|
applyDashboardPatch(patch);
|
||||||
publishDashboardPatch(patch);
|
publishDashboardPatch(patch);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
handleDashboardError(err);
|
||||||
} finally {
|
} finally {
|
||||||
refreshing.value = false;
|
refreshing.value = false;
|
||||||
}
|
}
|
||||||
@@ -709,7 +719,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
applyDashboardPatch(patch);
|
applyDashboardPatch(patch);
|
||||||
publishDashboardPatch(patch);
|
publishDashboardPatch(patch);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
handleDashboardError(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -970,6 +980,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
realtimeActive.value &&
|
realtimeActive.value &&
|
||||||
document.visibilityState !== 'hidden' &&
|
document.visibilityState !== 'hidden' &&
|
||||||
realtimeEnabled.value &&
|
realtimeEnabled.value &&
|
||||||
|
!accessLimited.value &&
|
||||||
session.isReady &&
|
session.isReady &&
|
||||||
session.hasGeneral &&
|
session.hasGeneral &&
|
||||||
generalId.value !== null;
|
generalId.value !== null;
|
||||||
@@ -1158,11 +1169,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
session.profile,
|
session.profile,
|
||||||
session.user?.id,
|
session.user?.id,
|
||||||
generalId.value,
|
generalId.value,
|
||||||
|
accessLimited.value,
|
||||||
],
|
],
|
||||||
([active, enabled, ready, hasGeneral]) => {
|
([active, enabled, ready, hasGeneral, , , , , limited]) => {
|
||||||
realtimeStatus.value = !enabled ? 'paused' : realtimeStatus.value;
|
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
|
||||||
if (!active || !ready || !hasGeneral) {
|
if (!active || !ready || !hasGeneral) {
|
||||||
realtimeStatus.value = enabled ? 'idle' : 'paused';
|
realtimeStatus.value = enabled && !limited ? 'idle' : 'paused';
|
||||||
}
|
}
|
||||||
reconcileRealtimeCoordinator();
|
reconcileRealtimeCoordinator();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,11 @@ export const formatSeoulHourMinute = (value: string | Date): string =>
|
|||||||
|
|
||||||
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
||||||
formatServerDateTime(value, { format: 'timeSeconds' });
|
formatServerDateTime(value, { format: 'timeSeconds' });
|
||||||
|
|
||||||
|
export const formatLocalTimeSeconds = (value: string | Date): string => {
|
||||||
|
const parsed = value instanceof Date ? value : new Date(value);
|
||||||
|
if (!Number.isFinite(parsed.getTime())) return '-';
|
||||||
|
return [parsed.getHours(), parsed.getMinutes(), parsed.getSeconds()]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
};
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ watch(
|
|||||||
:current-year="lobbyInfo?.year"
|
:current-year="lobbyInfo?.year"
|
||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
|
:server-time="lobbyInfo?.serverTime"
|
||||||
|
:clock-mode="lobbyInfo?.clockMode"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
:map-data="worldMap"
|
:map-data="worldMap"
|
||||||
:map-layout="mapLayout"
|
:map-layout="mapLayout"
|
||||||
@@ -331,6 +333,8 @@ watch(
|
|||||||
:current-year="lobbyInfo?.year"
|
:current-year="lobbyInfo?.year"
|
||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
|
:server-time="lobbyInfo?.serverTime"
|
||||||
|
:clock-mode="lobbyInfo?.clockMode"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
:map-data="worldMap"
|
:map-data="worldMap"
|
||||||
:map-layout="mapLayout"
|
:map-layout="mapLayout"
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { formatSeoulDateTime, formatSeoulHourMinute, formatSeoulTimeSeconds } from '../src/utils/legacyDateTime.ts';
|
import {
|
||||||
|
formatLocalTimeSeconds,
|
||||||
|
formatSeoulDateTime,
|
||||||
|
formatSeoulHourMinute,
|
||||||
|
formatSeoulTimeSeconds,
|
||||||
|
} from '../src/utils/legacyDateTime.ts';
|
||||||
|
|
||||||
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
||||||
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
||||||
@@ -14,3 +19,13 @@ void test('keeps legacy timezone-less server timestamps unchanged', () => {
|
|||||||
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
||||||
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('formats an ISO instant with the client local clock', () => {
|
||||||
|
const instant = new Date('2026-08-13T00:07:06.713Z');
|
||||||
|
const expected = [instant.getHours(), instant.getMinutes(), instant.getSeconds()]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
assert.equal(formatLocalTimeSeconds(instant), expected);
|
||||||
|
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
|
||||||
|
assert.equal(formatLocalTimeSeconds('invalid'), '-');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
import type { GatewayProfileRepository } from '../orchestrator/profileRepository.js';
|
||||||
|
|
||||||
|
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
|
||||||
|
const INTERNAL_TOKEN_CONTEXT = 'sammo:profile-status-source:v1';
|
||||||
|
|
||||||
|
const deriveInternalToken = (secret: string): string =>
|
||||||
|
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||||
|
|
||||||
|
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
|
||||||
|
const candidate = Array.isArray(provided) ? provided[0] : provided;
|
||||||
|
if (!candidate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidateBuffer = Buffer.from(candidate);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerProfileStatusInternalRoute = (
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: {
|
||||||
|
profiles: GatewayProfileRepository;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
): void => {
|
||||||
|
app.get<{ Params: { profileName: string } }>('/internal/profile-status/:profileName', async (request, reply) => {
|
||||||
|
void reply.header('Cache-Control', 'no-store');
|
||||||
|
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||||
|
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profileName = request.params.profileName;
|
||||||
|
const profile = await options.profiles.getProfile(profileName);
|
||||||
|
if (!profile) {
|
||||||
|
await reply.status(404).send({ ok: false, error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await reply.send({ profileName: profile.profileName, status: profile.status });
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -26,6 +26,7 @@ import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRep
|
|||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||||
|
import { registerProfileStatusInternalRoute } from './lobby/profileStatusInternalRoute.js';
|
||||||
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
||||||
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||||
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
||||||
@@ -98,6 +99,10 @@ export const createGatewayApiServer = async () => {
|
|||||||
users,
|
users,
|
||||||
secret: config.gameTokenSecret,
|
secret: config.gameTokenSecret,
|
||||||
});
|
});
|
||||||
|
registerProfileStatusInternalRoute(app, {
|
||||||
|
profiles,
|
||||||
|
secret: config.gameTokenSecret,
|
||||||
|
});
|
||||||
|
|
||||||
await app.register(fastifyTRPCPlugin, {
|
await app.register(fastifyTRPCPlugin, {
|
||||||
prefix: config.trpcPath,
|
prefix: config.trpcPath,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import fastify from 'fastify';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
|
||||||
|
import { registerProfileStatusInternalRoute } from '../src/lobby/profileStatusInternalRoute.js';
|
||||||
|
|
||||||
|
const secret = 'gateway-profile-status-test-secret';
|
||||||
|
const token = createHmac('sha256', secret).update('sammo:profile-status-source:v1').digest('hex');
|
||||||
|
|
||||||
|
describe('profile status internal route', () => {
|
||||||
|
it('requires a purpose-derived token and returns only the durable status', async () => {
|
||||||
|
const app = fastify();
|
||||||
|
const profiles = {
|
||||||
|
getProfile: vi.fn(async (profileName: string) => ({ profileName, status: 'PAUSED' })),
|
||||||
|
} as unknown as GatewayProfileRepository;
|
||||||
|
registerProfileStatusInternalRoute(app, { profiles, secret });
|
||||||
|
|
||||||
|
const unauthorized = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/internal/profile-status/che%3Adefault',
|
||||||
|
headers: { 'x-sammo-internal-token': secret },
|
||||||
|
});
|
||||||
|
expect(unauthorized.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/internal/profile-status/che%3Adefault',
|
||||||
|
headers: { 'x-sammo-internal-token': token },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store');
|
||||||
|
expect(response.json()).toEqual({ profileName: 'che:default', status: 'PAUSED' });
|
||||||
|
expect(Object.keys(response.json()).sort()).toEqual(['profileName', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for an unknown profile', async () => {
|
||||||
|
const app = fastify();
|
||||||
|
const profiles = { getProfile: vi.fn(async () => null) } as unknown as GatewayProfileRepository;
|
||||||
|
registerProfileStatusInternalRoute(app, { profiles, secret });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/internal/profile-status/missing',
|
||||||
|
headers: { 'x-sammo-internal-token': token },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const ACCESS_REFRESH_LIMIT_COEFFICIENT = 10;
|
||||||
|
|
||||||
|
export type AccessLimitLevel = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export const calculateAccessRefreshLimit = (tickSeconds: number): number => {
|
||||||
|
if (!Number.isFinite(tickSeconds) || tickSeconds <= 0) {
|
||||||
|
throw new RangeError('tickSeconds must be a positive finite number.');
|
||||||
|
}
|
||||||
|
const turnMinutes = tickSeconds / 60;
|
||||||
|
return Math.round(Math.pow(turnMinutes, 0.6) * 3) * ACCESS_REFRESH_LIMIT_COEFFICIENT;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAccessRefreshLimit = (tickSeconds: number, storedLimit: unknown): number => {
|
||||||
|
if (typeof storedLimit === 'number' && Number.isSafeInteger(storedLimit) && storedLimit > 0) {
|
||||||
|
return storedLimit;
|
||||||
|
}
|
||||||
|
return calculateAccessRefreshLimit(tickSeconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAccessLimitLevel = (refreshScore: number, refreshLimit: number): AccessLimitLevel => {
|
||||||
|
if (refreshScore > refreshLimit) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (refreshScore > refreshLimit * 0.9) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -23,3 +23,4 @@ export * from './ranking/legacyColor.js';
|
|||||||
export * from './auth/accountIconProjection.js';
|
export * from './auth/accountIconProjection.js';
|
||||||
export * from './logging/formatLegacyLogHtml.js';
|
export * from './logging/formatLegacyLogHtml.js';
|
||||||
export * from './gateway/profileStatus.js';
|
export * from './gateway/profileStatus.js';
|
||||||
|
export * from './game/accessPenalty.js';
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateAccessRefreshLimit,
|
||||||
|
resolveAccessLimitLevel,
|
||||||
|
resolveAccessRefreshLimit,
|
||||||
|
} from '../src/game/accessPenalty.js';
|
||||||
|
|
||||||
|
describe('legacy access penalty', () => {
|
||||||
|
it.each([
|
||||||
|
[60, 30],
|
||||||
|
[300, 80],
|
||||||
|
[600, 120],
|
||||||
|
[1_200, 180],
|
||||||
|
])('calculates the Ref refresh limit for %i-second turns', (tickSeconds, expected) => {
|
||||||
|
expect(calculateAccessRefreshLimit(tickSeconds)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a persisted positive limit and derives a missing one', () => {
|
||||||
|
expect(resolveAccessRefreshLimit(600, 777)).toBe(777);
|
||||||
|
expect(resolveAccessRefreshLimit(600, undefined)).toBe(120);
|
||||||
|
expect(resolveAccessRefreshLimit(600, 0)).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the strict Ref threshold and warns only above ninety percent', () => {
|
||||||
|
expect(resolveAccessLimitLevel(108, 120)).toBe(0);
|
||||||
|
expect(resolveAccessLimitLevel(109, 120)).toBe(1);
|
||||||
|
expect(resolveAccessLimitLevel(120, 120)).toBe(1);
|
||||||
|
expect(resolveAccessLimitLevel(121, 120)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,206 +1,100 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
|
||||||
import {
|
|
||||||
notBeNeutral,
|
|
||||||
occupiedCity,
|
|
||||||
suppliedCity,
|
|
||||||
notOccupiedDestCity,
|
|
||||||
notNeutralDestCity,
|
|
||||||
reqGeneralGold,
|
|
||||||
reqGeneralRice,
|
|
||||||
disallowDiplomacyBetweenStatus,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
|
||||||
import type {
|
|
||||||
GeneralActionOutcome,
|
|
||||||
GeneralActionResolveContext,
|
|
||||||
GeneralActionResolver,
|
|
||||||
GeneralActionEffect,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { createGeneralPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
|
||||||
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { createCityPatchEffect, type GeneralActionEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
||||||
import {
|
import {
|
||||||
|
STRATEGY_ARGS_SCHEMA,
|
||||||
|
StrategyActionDefinition,
|
||||||
|
StrategyActionResolver,
|
||||||
buildStrategyActionContext,
|
buildStrategyActionContext,
|
||||||
CommandResolver as StrategyCommandResolver,
|
type StrategyActionConfig,
|
||||||
type FireAttackResolveContext,
|
type StrategyArgs,
|
||||||
} from './che_화계.js';
|
type StrategyResolveContext,
|
||||||
|
type StrategyResult,
|
||||||
|
} from './strategyCommand.js';
|
||||||
|
|
||||||
export interface AgitateResolveContext<
|
const CONFIG = {
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
key: 'che_선동',
|
||||||
> extends FireAttackResolveContext<TriggerState> {
|
name: '선동',
|
||||||
env?: TurnCommandEnv;
|
statKey: 'leadership',
|
||||||
}
|
statExpKey: 'leadership_exp',
|
||||||
|
damageMode: 'agitate',
|
||||||
|
injuryGeneral: true,
|
||||||
|
} as const satisfies StrategyActionConfig;
|
||||||
|
|
||||||
const ACTION_NAME = '선동';
|
export type AgitateArgs = StrategyArgs;
|
||||||
const ACTION_KEY = 'che_선동';
|
export type AgitateResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
const ARGS_SCHEMA = z.object({
|
StrategyResolveContext<TriggerState>;
|
||||||
destCityId: z.number(),
|
|
||||||
});
|
|
||||||
export type AgitateArgs = z.infer<typeof ARGS_SCHEMA>;
|
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
> extends StrategyActionResolver<TriggerState> {
|
||||||
readonly key = ACTION_KEY;
|
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
|
||||||
private readonly command: StrategyCommandResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
const modules = env.generalActionModules ?? [];
|
super(env, CONFIG);
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
|
||||||
this.command = new StrategyCommandResolver<TriggerState>(modules, {
|
|
||||||
...env,
|
|
||||||
statKey: 'leadership',
|
|
||||||
damageMode: 'agitate',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
protected resolveSuccess(
|
||||||
const ctx = context as AgitateResolveContext<TriggerState>;
|
context: StrategyResolveContext<TriggerState>,
|
||||||
const general = ctx.general;
|
_args: StrategyArgs,
|
||||||
const destCity = ctx.destCity;
|
result: StrategyResult<TriggerState>,
|
||||||
if (!destCity) throw new Error('Target city missing');
|
effects: GeneralActionEffect<TriggerState>[]
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
): void {
|
||||||
const city = ctx.city;
|
|
||||||
if (!city) throw new Error('Source city missing');
|
|
||||||
const result = this.command.resolve(
|
|
||||||
{
|
|
||||||
...ctx,
|
|
||||||
city,
|
|
||||||
destCity,
|
|
||||||
destGenerals: ctx.destGenerals,
|
|
||||||
},
|
|
||||||
ctx.rng
|
|
||||||
);
|
|
||||||
general.gold = Math.max(0, general.gold - result.costGold);
|
|
||||||
general.rice = Math.max(0, general.rice - result.costRice);
|
|
||||||
general.experience += result.exp;
|
|
||||||
general.dedication += result.dedication;
|
|
||||||
general.meta.leadership_exp =
|
|
||||||
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1;
|
|
||||||
if (!result.success) {
|
|
||||||
ctx.addLog(
|
|
||||||
`<G><b>${destCity.name}</b></>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.`
|
|
||||||
);
|
|
||||||
return { effects };
|
|
||||||
}
|
|
||||||
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
|
||||||
const newSecu = Math.max(0, destCity.security - result.agriDamage);
|
|
||||||
const currentTrust =
|
const currentTrust =
|
||||||
typeof destCity.meta.trust === 'number' ? readLegacyCityTrust(destCity.meta.trust) : 50;
|
typeof context.destCity.meta.trust === 'number' ? readLegacyCityTrust(context.destCity.meta.trust) : 50;
|
||||||
const newTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.commDamage));
|
const nextTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.secondaryAmount));
|
||||||
|
|
||||||
// Log
|
|
||||||
const commandName = ACTION_NAME;
|
|
||||||
const destCityName = destCity.name;
|
|
||||||
ctx.addLog(`<G><b>${destCityName}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, {
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
});
|
|
||||||
ctx.addLog(
|
|
||||||
`도시의 치안이 <C>${result.agriDamage}</>, 민심이 <C>${result.commDamage.toFixed(
|
|
||||||
1
|
|
||||||
)}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
|
||||||
{
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// City Update
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createCityPatchEffect(
|
createCityPatchEffect(
|
||||||
{
|
{
|
||||||
security: newSecu,
|
security: Math.max(0, context.destCity.security - result.primaryAmount),
|
||||||
state: 32,
|
state: 32,
|
||||||
meta: {
|
meta: {
|
||||||
...destCity.meta,
|
...context.destCity.meta,
|
||||||
trust: newTrust,
|
trust: nextTrust,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
args.destCityId
|
context.destCity.id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
consumeSuccessfulStrategyItem(this.pipeline, context);
|
const destCityName = context.destCity.name;
|
||||||
for (const injured of result.injuredGenerals) {
|
context.addLog(`<G><b>${destCityName}</b></>의 백성들이 동요하고 있습니다.`, {
|
||||||
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
|
scope: LogScope.SYSTEM,
|
||||||
ctx.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
|
category: LogCategory.SUMMARY,
|
||||||
generalId: injured.id,
|
format: LogFormat.MONTH,
|
||||||
format: LogFormat.MONTH,
|
});
|
||||||
});
|
context.addLog(
|
||||||
}
|
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
|
||||||
|
{ format: LogFormat.MONTH }
|
||||||
return { effects };
|
);
|
||||||
|
context.addLog(
|
||||||
|
`도시의 치안이 <C>${result.primaryAmount}</>, 민심이 <C>${result.secondaryAmount.toFixed(
|
||||||
|
1
|
||||||
|
)}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
||||||
|
{ format: LogFormat.PLAIN }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, AgitateArgs, GeneralActionResolveContext<TriggerState>> {
|
> extends StrategyActionDefinition<TriggerState> {
|
||||||
public readonly key = ACTION_KEY;
|
|
||||||
public readonly name = ACTION_NAME;
|
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver<TriggerState>(env);
|
super(env, CONFIG, new ActionResolver<TriggerState>(env));
|
||||||
}
|
|
||||||
|
|
||||||
parseArgs(raw: unknown): AgitateArgs | null {
|
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildMinConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = ((env.develCost as number) ?? 100) * 5;
|
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
|
||||||
}
|
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = ((env.develCost as number) ?? 100) * 5;
|
|
||||||
return [
|
|
||||||
notBeNeutral(),
|
|
||||||
occupiedCity(),
|
|
||||||
suppliedCity(),
|
|
||||||
notOccupiedDestCity(),
|
|
||||||
notNeutralDestCity(),
|
|
||||||
reqGeneralGold(() => cost),
|
|
||||||
reqGeneralRice(() => cost),
|
|
||||||
disallowDiplomacyBetweenStatus({
|
|
||||||
7: '불가침국입니다.',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
|
||||||
return this.resolver.resolve(context, args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = buildStrategyActionContext;
|
||||||
const strategyContext = buildStrategyActionContext(base, options);
|
|
||||||
if (!strategyContext) return null;
|
|
||||||
return {
|
|
||||||
...strategyContext,
|
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_선동',
|
key: CONFIG.key,
|
||||||
category: '군사',
|
category: '계략',
|
||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { destCityId: 0 },
|
availabilityArgs: { destCityId: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: STRATEGY_ARGS_SCHEMA,
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,124 +1,64 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
|
||||||
import {
|
|
||||||
notBeNeutral,
|
|
||||||
occupiedCity,
|
|
||||||
suppliedCity,
|
|
||||||
notOccupiedDestCity,
|
|
||||||
notNeutralDestCity,
|
|
||||||
reqGeneralGold,
|
|
||||||
reqGeneralRice,
|
|
||||||
disallowDiplomacyBetweenStatus,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
|
||||||
import type {
|
|
||||||
GeneralActionOutcome,
|
|
||||||
GeneralActionResolveContext,
|
|
||||||
GeneralActionResolver,
|
|
||||||
GeneralActionEffect,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { createCityPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
|
||||||
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
|
||||||
import {
|
import {
|
||||||
|
createCityPatchEffect,
|
||||||
|
createNationPatchEffect,
|
||||||
|
type GeneralActionEffect,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import {
|
||||||
|
STRATEGY_ARGS_SCHEMA,
|
||||||
|
StrategyActionDefinition,
|
||||||
|
StrategyActionResolver,
|
||||||
buildStrategyActionContext,
|
buildStrategyActionContext,
|
||||||
CommandResolver as StrategyCommandResolver,
|
type StrategyActionConfig,
|
||||||
type FireAttackResolveContext,
|
type StrategyArgs,
|
||||||
} from './che_화계.js';
|
type StrategyResolveContext,
|
||||||
|
type StrategyResult,
|
||||||
|
} from './strategyCommand.js';
|
||||||
|
|
||||||
export interface SeizeResolveContext<
|
const CONFIG = {
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
key: 'che_탈취',
|
||||||
> extends FireAttackResolveContext<TriggerState> {
|
name: '탈취',
|
||||||
env?: TurnCommandEnv;
|
statKey: 'strength',
|
||||||
year?: number;
|
statExpKey: 'strength_exp',
|
||||||
startYear?: number;
|
damageMode: 'seize',
|
||||||
}
|
injuryGeneral: false,
|
||||||
|
} as const satisfies StrategyActionConfig;
|
||||||
|
|
||||||
const ACTION_NAME = '탈취';
|
export type SeizeArgs = StrategyArgs;
|
||||||
const ACTION_KEY = 'che_탈취';
|
export type SeizeResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
const ARGS_SCHEMA = z.object({
|
StrategyResolveContext<TriggerState>;
|
||||||
destCityId: z.number(),
|
|
||||||
});
|
|
||||||
export type SeizeArgs = z.infer<typeof ARGS_SCHEMA>;
|
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
|
> extends StrategyActionResolver<TriggerState> {
|
||||||
readonly key = ACTION_KEY;
|
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
|
||||||
private readonly command: StrategyCommandResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
const modules = env.generalActionModules ?? [];
|
super(env, CONFIG);
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
|
||||||
this.command = new StrategyCommandResolver<TriggerState>(modules, {
|
|
||||||
...env,
|
|
||||||
statKey: 'strength',
|
|
||||||
damageMode: 'seize',
|
|
||||||
injuryGeneral: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
protected resolveSuccess(
|
||||||
const ctx = context as SeizeResolveContext<TriggerState>;
|
context: StrategyResolveContext<TriggerState>,
|
||||||
const general = ctx.general;
|
args: StrategyArgs,
|
||||||
const nation = ctx.nation; // Own nation
|
result: StrategyResult<TriggerState>,
|
||||||
const destCity = ctx.destCity;
|
effects: GeneralActionEffect<TriggerState>[]
|
||||||
const destNation = ctx.destNation;
|
): void {
|
||||||
|
const { general, nation, destCity, destNation } = context;
|
||||||
|
const currentYear = context.year ?? 200;
|
||||||
|
const startYear = context.startYear ?? currentYear;
|
||||||
|
const yearCoefficient = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2;
|
||||||
|
const commerceRatio = destCity.commerce / destCity.commerceMax;
|
||||||
|
const agricultureRatio = destCity.agriculture / destCity.agricultureMax;
|
||||||
|
|
||||||
if (!destCity) throw new Error('Target city missing');
|
let stolenGold = result.primaryAmount * destCity.level * yearCoefficient * (0.25 + commerceRatio / 4);
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
let stolenRice = result.secondaryAmount * destCity.level * yearCoefficient * (0.25 + agricultureRatio / 4);
|
||||||
const city = ctx.city;
|
|
||||||
if (!city) throw new Error('Source city missing');
|
|
||||||
const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng);
|
|
||||||
general.gold = Math.max(0, general.gold - result.costGold);
|
|
||||||
general.rice = Math.max(0, general.rice - result.costRice);
|
|
||||||
general.experience += result.exp;
|
|
||||||
general.dedication += result.dedication;
|
|
||||||
general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1;
|
|
||||||
if (!result.success) {
|
|
||||||
ctx.addLog(
|
|
||||||
`<G><b>${destCity.name}</b></>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.`
|
|
||||||
);
|
|
||||||
return { effects };
|
|
||||||
}
|
|
||||||
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
|
||||||
|
|
||||||
const currentYear = ctx.year ?? 200;
|
|
||||||
const startYear = ctx.startYear ?? currentYear;
|
|
||||||
const yearCoef = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2;
|
|
||||||
|
|
||||||
const commRatio = destCity.commerce / destCity.commerceMax;
|
|
||||||
const agriRatio = destCity.agriculture / destCity.agricultureMax;
|
|
||||||
|
|
||||||
const rawGold = result.agriDamage * destCity.level * yearCoef * (0.25 + commRatio / 4);
|
|
||||||
const rawRice = result.commDamage * destCity.level * yearCoef * (0.25 + agriRatio / 4);
|
|
||||||
|
|
||||||
// 레거시는 탈취량을 부동소수점으로 유지한 채 국가/장수 DB 정수 필드에
|
|
||||||
// 기록할 때 반올림한다. 여기서 미리 내림하면 국고와 본국 몫이 1씩
|
|
||||||
// 달라질 수 있다.
|
|
||||||
let stolenGold = rawGold;
|
|
||||||
let stolenRice = rawRice;
|
|
||||||
|
|
||||||
const isSupplied = destCity.supplyState === 1;
|
|
||||||
|
|
||||||
if (isSupplied && destNation) {
|
|
||||||
const minGold = 0;
|
|
||||||
const minRice = 0;
|
|
||||||
|
|
||||||
const availableGold = Math.max(0, destNation.gold - minGold);
|
|
||||||
const availableRice = Math.max(0, destNation.rice - minRice);
|
|
||||||
|
|
||||||
stolenGold = Math.min(stolenGold, availableGold);
|
|
||||||
stolenRice = Math.min(stolenRice, availableRice);
|
|
||||||
|
|
||||||
|
if (destCity.supplyState === 1 && destNation) {
|
||||||
|
stolenGold = Math.min(stolenGold, Math.max(0, destNation.gold));
|
||||||
|
stolenRice = Math.min(stolenRice, Math.max(0, destNation.rice));
|
||||||
effects.push(
|
effects.push(
|
||||||
createNationPatchEffect(
|
createNationPatchEffect(
|
||||||
{
|
{
|
||||||
@@ -128,40 +68,18 @@ export class ActionResolver<
|
|||||||
destNation.id
|
destNation.id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
effects.push(
|
|
||||||
createCityPatchEffect(
|
|
||||||
{
|
|
||||||
// 레거시는 같은 명령 안에서 잠시 34로 쓴 뒤 최종 32로
|
|
||||||
// 덮어쓴다. 관찰 가능한 최종 상태는 32다.
|
|
||||||
state: 32,
|
|
||||||
},
|
|
||||||
args.destCityId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 레거시는 미보급 도시 자원을 먼저 감소시키지만 같은 명령 끝의
|
|
||||||
// 원본 destCity 전체 저장이 이를 덮어쓴다. 관찰 가능한 최종
|
|
||||||
// 상태는 자원 변화 없이 state 32만 남는다.
|
|
||||||
effects.push(
|
|
||||||
createCityPatchEffect(
|
|
||||||
{
|
|
||||||
state: 32,
|
|
||||||
},
|
|
||||||
args.destCityId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let myShareGold = stolenGold;
|
// Ref는 미보급 도시의 일시 자원 감소를 원본 destCity 저장으로 덮어쓴다.
|
||||||
let myShareRice = stolenRice;
|
effects.push(createCityPatchEffect({ state: 32 }, args.destCityId));
|
||||||
|
|
||||||
|
let generalShareGold = stolenGold;
|
||||||
|
let generalShareRice = stolenRice;
|
||||||
if (nation && nation.id !== 0) {
|
if (nation && nation.id !== 0) {
|
||||||
const nationShareGold = Math.round(stolenGold * 0.7);
|
const nationShareGold = Math.round(stolenGold * 0.7);
|
||||||
const nationShareRice = Math.round(stolenRice * 0.7);
|
const nationShareRice = Math.round(stolenRice * 0.7);
|
||||||
myShareGold -= nationShareGold;
|
generalShareGold -= nationShareGold;
|
||||||
myShareRice -= nationShareRice;
|
generalShareRice -= nationShareRice;
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createNationPatchEffect(
|
createNationPatchEffect(
|
||||||
{
|
{
|
||||||
@@ -172,85 +90,43 @@ export class ActionResolver<
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
general.gold = Math.round(general.gold + generalShareGold);
|
||||||
|
general.rice = Math.round(general.rice + generalShareRice);
|
||||||
|
|
||||||
const commandName = ACTION_NAME;
|
|
||||||
const destCityName = destCity.name;
|
const destCityName = destCity.name;
|
||||||
ctx.addLog(`<G><b>${destCityName}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, {
|
context.addLog(`<G><b>${destCityName}</b></>에서 금과 쌀을 도둑맞았습니다.`, {
|
||||||
category: LogCategory.ACTION,
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
ctx.addLog(`금<C>${stolenGold}</> 쌀<C>${stolenRice}</>을 획득했습니다.`, {
|
context.addLog(
|
||||||
category: LogCategory.ACTION,
|
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
|
||||||
format: LogFormat.PLAIN,
|
{ format: LogFormat.MONTH }
|
||||||
});
|
);
|
||||||
|
context.addLog(
|
||||||
consumeSuccessfulStrategyItem(this.pipeline, context);
|
`금<C>${Math.round(stolenGold).toLocaleString('en-US')}</> 쌀<C>${Math.round(stolenRice).toLocaleString(
|
||||||
general.gold = Math.round(general.gold + myShareGold);
|
'en-US'
|
||||||
general.rice = Math.round(general.rice + myShareRice);
|
)}</>을 획득했습니다.`,
|
||||||
|
{ format: LogFormat.PLAIN }
|
||||||
return { effects };
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, SeizeArgs, GeneralActionResolveContext<TriggerState>> {
|
> extends StrategyActionDefinition<TriggerState> {
|
||||||
public readonly key = ACTION_KEY;
|
|
||||||
public readonly name = ACTION_NAME;
|
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver<TriggerState>(env);
|
super(env, CONFIG, new ActionResolver<TriggerState>(env));
|
||||||
}
|
|
||||||
|
|
||||||
parseArgs(raw: unknown): SeizeArgs | null {
|
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildMinConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = ((env.develCost as number) ?? 100) * 5;
|
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
|
||||||
}
|
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = ((env.develCost as number) ?? 100) * 5;
|
|
||||||
return [
|
|
||||||
notBeNeutral(),
|
|
||||||
occupiedCity(),
|
|
||||||
suppliedCity(),
|
|
||||||
notOccupiedDestCity(),
|
|
||||||
notNeutralDestCity(),
|
|
||||||
reqGeneralGold(() => cost),
|
|
||||||
reqGeneralRice(() => cost),
|
|
||||||
disallowDiplomacyBetweenStatus({
|
|
||||||
7: '불가침국입니다.',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
|
||||||
return this.resolver.resolve(context, args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = buildStrategyActionContext;
|
||||||
const strategyContext = buildStrategyActionContext(base, options);
|
|
||||||
if (!strategyContext) return null;
|
|
||||||
return {
|
|
||||||
...strategyContext,
|
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
|
||||||
year: options.world.currentYear,
|
|
||||||
startYear: options.scenarioMeta?.startYear ?? options.world.currentYear,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_탈취',
|
key: CONFIG.key,
|
||||||
category: '군사',
|
category: '계략',
|
||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { destCityId: 0 },
|
availabilityArgs: { destCityId: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: STRATEGY_ARGS_SCHEMA,
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,189 +1,90 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
|
||||||
import {
|
|
||||||
notBeNeutral,
|
|
||||||
occupiedCity,
|
|
||||||
suppliedCity,
|
|
||||||
notOccupiedDestCity,
|
|
||||||
notNeutralDestCity,
|
|
||||||
reqGeneralGold,
|
|
||||||
reqGeneralRice,
|
|
||||||
disallowDiplomacyBetweenStatus,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
|
||||||
import type {
|
|
||||||
GeneralActionOutcome,
|
|
||||||
GeneralActionResolveContext,
|
|
||||||
GeneralActionResolver,
|
|
||||||
GeneralActionEffect,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { createGeneralPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
|
||||||
import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { createCityPatchEffect, type GeneralActionEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import { LogFormat, LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
|
||||||
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import {
|
import {
|
||||||
|
STRATEGY_ARGS_SCHEMA,
|
||||||
|
StrategyActionDefinition,
|
||||||
|
StrategyActionResolver,
|
||||||
buildStrategyActionContext,
|
buildStrategyActionContext,
|
||||||
CommandResolver as StrategyCommandResolver,
|
type StrategyActionConfig,
|
||||||
type FireAttackResolveContext,
|
type StrategyArgs,
|
||||||
} from './che_화계.js';
|
type StrategyResolveContext,
|
||||||
|
type StrategyResult,
|
||||||
|
} from './strategyCommand.js';
|
||||||
|
|
||||||
export interface DestroyResolveContext<
|
const CONFIG = {
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
key: 'che_파괴',
|
||||||
> extends FireAttackResolveContext<TriggerState> {
|
name: '파괴',
|
||||||
env?: TurnCommandEnv;
|
statKey: 'strength',
|
||||||
}
|
statExpKey: 'strength_exp',
|
||||||
|
damageMode: 'destroy',
|
||||||
|
injuryGeneral: true,
|
||||||
|
} as const satisfies StrategyActionConfig;
|
||||||
|
|
||||||
const ACTION_NAME = '파괴';
|
export type DestroyArgs = StrategyArgs;
|
||||||
const ACTION_KEY = 'che_파괴';
|
export type DestroyResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
const ARGS_SCHEMA = z.object({
|
StrategyResolveContext<TriggerState>;
|
||||||
destCityId: z.number(),
|
|
||||||
});
|
|
||||||
export type DestroyArgs = z.infer<typeof ARGS_SCHEMA>;
|
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
|
> extends StrategyActionResolver<TriggerState> {
|
||||||
readonly key = ACTION_KEY;
|
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
|
||||||
private readonly command: StrategyCommandResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
const modules = env.generalActionModules ?? [];
|
super(env, CONFIG);
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
|
||||||
this.command = new StrategyCommandResolver<TriggerState>(modules, {
|
|
||||||
...env,
|
|
||||||
statKey: 'strength',
|
|
||||||
damageMode: 'destroy',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
protected resolveSuccess(
|
||||||
const ctx = context as DestroyResolveContext<TriggerState>;
|
context: StrategyResolveContext<TriggerState>,
|
||||||
const general = ctx.general;
|
_args: StrategyArgs,
|
||||||
const destCity = ctx.destCity;
|
result: StrategyResult<TriggerState>,
|
||||||
if (!destCity) throw new Error('Target city missing');
|
effects: GeneralActionEffect<TriggerState>[]
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
): void {
|
||||||
const city = ctx.city;
|
|
||||||
if (!city) throw new Error('Source city missing');
|
|
||||||
const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng);
|
|
||||||
general.gold = Math.max(0, general.gold - result.costGold);
|
|
||||||
general.rice = Math.max(0, general.rice - result.costRice);
|
|
||||||
general.experience += result.exp;
|
|
||||||
general.dedication += result.dedication;
|
|
||||||
general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1;
|
|
||||||
if (!result.success) {
|
|
||||||
ctx.addLog(
|
|
||||||
`<G><b>${destCity.name}</b></>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.`
|
|
||||||
);
|
|
||||||
return { effects };
|
|
||||||
}
|
|
||||||
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
|
||||||
const newDef = Math.max(0, destCity.defence - result.agriDamage);
|
|
||||||
const newWall = Math.max(0, destCity.wall - result.commDamage);
|
|
||||||
|
|
||||||
// Log
|
|
||||||
const commandName = ACTION_NAME;
|
|
||||||
const destCityName = destCity.name;
|
|
||||||
ctx.addLog(`<G><b>${destCityName}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, {
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
});
|
|
||||||
ctx.addLog(
|
|
||||||
`도시의 수비가 <C>${result.agriDamage}</>, 성벽이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
|
||||||
{
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// City Update
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createCityPatchEffect(
|
createCityPatchEffect(
|
||||||
{
|
{
|
||||||
defence: newDef,
|
defence: Math.max(0, context.destCity.defence - result.primaryAmount),
|
||||||
wall: newWall,
|
wall: Math.max(0, context.destCity.wall - result.secondaryAmount),
|
||||||
state: 32, // Legacy sabotage state
|
state: 32,
|
||||||
},
|
},
|
||||||
args.destCityId
|
context.destCity.id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
consumeSuccessfulStrategyItem(this.pipeline, context);
|
const destCityName = context.destCity.name;
|
||||||
for (const injured of result.injuredGenerals) {
|
context.addLog(`누군가가 <G><b>${destCityName}</b></>의 성벽을 허물었습니다.`, {
|
||||||
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
|
scope: LogScope.SYSTEM,
|
||||||
ctx.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
|
category: LogCategory.SUMMARY,
|
||||||
generalId: injured.id,
|
format: LogFormat.MONTH,
|
||||||
format: LogFormat.MONTH,
|
});
|
||||||
});
|
context.addLog(
|
||||||
}
|
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
|
||||||
|
{ format: LogFormat.MONTH }
|
||||||
return { effects };
|
);
|
||||||
|
context.addLog(
|
||||||
|
`도시의 수비가 <C>${result.primaryAmount}</>, 성벽이 <C>${result.secondaryAmount}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
||||||
|
{ format: LogFormat.PLAIN }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, DestroyArgs, GeneralActionResolveContext<TriggerState>> {
|
> extends StrategyActionDefinition<TriggerState> {
|
||||||
public readonly key = ACTION_KEY;
|
|
||||||
public readonly name = ACTION_NAME;
|
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver<TriggerState>(env);
|
super(env, CONFIG, new ActionResolver<TriggerState>(env));
|
||||||
}
|
|
||||||
|
|
||||||
parseArgs(raw: unknown): DestroyArgs | null {
|
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildMinConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = ((env.develCost as number) ?? 100) * 5;
|
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
|
||||||
}
|
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = ((env.develCost as number) ?? 100) * 5;
|
|
||||||
return [
|
|
||||||
notBeNeutral(),
|
|
||||||
occupiedCity(),
|
|
||||||
suppliedCity(),
|
|
||||||
notOccupiedDestCity(),
|
|
||||||
notNeutralDestCity(),
|
|
||||||
reqGeneralGold(() => cost),
|
|
||||||
reqGeneralRice(() => cost),
|
|
||||||
disallowDiplomacyBetweenStatus({
|
|
||||||
7: '불가침국입니다.',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
|
||||||
return this.resolver.resolve(context, args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = buildStrategyActionContext;
|
||||||
const strategyContext = buildStrategyActionContext(base, options);
|
|
||||||
if (!strategyContext) return null;
|
|
||||||
return {
|
|
||||||
...strategyContext,
|
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_파괴',
|
key: CONFIG.key,
|
||||||
category: '군사',
|
category: '계략',
|
||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { destCityId: 0 },
|
availabilityArgs: { destCityId: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: STRATEGY_ARGS_SCHEMA,
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,361 +1,54 @@
|
|||||||
import type { RandomGenerator } from '@sammo-ts/common';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
|
||||||
import {
|
|
||||||
disallowDiplomacyBetweenStatus,
|
|
||||||
notBeNeutral,
|
|
||||||
notNeutralDestCity,
|
|
||||||
notOccupiedDestCity,
|
|
||||||
occupiedCity,
|
|
||||||
reqGeneralGold,
|
|
||||||
reqGeneralRice,
|
|
||||||
suppliedCity,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
|
||||||
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
|
||||||
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
|
||||||
import type {
|
|
||||||
GeneralActionEffect,
|
|
||||||
GeneralActionOutcome,
|
|
||||||
GeneralActionResolveContext,
|
|
||||||
GeneralActionResolver,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { createCityPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { createCityPatchEffect, type GeneralActionEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import type {
|
|
||||||
ActionContextBase,
|
|
||||||
ActionContextBuilder,
|
|
||||||
ActionContextOptions,
|
|
||||||
} from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { clamp } from 'es-toolkit';
|
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
|
||||||
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
|
|
||||||
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
|
|
||||||
|
|
||||||
export interface FireAttackEnvironment {
|
import {
|
||||||
develCost: number;
|
STRATEGY_ARGS_SCHEMA,
|
||||||
sabotageDefaultProb: number;
|
StrategyActionDefinition,
|
||||||
sabotageProbCoefByStat: number;
|
StrategyActionResolver,
|
||||||
sabotageDefenceCoefByGeneralCount: number;
|
buildStrategyActionContext,
|
||||||
sabotageDamageMin: number;
|
type StrategyActionConfig,
|
||||||
sabotageDamageMax: number;
|
type StrategyArgs,
|
||||||
maxSuccessProbability?: number;
|
type StrategyResolveContext,
|
||||||
statKey?: 'leadership' | 'strength' | 'intelligence';
|
type StrategyResult,
|
||||||
getDistance?: (sourceCityId: number, destCityId: number) => number | null;
|
} from './strategyCommand.js';
|
||||||
getDefenceCorrection?: (context: FireAttackContext, defender: General) => number;
|
|
||||||
getInjuryProbability?: (context: FireAttackContext, defender: General) => number;
|
|
||||||
damageMode?: 'fire' | 'agitate' | 'destroy' | 'seize';
|
|
||||||
injuryGeneral?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FireAttackContext<
|
const CONFIG = {
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
key: 'che_화계',
|
||||||
> extends GeneralActionContext<TriggerState> {
|
name: '화계',
|
||||||
general: General<TriggerState>;
|
statKey: 'intelligence',
|
||||||
city: City;
|
statExpKey: 'intel_exp',
|
||||||
nation?: Nation | null;
|
damageMode: 'fire',
|
||||||
destCity: City;
|
injuryGeneral: true,
|
||||||
destNation?: Nation | null;
|
} as const satisfies StrategyActionConfig;
|
||||||
destGenerals: General<TriggerState>[];
|
|
||||||
distance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FireAttackResolveContext<
|
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
|
||||||
> extends GeneralActionResolveContext<TriggerState> {
|
|
||||||
destCity: City;
|
|
||||||
destNation?: Nation | null;
|
|
||||||
destGenerals: General<TriggerState>[];
|
|
||||||
distance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FireAttackResult<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
|
||||||
success: boolean;
|
|
||||||
probability: number;
|
|
||||||
distance: number;
|
|
||||||
costGold: number;
|
|
||||||
costRice: number;
|
|
||||||
exp: number;
|
|
||||||
dedication: number;
|
|
||||||
agriDamage: number;
|
|
||||||
commDamage: number;
|
|
||||||
injuryCount: number;
|
|
||||||
injuredGenerals: Array<{
|
|
||||||
id: number;
|
|
||||||
patch: Partial<General<TriggerState>>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_NAME = '화계';
|
|
||||||
const ACTION_KEY = '계략';
|
|
||||||
const ARGS_SCHEMA = z.object({
|
|
||||||
destCityId: z.number(),
|
|
||||||
});
|
|
||||||
export type FireAttackArgs = z.infer<typeof ARGS_SCHEMA>;
|
|
||||||
const STAT_EXP_KEY = 'intel_exp';
|
|
||||||
const DEFAULT_MAX_PROB = 0.5;
|
|
||||||
const INJURY_MAX = 80;
|
|
||||||
const CITY_STATE_BURNING = 32;
|
const CITY_STATE_BURNING = 32;
|
||||||
|
|
||||||
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1);
|
export type FireAttackArgs = StrategyArgs;
|
||||||
|
export type FireAttackResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
const getStatValue = (general: General, statKey: 'leadership' | 'strength' | 'intelligence'): number => {
|
StrategyResolveContext<TriggerState>;
|
||||||
if (statKey === 'leadership') {
|
|
||||||
return general.stats.leadership;
|
|
||||||
}
|
|
||||||
if (statKey === 'strength') {
|
|
||||||
return general.stats.strength;
|
|
||||||
}
|
|
||||||
return general.stats.intelligence;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
|
|
||||||
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
|
||||||
return { ...meta, [key]: current + delta };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 화계 성공/실패 및 피해량 계산을 담당한다.
|
|
||||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
|
||||||
private readonly env: FireAttackEnvironment;
|
|
||||||
private readonly statKey: 'leadership' | 'strength' | 'intelligence';
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
modules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>,
|
|
||||||
env: FireAttackEnvironment
|
|
||||||
) {
|
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
|
||||||
this.env = env;
|
|
||||||
this.statKey = env.statKey ?? 'intelligence';
|
|
||||||
}
|
|
||||||
|
|
||||||
getCost(): { gold: number; rice: number } {
|
|
||||||
const cost = this.env.develCost * 5;
|
|
||||||
return { gold: cost, rice: cost };
|
|
||||||
}
|
|
||||||
|
|
||||||
private calcAttackProb(context: FireAttackContext<TriggerState>): number {
|
|
||||||
const stat = getStatValue(context.general, this.statKey);
|
|
||||||
const prob = stat / this.env.sabotageProbCoefByStat;
|
|
||||||
return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'success', prob);
|
|
||||||
}
|
|
||||||
|
|
||||||
private calcDefenceProb(context: FireAttackContext<TriggerState>): number {
|
|
||||||
const destNationId = context.destCity.nationId;
|
|
||||||
let maxStat = 0;
|
|
||||||
let probCorrection = 0;
|
|
||||||
let affectCount = 0;
|
|
||||||
|
|
||||||
for (const defender of context.destGenerals ?? []) {
|
|
||||||
if (defender.nationId !== destNationId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
affectCount += 1;
|
|
||||||
maxStat = Math.max(maxStat, getStatValue(defender, this.statKey));
|
|
||||||
probCorrection += this.env.getDefenceCorrection?.(context, defender) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let prob = maxStat / this.env.sabotageProbCoefByStat;
|
|
||||||
prob += probCorrection;
|
|
||||||
prob += (Math.log2(affectCount + 1) - 1.25) * this.env.sabotageDefenceCoefByGeneralCount;
|
|
||||||
|
|
||||||
prob += context.destCity.security / context.destCity.securityMax / 5;
|
|
||||||
prob += context.destCity.supplyState ? 0.1 : 0;
|
|
||||||
|
|
||||||
return prob;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(context: FireAttackContext<TriggerState>, rng: RandomGenerator): FireAttackResult<TriggerState> {
|
|
||||||
const { gold: costGold, rice: costRice } = this.getCost();
|
|
||||||
const distance = context.distance ?? this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99;
|
|
||||||
|
|
||||||
const attackProb = this.calcAttackProb(context);
|
|
||||||
const defenceProb = this.calcDefenceProb(context);
|
|
||||||
let probability = this.env.sabotageDefaultProb + attackProb - defenceProb;
|
|
||||||
probability /= distance;
|
|
||||||
probability = clamp(probability, 0, this.env.maxSuccessProbability ?? DEFAULT_MAX_PROB);
|
|
||||||
|
|
||||||
const success = rng.nextBool(probability);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return {
|
|
||||||
success,
|
|
||||||
probability,
|
|
||||||
distance,
|
|
||||||
costGold,
|
|
||||||
costRice,
|
|
||||||
exp: randomRangeInt(rng, 1, 100),
|
|
||||||
dedication: randomRangeInt(rng, 1, 70),
|
|
||||||
agriDamage: 0,
|
|
||||||
commDamage: 0,
|
|
||||||
injuryCount: 0,
|
|
||||||
injuredGenerals: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const injuryProbDefault = 0.3;
|
|
||||||
const injuredGenerals: Array<{
|
|
||||||
id: number;
|
|
||||||
patch: Partial<General<TriggerState>>;
|
|
||||||
}> = [];
|
|
||||||
for (const defender of this.env.injuryGeneral === false ? [] : (context.destGenerals ?? [])) {
|
|
||||||
if (defender.nationId !== context.destCity.nationId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const injuryProb = this.env.getInjuryProbability?.(context, defender) ?? injuryProbDefault;
|
|
||||||
if (!rng.nextBool(injuryProb)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const injuryAmount = randomRangeInt(rng, 1, 16);
|
|
||||||
injuredGenerals.push({
|
|
||||||
id: defender.id,
|
|
||||||
patch: {
|
|
||||||
injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX),
|
|
||||||
crew: Math.round(defender.crew * 0.98),
|
|
||||||
atmos: Math.round(defender.atmos * 0.98),
|
|
||||||
train: Math.round(defender.train * 0.98),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const damageMode = this.env.damageMode ?? 'fire';
|
|
||||||
let agriDamage: number;
|
|
||||||
let commDamage: number;
|
|
||||||
if (damageMode === 'agitate') {
|
|
||||||
agriDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.security
|
|
||||||
);
|
|
||||||
const trust = typeof context.destCity.meta.trust === 'number' ? context.destCity.meta.trust : 0;
|
|
||||||
commDamage = clamp(
|
|
||||||
(this.env.sabotageDamageMin +
|
|
||||||
rng.nextFloat1() * (this.env.sabotageDamageMax - this.env.sabotageDamageMin)) /
|
|
||||||
50,
|
|
||||||
0,
|
|
||||||
trust
|
|
||||||
);
|
|
||||||
} else if (damageMode === 'destroy') {
|
|
||||||
agriDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.defence
|
|
||||||
);
|
|
||||||
commDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.wall
|
|
||||||
);
|
|
||||||
} else if (damageMode === 'seize') {
|
|
||||||
agriDamage = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax);
|
|
||||||
commDamage = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax);
|
|
||||||
} else {
|
|
||||||
agriDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.agriculture
|
|
||||||
);
|
|
||||||
commDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.commerce
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success,
|
|
||||||
probability,
|
|
||||||
distance,
|
|
||||||
costGold,
|
|
||||||
costRice,
|
|
||||||
exp: randomRangeInt(rng, 201, 300),
|
|
||||||
dedication: randomRangeInt(rng, 141, 210),
|
|
||||||
agriDamage,
|
|
||||||
commDamage,
|
|
||||||
injuryCount: injuredGenerals.length,
|
|
||||||
injuredGenerals,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, FireAttackArgs> {
|
> extends StrategyActionResolver<TriggerState> {
|
||||||
readonly key = 'che_화계';
|
constructor(env: TurnCommandEnv) {
|
||||||
private readonly command: CommandResolver<TriggerState>;
|
super(env, CONFIG);
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
modules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>,
|
|
||||||
env: FireAttackEnvironment
|
|
||||||
) {
|
|
||||||
this.command = new CommandResolver(modules, env);
|
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
protected resolveSuccess(
|
||||||
context: FireAttackResolveContext<TriggerState>,
|
context: StrategyResolveContext<TriggerState>,
|
||||||
_args: FireAttackArgs
|
_args: StrategyArgs,
|
||||||
): GeneralActionOutcome<TriggerState> {
|
result: StrategyResult<TriggerState>,
|
||||||
void _args;
|
effects: GeneralActionEffect<TriggerState>[]
|
||||||
const general = context.general;
|
): void {
|
||||||
const city = context.city;
|
|
||||||
if (!city) {
|
|
||||||
throw new Error('Fire attack requires a city context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = this.command.resolve(
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
city,
|
|
||||||
nation: context.nation ?? null,
|
|
||||||
destCity: context.destCity,
|
|
||||||
destNation: context.destNation ?? null,
|
|
||||||
destGenerals: context.destGenerals,
|
|
||||||
},
|
|
||||||
context.rng
|
|
||||||
);
|
|
||||||
|
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
|
||||||
|
|
||||||
const nextGold = Math.max(0, general.gold - result.costGold);
|
|
||||||
const nextRice = Math.max(0, general.rice - result.costRice);
|
|
||||||
const nextExperience = general.experience + result.exp;
|
|
||||||
const nextDedication = general.dedication + result.dedication;
|
|
||||||
|
|
||||||
const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1);
|
|
||||||
const metaUpdated = result.success ? addMetaNumber(metaWithStatExp, 'firenum', 1) : metaWithStatExp;
|
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
|
||||||
general.gold = nextGold;
|
|
||||||
general.rice = nextRice;
|
|
||||||
general.experience = nextExperience;
|
|
||||||
general.dedication = nextDedication;
|
|
||||||
general.meta = metaUpdated;
|
|
||||||
|
|
||||||
const commandName = ACTION_NAME;
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
context.addLog(
|
|
||||||
`<G><b>${context.destCity.name}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 실패했습니다.`,
|
|
||||||
{
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return { effects: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 타겟 도시는 Draft가 아니므로 Effect 반환
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createCityPatchEffect(
|
createCityPatchEffect(
|
||||||
{
|
{
|
||||||
agriculture: context.destCity.agriculture - result.agriDamage,
|
agriculture: context.destCity.agriculture - result.primaryAmount,
|
||||||
commerce: context.destCity.commerce - result.commDamage,
|
commerce: context.destCity.commerce - result.secondaryAmount,
|
||||||
state: CITY_STATE_BURNING,
|
state: CITY_STATE_BURNING,
|
||||||
},
|
},
|
||||||
context.destCity.id
|
context.destCity.id
|
||||||
@@ -369,126 +62,31 @@ export class ActionResolver<
|
|||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`<G><b>${context.destCity.name}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`,
|
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
|
||||||
{
|
{ format: LogFormat.MONTH }
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`도시의 농업이 <C>${result.agriDamage}</>, 상업이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
`도시의 농업이 <C>${result.primaryAmount}</>, 상업이 <C>${result.secondaryAmount}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
||||||
{
|
{ format: LogFormat.PLAIN }
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemCode = general.role.items.item;
|
|
||||||
const consumedItems = consumeSuccessfulStrategyItem(this.pipeline, context);
|
|
||||||
if (typeof itemCode === 'string' && consumedItems.includes(itemCode)) {
|
|
||||||
context.addLog(`<C>${itemCode}</>${JosaUtil.pick(itemCode, '을')} 사용!`, {
|
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const injured of result.injuredGenerals) {
|
|
||||||
// 타겟 장수는 Draft가 아니므로 Effect 반환
|
|
||||||
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
|
|
||||||
context.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
|
|
||||||
generalId: injured.id,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { effects };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, FireAttackArgs, FireAttackResolveContext<TriggerState>> {
|
> extends StrategyActionDefinition<TriggerState> {
|
||||||
public readonly key = 'che_화계';
|
constructor(env: TurnCommandEnv) {
|
||||||
public readonly name = ACTION_NAME;
|
super(env, CONFIG, new ActionResolver<TriggerState>(env));
|
||||||
private readonly command: CommandResolver<TriggerState>;
|
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
modules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>,
|
|
||||||
env: FireAttackEnvironment
|
|
||||||
) {
|
|
||||||
this.command = new CommandResolver(modules, env);
|
|
||||||
this.resolver = new ActionResolver(modules, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
parseArgs(raw: unknown): FireAttackArgs | null {
|
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] {
|
|
||||||
const { gold, rice } = this.command.getCost();
|
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => gold), reqGeneralRice(() => rice)];
|
|
||||||
}
|
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] {
|
|
||||||
void _ctx;
|
|
||||||
void _args;
|
|
||||||
const { gold, rice } = this.command.getCost();
|
|
||||||
return [
|
|
||||||
notBeNeutral(),
|
|
||||||
occupiedCity(),
|
|
||||||
suppliedCity(),
|
|
||||||
notOccupiedDestCity(),
|
|
||||||
notNeutralDestCity(),
|
|
||||||
reqGeneralGold(() => gold),
|
|
||||||
reqGeneralRice(() => rice),
|
|
||||||
disallowDiplomacyBetweenStatus({
|
|
||||||
7: '불가침국입니다.',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
formatConstraintFailure(
|
|
||||||
reason: string,
|
|
||||||
_ctx: ConstraintContext,
|
|
||||||
args: FireAttackArgs,
|
|
||||||
view: StateView
|
|
||||||
): string | null {
|
|
||||||
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(context: FireAttackResolveContext<TriggerState>, args: FireAttackArgs): GeneralActionOutcome<TriggerState> {
|
|
||||||
return this.resolver.resolve(context, args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildStrategyActionContext = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = buildStrategyActionContext;
|
||||||
const destCityId = options.actionArgs.destCityId;
|
|
||||||
if (typeof destCityId !== 'number' || !options.worldRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const destCity = options.worldRef.getCityById(destCityId);
|
|
||||||
if (!destCity) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const destNation = destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null;
|
|
||||||
const destGenerals = options.worldRef
|
|
||||||
.listGenerals()
|
|
||||||
.filter((general) => general.cityId === destCity.id && general.nationId === destCity.nationId);
|
|
||||||
const distance = options.map ? (searchDistance(options.map, base.general.cityId, 5)[destCity.id] ?? 99) : 99;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
destCity,
|
|
||||||
destNation,
|
|
||||||
destGenerals,
|
|
||||||
distance,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = buildStrategyActionContext;
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_화계',
|
key: CONFIG.key,
|
||||||
category: '계략',
|
category: '계략',
|
||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { destCityId: 0 },
|
availabilityArgs: { destCityId: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: STRATEGY_ARGS_SCHEMA,
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import { JosaUtil, type RandomGenerator } from '@sammo-ts/common';
|
||||||
|
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
|
import type {
|
||||||
|
GeneralActionEffect,
|
||||||
|
GeneralActionOutcome,
|
||||||
|
GeneralActionResolveContext,
|
||||||
|
GeneralActionResolver,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import type {
|
||||||
|
ActionContextBase,
|
||||||
|
ActionContextBuilder,
|
||||||
|
ActionContextOptions,
|
||||||
|
} from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
|
import {
|
||||||
|
disallowDiplomacyBetweenStatus,
|
||||||
|
notBeNeutral,
|
||||||
|
notNeutralDestCity,
|
||||||
|
notOccupiedDestCity,
|
||||||
|
occupiedCity,
|
||||||
|
reqGeneralGold,
|
||||||
|
reqGeneralRice,
|
||||||
|
suppliedCity,
|
||||||
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
|
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
||||||
|
import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
|
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
||||||
|
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||||
|
import { clamp } from 'es-toolkit';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
|
||||||
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
|
||||||
|
export const STRATEGY_ARGS_SCHEMA = z.object({
|
||||||
|
destCityId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StrategyArgs = z.infer<typeof STRATEGY_ARGS_SCHEMA>;
|
||||||
|
export type StrategyStatKey = 'leadership' | 'strength' | 'intelligence';
|
||||||
|
export type StrategyStatExpKey = 'leadership_exp' | 'strength_exp' | 'intel_exp';
|
||||||
|
export type StrategyDamageMode = 'fire' | 'agitate' | 'destroy' | 'seize';
|
||||||
|
|
||||||
|
export interface StrategyActionConfig {
|
||||||
|
key: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
|
||||||
|
name: '화계' | '선동' | '파괴' | '탈취';
|
||||||
|
statKey: StrategyStatKey;
|
||||||
|
statExpKey: StrategyStatExpKey;
|
||||||
|
damageMode: StrategyDamageMode;
|
||||||
|
injuryGeneral: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyEnvironment {
|
||||||
|
develCost: number;
|
||||||
|
sabotageDefaultProb: number;
|
||||||
|
sabotageProbCoefByStat: number;
|
||||||
|
sabotageDefenceCoefByGeneralCount: number;
|
||||||
|
sabotageDamageMin: number;
|
||||||
|
sabotageDamageMax: number;
|
||||||
|
maxSuccessProbability?: number;
|
||||||
|
getDistance?: (sourceCityId: number, destCityId: number) => number | null;
|
||||||
|
getDefenceCorrection?: (context: StrategyContext, defender: General) => number;
|
||||||
|
getInjuryProbability?: (context: StrategyContext, defender: General) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionContext<TriggerState> {
|
||||||
|
general: General<TriggerState>;
|
||||||
|
city: City;
|
||||||
|
nation?: Nation | null;
|
||||||
|
destCity: City;
|
||||||
|
destNation?: Nation | null;
|
||||||
|
destGenerals: General<TriggerState>[];
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyResolveContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destCity: City;
|
||||||
|
destNation?: Nation | null;
|
||||||
|
destGenerals: General<TriggerState>[];
|
||||||
|
distance?: number;
|
||||||
|
year?: number;
|
||||||
|
startYear?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyProbability {
|
||||||
|
attack: number;
|
||||||
|
defence: number;
|
||||||
|
distance: number;
|
||||||
|
success: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyResult<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
|
success: boolean;
|
||||||
|
probability: StrategyProbability;
|
||||||
|
costGold: number;
|
||||||
|
costRice: number;
|
||||||
|
exp: number;
|
||||||
|
dedication: number;
|
||||||
|
primaryAmount: number;
|
||||||
|
secondaryAmount: number;
|
||||||
|
injuryCount: number;
|
||||||
|
injuredGenerals: Array<{
|
||||||
|
id: number;
|
||||||
|
patch: Partial<General<TriggerState>>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_PROBABILITY = 0.5;
|
||||||
|
const INJURY_MAX = 80;
|
||||||
|
|
||||||
|
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1);
|
||||||
|
|
||||||
|
const getStatValue = (general: General, statKey: StrategyStatKey): number => general.stats[statKey];
|
||||||
|
|
||||||
|
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
|
||||||
|
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
||||||
|
return { ...meta, [key]: current + delta };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ref `che_화계`가 소유한 네 계략의 공통 확률, RNG, 비용과 성장 계산을 분리한 기반. */
|
||||||
|
export class StrategyCommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
modules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
|
private readonly env: StrategyEnvironment,
|
||||||
|
private readonly config: StrategyActionConfig
|
||||||
|
) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCost(): { gold: number; rice: number } {
|
||||||
|
const cost = this.env.develCost * 5;
|
||||||
|
return { gold: cost, rice: cost };
|
||||||
|
}
|
||||||
|
|
||||||
|
getProbability(context: StrategyContext<TriggerState>): StrategyProbability {
|
||||||
|
const attackBase = getStatValue(context.general, this.config.statKey) / this.env.sabotageProbCoefByStat;
|
||||||
|
const attack = this.pipeline.onCalcDomestic(context, '계략', 'success', attackBase);
|
||||||
|
|
||||||
|
const destNationId = context.destCity.nationId;
|
||||||
|
let maxStat = 0;
|
||||||
|
let defenceCorrection = 0;
|
||||||
|
let affectCount = 0;
|
||||||
|
for (const defender of context.destGenerals ?? []) {
|
||||||
|
if (defender.nationId !== destNationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
affectCount += 1;
|
||||||
|
maxStat = Math.max(maxStat, getStatValue(defender, this.config.statKey));
|
||||||
|
defenceCorrection += this.env.getDefenceCorrection?.(context, defender) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let defence = maxStat / this.env.sabotageProbCoefByStat;
|
||||||
|
defence += defenceCorrection;
|
||||||
|
defence += (Math.log2(affectCount + 1) - 1.25) * this.env.sabotageDefenceCoefByGeneralCount;
|
||||||
|
defence += context.destCity.security / context.destCity.securityMax / 5;
|
||||||
|
defence += context.destCity.supplyState ? 0.1 : 0;
|
||||||
|
|
||||||
|
const distance = context.distance ?? this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99;
|
||||||
|
const success = clamp(
|
||||||
|
(this.env.sabotageDefaultProb + attack - defence) / distance,
|
||||||
|
0,
|
||||||
|
this.env.maxSuccessProbability ?? DEFAULT_MAX_PROBABILITY
|
||||||
|
);
|
||||||
|
return { attack, defence, distance, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: StrategyContext<TriggerState>, rng: RandomGenerator): StrategyResult<TriggerState> {
|
||||||
|
const { gold: costGold, rice: costRice } = this.getCost();
|
||||||
|
const probability = this.getProbability(context);
|
||||||
|
const success = rng.nextBool(probability.success);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
probability,
|
||||||
|
costGold,
|
||||||
|
costRice,
|
||||||
|
exp: randomRangeInt(rng, 1, 100),
|
||||||
|
dedication: randomRangeInt(rng, 1, 70),
|
||||||
|
primaryAmount: 0,
|
||||||
|
secondaryAmount: 0,
|
||||||
|
injuryCount: 0,
|
||||||
|
injuredGenerals: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const injuredGenerals: Array<{
|
||||||
|
id: number;
|
||||||
|
patch: Partial<General<TriggerState>>;
|
||||||
|
}> = [];
|
||||||
|
for (const defender of this.config.injuryGeneral ? (context.destGenerals ?? []) : []) {
|
||||||
|
if (defender.nationId !== context.destCity.nationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const injuryProbability = this.env.getInjuryProbability?.(context, defender) ?? 0.3;
|
||||||
|
if (!rng.nextBool(injuryProbability)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
injuredGenerals.push({
|
||||||
|
id: defender.id,
|
||||||
|
patch: {
|
||||||
|
injury: clamp(defender.injury + randomRangeInt(rng, 1, 16), 0, INJURY_MAX),
|
||||||
|
crew: Math.round(defender.crew * 0.98),
|
||||||
|
atmos: Math.round(defender.atmos * 0.98),
|
||||||
|
train: Math.round(defender.train * 0.98),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let primaryAmount: number;
|
||||||
|
let secondaryAmount: number;
|
||||||
|
if (this.config.damageMode === 'agitate') {
|
||||||
|
primaryAmount = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.security
|
||||||
|
);
|
||||||
|
const trust = typeof context.destCity.meta.trust === 'number' ? context.destCity.meta.trust : 0;
|
||||||
|
secondaryAmount = clamp(
|
||||||
|
(this.env.sabotageDamageMin +
|
||||||
|
rng.nextFloat1() * (this.env.sabotageDamageMax - this.env.sabotageDamageMin)) /
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
trust
|
||||||
|
);
|
||||||
|
} else if (this.config.damageMode === 'destroy') {
|
||||||
|
primaryAmount = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.defence
|
||||||
|
);
|
||||||
|
secondaryAmount = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.wall
|
||||||
|
);
|
||||||
|
} else if (this.config.damageMode === 'seize') {
|
||||||
|
primaryAmount = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax);
|
||||||
|
secondaryAmount = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax);
|
||||||
|
} else {
|
||||||
|
primaryAmount = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.agriculture
|
||||||
|
);
|
||||||
|
secondaryAmount = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.commerce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
probability,
|
||||||
|
costGold,
|
||||||
|
costRice,
|
||||||
|
exp: randomRangeInt(rng, 201, 300),
|
||||||
|
dedication: randomRangeInt(rng, 141, 210),
|
||||||
|
primaryAmount,
|
||||||
|
secondaryAmount,
|
||||||
|
injuryCount: injuredGenerals.length,
|
||||||
|
injuredGenerals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class StrategyActionResolver<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionResolver<TriggerState, StrategyArgs> {
|
||||||
|
public readonly key: StrategyActionConfig['key'];
|
||||||
|
protected readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
private readonly command: StrategyCommandResolver<TriggerState>;
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
protected readonly env: TurnCommandEnv,
|
||||||
|
protected readonly config: StrategyActionConfig
|
||||||
|
) {
|
||||||
|
const modules = env.generalActionModules ?? [];
|
||||||
|
this.key = config.key;
|
||||||
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
this.command = new StrategyCommandResolver<TriggerState>(modules, env, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract resolveSuccess(
|
||||||
|
context: StrategyResolveContext<TriggerState>,
|
||||||
|
args: StrategyArgs,
|
||||||
|
result: StrategyResult<TriggerState>,
|
||||||
|
effects: GeneralActionEffect<TriggerState>[]
|
||||||
|
): void;
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
context: GeneralActionResolveContext<TriggerState>,
|
||||||
|
args: StrategyArgs
|
||||||
|
): GeneralActionOutcome<TriggerState> {
|
||||||
|
const strategyContext = context as StrategyResolveContext<TriggerState>;
|
||||||
|
const { general, city, destCity } = strategyContext;
|
||||||
|
if (!city) {
|
||||||
|
throw new Error('Strategy command requires a source city context.');
|
||||||
|
}
|
||||||
|
if (!destCity) {
|
||||||
|
throw new Error('Strategy command requires a target city context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.command.resolve(
|
||||||
|
{
|
||||||
|
...strategyContext,
|
||||||
|
city,
|
||||||
|
destCity,
|
||||||
|
destGenerals: strategyContext.destGenerals,
|
||||||
|
},
|
||||||
|
strategyContext.rng
|
||||||
|
);
|
||||||
|
|
||||||
|
general.gold = Math.max(0, general.gold - result.costGold);
|
||||||
|
general.rice = Math.max(0, general.rice - result.costRice);
|
||||||
|
general.experience += result.exp;
|
||||||
|
general.dedication += result.dedication;
|
||||||
|
general.meta = addMetaNumber(general.meta, this.config.statExpKey, 1);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
strategyContext.addLog(
|
||||||
|
`<G><b>${destCity.name}</b></>에 ${this.config.name}${JosaUtil.pick(this.config.name, '이')} 실패했습니다.`,
|
||||||
|
{ format: LogFormat.MONTH }
|
||||||
|
);
|
||||||
|
return { effects: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
general.meta = addMetaNumber(general.meta, 'firenum', 1);
|
||||||
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
|
// Ref의 SabotageInjury()는 대상 도시 효과/성공 로그보다 먼저 저장된다.
|
||||||
|
for (const injured of result.injuredGenerals) {
|
||||||
|
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
|
||||||
|
strategyContext.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
|
||||||
|
generalId: injured.id,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolveSuccess(strategyContext, args, result, effects);
|
||||||
|
|
||||||
|
const itemCode = general.role.items.item;
|
||||||
|
const consumedItems = consumeSuccessfulStrategyItem(this.pipeline, strategyContext);
|
||||||
|
if (typeof itemCode === 'string' && consumedItems.includes(itemCode)) {
|
||||||
|
const item = this.env.itemCatalog?.[itemCode];
|
||||||
|
const itemName = item?.name ?? itemCode;
|
||||||
|
const itemRawName = item?.rawName ?? itemName;
|
||||||
|
strategyContext.addLog(`<C>${itemName}</>${JosaUtil.pick(itemRawName, '을')} 사용!`, {
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StrategyActionDefinition<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> implements GeneralActionDefinition<TriggerState, StrategyArgs, StrategyResolveContext<TriggerState>> {
|
||||||
|
public readonly key: StrategyActionConfig['key'];
|
||||||
|
public readonly name: StrategyActionConfig['name'];
|
||||||
|
private readonly command: StrategyCommandResolver<TriggerState>;
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
env: TurnCommandEnv,
|
||||||
|
config: StrategyActionConfig,
|
||||||
|
private readonly resolver: StrategyActionResolver<TriggerState>
|
||||||
|
) {
|
||||||
|
this.key = config.key;
|
||||||
|
this.name = config.name;
|
||||||
|
this.command = new StrategyCommandResolver<TriggerState>(env.generalActionModules ?? [], env, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseArgs(raw: unknown): StrategyArgs | null {
|
||||||
|
return parseArgsWithSchema(STRATEGY_ARGS_SCHEMA, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMinConstraints(_ctx: ConstraintContext, _args: StrategyArgs): Constraint[] {
|
||||||
|
const { gold, rice } = this.command.getCost();
|
||||||
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => gold), reqGeneralRice(() => rice)];
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConstraints(_ctx: ConstraintContext, _args: StrategyArgs): Constraint[] {
|
||||||
|
const { gold, rice } = this.command.getCost();
|
||||||
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
occupiedCity(),
|
||||||
|
suppliedCity(),
|
||||||
|
notOccupiedDestCity(),
|
||||||
|
notNeutralDestCity(),
|
||||||
|
reqGeneralGold(() => gold),
|
||||||
|
reqGeneralRice(() => rice),
|
||||||
|
disallowDiplomacyBetweenStatus({
|
||||||
|
7: '불가침국입니다.',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
formatConstraintFailure(
|
||||||
|
reason: string,
|
||||||
|
_ctx: ConstraintContext,
|
||||||
|
args: StrategyArgs,
|
||||||
|
view: StateView
|
||||||
|
): string | null {
|
||||||
|
return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location');
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: StrategyResolveContext<TriggerState>, args: StrategyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
return this.resolver.resolve(context, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildStrategyActionContext = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
|
const destCityId = options.actionArgs.destCityId;
|
||||||
|
if (typeof destCityId !== 'number' || !options.worldRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const destCity = options.worldRef.getCityById(destCityId);
|
||||||
|
if (!destCity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const destNation = destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null;
|
||||||
|
const destGenerals = options.worldRef
|
||||||
|
.listGenerals()
|
||||||
|
.filter((general) => general.cityId === destCity.id && general.nationId === destCity.nationId);
|
||||||
|
const distance = options.map ? (searchDistance(options.map, base.general.cityId, 5)[destCity.id] ?? 99) : 99;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destCity,
|
||||||
|
destNation,
|
||||||
|
destGenerals,
|
||||||
|
distance,
|
||||||
|
year: options.world.currentYear,
|
||||||
|
startYear: options.scenarioMeta?.startYear ?? options.world.currentYear,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const strategyActionContextBuilder: ActionContextBuilder = buildStrategyActionContext;
|
||||||
@@ -2,7 +2,17 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import type { City, General, Nation } from '../src/domain/entities.js';
|
import type { City, General, Nation } from '../src/domain/entities.js';
|
||||||
import { commandSpec as fireSpec } from '../src/actions/turn/general/che_화계.js';
|
import { commandSpec as fireSpec } from '../src/actions/turn/general/che_화계.js';
|
||||||
|
import { commandSpec as agitateSpec } from '../src/actions/turn/general/che_선동.js';
|
||||||
|
import { commandSpec as destroySpec } from '../src/actions/turn/general/che_파괴.js';
|
||||||
|
import { commandSpec as seizeSpec } from '../src/actions/turn/general/che_탈취.js';
|
||||||
import type { TurnCommandEnv } from '../src/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '../src/actions/turn/commandEnv.js';
|
||||||
|
import type { GeneralTurnCommandSpec } from '../src/actions/turn/general/index.js';
|
||||||
|
import {
|
||||||
|
StrategyActionDefinition,
|
||||||
|
StrategyCommandResolver,
|
||||||
|
type StrategyActionConfig,
|
||||||
|
type StrategyContext,
|
||||||
|
} from '../src/actions/turn/general/strategyCommand.js';
|
||||||
import type { WorldSnapshot } from '../src/world/types.js';
|
import type { WorldSnapshot } from '../src/world/types.js';
|
||||||
import { MINIMAL_MAP } from './fixtures/minimalMap.js';
|
import { MINIMAL_MAP } from './fixtures/minimalMap.js';
|
||||||
import { InMemoryWorld, TestGameRunner } from './testEnv.js';
|
import { InMemoryWorld, TestGameRunner } from './testEnv.js';
|
||||||
@@ -99,52 +109,123 @@ const makeGeneral = (id: number, nationId: number, cityId: number): General => (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('best-general sabotage audit', () => {
|
describe('best-general sabotage audit', () => {
|
||||||
it('repeats real fire-attack turns until one succeeds and increments firenum', async () => {
|
const strategyCases: Array<[GeneralTurnCommandSpec['key'], GeneralTurnCommandSpec, number]> = [
|
||||||
const attackerNation = makeNation(1);
|
['che_화계', fireSpec, 7],
|
||||||
const defenderNation = makeNation(2);
|
['che_선동', agitateSpec, 1],
|
||||||
const attackerCity = makeCity(1, 1);
|
['che_파괴', destroySpec, 5],
|
||||||
const defenderCity = makeCity(2, 2);
|
['che_탈취', seizeSpec, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
it('uses the same Ref probability equation through the shared base command', () => {
|
||||||
const attacker = makeGeneral(1, 1, 1);
|
const attacker = makeGeneral(1, 1, 1);
|
||||||
const defender = makeGeneral(2, 2, 2);
|
const defender = makeGeneral(2, 2, 2);
|
||||||
const snapshot: WorldSnapshot = {
|
const sourceCity = makeCity(1, 1);
|
||||||
scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' } } as never,
|
const destCity = makeCity(2, 2);
|
||||||
scenarioMeta: { startYear: 180 } as never,
|
const context = {
|
||||||
map: MINIMAL_MAP,
|
general: attacker,
|
||||||
unitSet: { id: 'default', name: 'default', crewTypes: [] },
|
city: sourceCity,
|
||||||
nations: [attackerNation, defenderNation],
|
nation: makeNation(1),
|
||||||
cities: [attackerCity, defenderCity],
|
destCity,
|
||||||
generals: [attacker, defender],
|
destNation: makeNation(2),
|
||||||
troops: [],
|
destGenerals: [defender],
|
||||||
diplomacy: [],
|
distance: 1,
|
||||||
events: [],
|
} as StrategyContext;
|
||||||
initialEvents: [],
|
const configs: StrategyActionConfig[] = [
|
||||||
};
|
{
|
||||||
const world = new InMemoryWorld(snapshot);
|
key: 'che_화계',
|
||||||
const runner = new TestGameRunner(world, 180, 1, 'best-general-sabotage-audit-2');
|
name: '화계',
|
||||||
const fire = fireSpec.createDefinition(commandEnv);
|
statKey: 'intelligence',
|
||||||
let attempts = 0;
|
statExpKey: 'intel_exp',
|
||||||
|
damageMode: 'fire',
|
||||||
|
injuryGeneral: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'che_선동',
|
||||||
|
name: '선동',
|
||||||
|
statKey: 'leadership',
|
||||||
|
statExpKey: 'leadership_exp',
|
||||||
|
damageMode: 'agitate',
|
||||||
|
injuryGeneral: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'che_파괴',
|
||||||
|
name: '파괴',
|
||||||
|
statKey: 'strength',
|
||||||
|
statExpKey: 'strength_exp',
|
||||||
|
damageMode: 'destroy',
|
||||||
|
injuryGeneral: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'che_탈취',
|
||||||
|
name: '탈취',
|
||||||
|
statKey: 'strength',
|
||||||
|
statExpKey: 'strength_exp',
|
||||||
|
damageMode: 'seize',
|
||||||
|
injuryGeneral: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
while ((world.getGeneral(attacker.id)?.meta.firenum ?? 0) === 0 && attempts < 20) {
|
for (const config of configs) {
|
||||||
attempts += 1;
|
const probability = new StrategyCommandResolver([], commandEnv, config).getProbability(context);
|
||||||
await runner.runTurn([
|
|
||||||
{
|
expect(probability).toMatchObject({ distance: 1 });
|
||||||
generalId: attacker.id,
|
expect(probability.success).toBeCloseTo(0.325, 12);
|
||||||
commandKey: 'che_화계',
|
}
|
||||||
resolver: fire,
|
for (const [, spec] of strategyCases) {
|
||||||
args: { destCityId: defenderCity.id },
|
expect(spec.createDefinition(commandEnv)).toBeInstanceOf(StrategyActionDefinition);
|
||||||
context: {
|
expect(spec.category).toBe('계략');
|
||||||
destCity: world.getCity(defenderCity.id),
|
|
||||||
destNation: defenderNation,
|
|
||||||
destGenerals: [world.getGeneral(defender.id)],
|
|
||||||
distance: 1,
|
|
||||||
env: commandEnv,
|
|
||||||
map: MINIMAL_MAP,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(attempts).toBe(3);
|
|
||||||
expect(world.getGeneral(attacker.id)?.meta.firenum).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(strategyCases)(
|
||||||
|
'%s repeats real general turns until success and increments firenum',
|
||||||
|
async (key, spec, expectedAttempts) => {
|
||||||
|
const attackerNation = makeNation(1);
|
||||||
|
const defenderNation = makeNation(2);
|
||||||
|
const attackerCity = makeCity(1, 1);
|
||||||
|
const defenderCity = makeCity(2, 2);
|
||||||
|
const attacker = makeGeneral(1, 1, 1);
|
||||||
|
const defender = makeGeneral(2, 2, 2);
|
||||||
|
const snapshot: WorldSnapshot = {
|
||||||
|
scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' } } as never,
|
||||||
|
scenarioMeta: { startYear: 180 } as never,
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
unitSet: { id: 'default', name: 'default', crewTypes: [] },
|
||||||
|
nations: [attackerNation, defenderNation],
|
||||||
|
cities: [attackerCity, defenderCity],
|
||||||
|
generals: [attacker, defender],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
};
|
||||||
|
const world = new InMemoryWorld(snapshot);
|
||||||
|
const runner = new TestGameRunner(world, 180, 1, `best-general-sabotage-audit-${key}`);
|
||||||
|
const strategy = spec.createDefinition(commandEnv);
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while ((world.getGeneral(attacker.id)?.meta.firenum ?? 0) === 0 && attempts < 20) {
|
||||||
|
attempts += 1;
|
||||||
|
await runner.runTurn([
|
||||||
|
{
|
||||||
|
generalId: attacker.id,
|
||||||
|
commandKey: key,
|
||||||
|
resolver: strategy,
|
||||||
|
args: { destCityId: defenderCity.id },
|
||||||
|
context: {
|
||||||
|
destCity: world.getCity(defenderCity.id),
|
||||||
|
destNation: defenderNation,
|
||||||
|
destGenerals: [world.getGeneral(defender.id)],
|
||||||
|
distance: 1,
|
||||||
|
env: commandEnv,
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(attempts).toBe(expectedAttempts);
|
||||||
|
expect(world.getGeneral(attacker.id)?.meta.firenum).toBe(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"che_사기진작",
|
"che_사기진작",
|
||||||
"che_요양",
|
"che_요양",
|
||||||
"che_견문",
|
"che_견문",
|
||||||
|
"che_은퇴",
|
||||||
"che_내정특기초기화",
|
"che_내정특기초기화",
|
||||||
"che_전투특기초기화",
|
"che_전투특기초기화",
|
||||||
"che_장비매매",
|
"che_장비매매",
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
"che_치안강화",
|
"che_치안강화",
|
||||||
"che_수비강화",
|
"che_수비강화",
|
||||||
"che_성벽보수",
|
"che_성벽보수",
|
||||||
|
"che_선동",
|
||||||
|
"che_탈취",
|
||||||
|
"che_파괴",
|
||||||
"che_화계",
|
"che_화계",
|
||||||
"che_집합",
|
"che_집합",
|
||||||
"che_인재탐색",
|
"che_인재탐색",
|
||||||
@@ -34,6 +38,8 @@
|
|||||||
"che_증여",
|
"che_증여",
|
||||||
"che_헌납",
|
"che_헌납",
|
||||||
"che_이동",
|
"che_이동",
|
||||||
|
"che_강행",
|
||||||
|
"che_하야",
|
||||||
"che_선양",
|
"che_선양",
|
||||||
"che_해산",
|
"che_해산",
|
||||||
"휴식"
|
"휴식"
|
||||||
|
|||||||
@@ -33,10 +33,7 @@
|
|||||||
"regex": []
|
"regex": []
|
||||||
},
|
},
|
||||||
"General/che_인재탐색": {
|
"General/che_인재탐색": {
|
||||||
"templates": [
|
"templates": ["<Y>${}</>${}는 <C>인재</>를 ${}하였습니다!", "<Y>${}</>${}는 <C>인재</>를 발견하였습니다!"],
|
||||||
"<Y>${}</>${}는 <C>인재</>를 ${}하였습니다!",
|
|
||||||
"<Y>${}</>${}는 <C>인재</>를 발견하였습니다!"
|
|
||||||
],
|
|
||||||
"regex": []
|
"regex": []
|
||||||
},
|
},
|
||||||
"General/che_기술연구": {
|
"General/che_기술연구": {
|
||||||
@@ -56,6 +53,10 @@
|
|||||||
"templates": ["<G>${}</>에 선동${} 실패했습니다."],
|
"templates": ["<G>${}</>에 선동${} 실패했습니다."],
|
||||||
"regex": []
|
"regex": []
|
||||||
},
|
},
|
||||||
|
"General/che_화계": {
|
||||||
|
"templates": ["<G>${}</>에 ${}${} 실패했습니다.", "<C>${}</>${} 사용!"],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
"General/che_은퇴": {
|
"General/che_은퇴": {
|
||||||
"templates": ["나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다."],
|
"templates": ["나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다."],
|
||||||
"regex": []
|
"regex": []
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||||
|
import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
|
||||||
import {
|
import {
|
||||||
GENERAL_TURN_COMMAND_KEYS,
|
GENERAL_TURN_COMMAND_KEYS,
|
||||||
NATION_TURN_COMMAND_KEYS,
|
NATION_TURN_COMMAND_KEYS,
|
||||||
normalizeScenarioEffect,
|
normalizeScenarioEffect,
|
||||||
|
readLegacyCityTrust,
|
||||||
type MapDefinition,
|
type MapDefinition,
|
||||||
type Nation,
|
type Nation,
|
||||||
type TurnCommandProfile,
|
type TurnCommandProfile,
|
||||||
@@ -683,7 +685,10 @@ const projectWorld = (
|
|||||||
conflict: city.conflict ?? {},
|
conflict: city.conflict ?? {},
|
||||||
state: city.state,
|
state: city.state,
|
||||||
term: readNumber(city.meta, 'term'),
|
term: readNumber(city.meta, 'term'),
|
||||||
trust: readNumber(city.meta, 'trust'),
|
// The reference snapshot observes MariaDB FLOAT through its text
|
||||||
|
// protocol. Project the in-memory binary32 value at that same
|
||||||
|
// read boundary before comparing state deltas.
|
||||||
|
trust: readLegacyCityTrust(readNumber(city.meta, 'trust')),
|
||||||
trade: readNumber(city.meta, 'trade'),
|
trade: readNumber(city.meta, 'trade'),
|
||||||
officerSet: readNumber(city.meta, 'officer_set'),
|
officerSet: readNumber(city.meta, 'officer_set'),
|
||||||
})),
|
})),
|
||||||
@@ -696,7 +701,7 @@ const projectWorld = (
|
|||||||
capitalCityId: nation.capitalCityId,
|
capitalCityId: nation.capitalCityId,
|
||||||
gold: toDatabaseInt(nation.gold),
|
gold: toDatabaseInt(nation.gold),
|
||||||
rice: toDatabaseInt(nation.rice),
|
rice: toDatabaseInt(nation.rice),
|
||||||
tech: readNumber(nation.meta, 'tech'),
|
tech: readLegacyStoredFloat(readNumber(nation.meta, 'tech')),
|
||||||
level: nation.level,
|
level: nation.level,
|
||||||
typeCode: nation.typeCode,
|
typeCode: nation.typeCode,
|
||||||
generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length,
|
generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length,
|
||||||
|
|||||||
@@ -2823,6 +2823,142 @@ type SabotageProbabilityClampCase = {
|
|||||||
boundary: 'zero' | 'max';
|
boundary: 'zero' | 'max';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sabotageStatProgressionCases = [
|
||||||
|
{ action: 'che_화계', stat: 'intelligence', statExp: 'intelExp' },
|
||||||
|
{ action: 'che_선동', stat: 'leadership', statExp: 'leadershipExp' },
|
||||||
|
{ action: 'che_파괴', stat: 'strength', statExp: 'strengthExp' },
|
||||||
|
{ action: 'che_탈취', stat: 'strength', statExp: 'strengthExp' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const sabotageSuccessfulEffectCases = sabotageStatProgressionCases.map(({ action, stat }) => ({ action, stat }));
|
||||||
|
|
||||||
|
const sabotageSuccessfulEffectExpected = {
|
||||||
|
che_화계: { city: { agriculture: 494, commerce: 859, state: 32 } },
|
||||||
|
che_선동: { city: { security: 0, trust: 70.1066, state: 32 } },
|
||||||
|
che_파괴: { city: { defence: 536, wall: 222, state: 32 } },
|
||||||
|
che_탈취: {
|
||||||
|
city: { state: 32 },
|
||||||
|
nation: { gold: 999_341, rice: 999_200 },
|
||||||
|
actor: { gold: 100_108, rice: 100_140 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
integration('general sabotage successful effect matrix', () => {
|
||||||
|
it.each(sabotageSuccessfulEffectCases)(
|
||||||
|
'$action executes a real general turn at the 0.5 probability clamp',
|
||||||
|
async ({ action, stat }) => {
|
||||||
|
const request = buildRequest(
|
||||||
|
action,
|
||||||
|
{ destCityID: 70 },
|
||||||
|
{ [stat]: 100 },
|
||||||
|
{
|
||||||
|
generals: { 2: { [stat]: 10 } },
|
||||||
|
cities: { 70: { security: 100, securityMax: 2_000, supplyState: 1 } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-value-0';
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: action,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
expect(reference.rng[0]).toMatchObject({
|
||||||
|
operation: 'nextBits',
|
||||||
|
arguments: { bits: 1 },
|
||||||
|
result: '01',
|
||||||
|
});
|
||||||
|
expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
|
||||||
|
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
const findById = (rows: Array<Record<string, unknown>>, id: number) =>
|
||||||
|
rows.find((entry) => entry.id === id);
|
||||||
|
const expected = sabotageSuccessfulEffectExpected[action];
|
||||||
|
expect(findById(reference.after.cities, 70)).toMatchObject(expected.city);
|
||||||
|
if ('nation' in expected) {
|
||||||
|
expect(findById(reference.after.nations, 2)).toMatchObject(expected.nation);
|
||||||
|
}
|
||||||
|
if ('actor' in expected) {
|
||||||
|
expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') {
|
||||||
|
process.stderr.write(
|
||||||
|
`${JSON.stringify({
|
||||||
|
action,
|
||||||
|
probability: 0.5,
|
||||||
|
rng: reference.rng,
|
||||||
|
reference: {
|
||||||
|
actorBefore: findById(reference.before.generals, 1),
|
||||||
|
actorAfter: findById(reference.after.generals, 1),
|
||||||
|
targetCityBefore: findById(reference.before.cities, 70),
|
||||||
|
targetCityAfter: findById(reference.after.cities, 70),
|
||||||
|
targetNationBefore: findById(reference.before.nations, 2),
|
||||||
|
targetNationAfter: findById(reference.after.nations, 2),
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
actorAfter: findById(core.after.generals, 1),
|
||||||
|
targetCityAfter: findById(core.after.cities, 70),
|
||||||
|
targetNationAfter: findById(core.after.nations, 2),
|
||||||
|
},
|
||||||
|
})}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
integration('general sabotage stat progression matrix', () => {
|
||||||
|
it.each(sabotageStatProgressionCases)(
|
||||||
|
'$action inherits the base strategy stat progression tail',
|
||||||
|
async ({ action, stat, statExp }) => {
|
||||||
|
const request = buildRequest(
|
||||||
|
action,
|
||||||
|
{ destCityID: 70 },
|
||||||
|
{ [stat]: 100, [statExp]: 29 },
|
||||||
|
{
|
||||||
|
generals: { 2: { [stat]: 10 } },
|
||||||
|
cities: { 70: { security: 0, securityMax: 2_000 } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-value-0';
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: action,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
|
||||||
|
expect(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = (
|
const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = (
|
||||||
[
|
[
|
||||||
['che_화계', 'intelligence'],
|
['che_화계', 'intelligence'],
|
||||||
|
|||||||
Reference in New Issue
Block a user