Merge branch 'main' into feature/best-general-data-parity
This commit is contained in:
@@ -55,7 +55,7 @@ export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}):
|
||||
continue;
|
||||
}
|
||||
|
||||
let job: BattleSimJob | null = null;
|
||||
let job: BattleSimJob;
|
||||
try {
|
||||
job = JSON.parse(raw) as BattleSimJob;
|
||||
} catch {
|
||||
|
||||
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
|
||||
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
|
||||
|
||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
|
||||
return resolveNationPermission(general, nation.meta, true);
|
||||
};
|
||||
|
||||
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
|
||||
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
|
||||
if (state === 'ACTIVATED') return 'ACTIVATED';
|
||||
if (state === 'CANCELLED') return 'CANCELLED';
|
||||
if (state === 'REPLACED') return 'REPLACED';
|
||||
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
|
||||
select: { id: true },
|
||||
});
|
||||
if (newer) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (prevLetter.state === 'PROPOSED') {
|
||||
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
destNationId =
|
||||
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
}
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
@@ -372,4 +374,4 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
return { state: 'ACTIVATED' };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
||||
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
type WorldTrendSnapshot = {
|
||||
year: number;
|
||||
@@ -231,6 +232,11 @@ const sortNpcList = <T extends {
|
||||
});
|
||||
|
||||
export const publicRouter = router({
|
||||
recordAccess: sessionActivityProcedure
|
||||
.input(z.object({ page: z.enum(accessPages) }))
|
||||
.mutation(async ({ ctx, input }) => ({
|
||||
recorded: await recordGeneralAccess(ctx, input.page),
|
||||
})),
|
||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||
return loadMapLayout(ctx.profile.scenario);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
export const accessPages = [
|
||||
'front-info',
|
||||
'nation-info',
|
||||
'nation-cities',
|
||||
'global-info',
|
||||
'current-city',
|
||||
'diplomacy',
|
||||
'nation-generals',
|
||||
'nation-personnel',
|
||||
'nation-finance',
|
||||
'battle-center',
|
||||
'board',
|
||||
'best-general',
|
||||
'hall-of-fame',
|
||||
'dynasty',
|
||||
'yearbook',
|
||||
'nation-betting',
|
||||
'traffic',
|
||||
'npc-list',
|
||||
'my-page',
|
||||
'npc-control',
|
||||
'tournament',
|
||||
'betting',
|
||||
] as const;
|
||||
|
||||
export type AccessPage = (typeof accessPages)[number];
|
||||
|
||||
export const accessPageWeights: Record<AccessPage, number> = {
|
||||
'front-info': 1,
|
||||
'nation-info': 1,
|
||||
'nation-cities': 1,
|
||||
'global-info': 1,
|
||||
'current-city': 1,
|
||||
diplomacy: 1,
|
||||
'nation-generals': 1,
|
||||
'nation-personnel': 1,
|
||||
'nation-finance': 1,
|
||||
'battle-center': 1,
|
||||
board: 1,
|
||||
'best-general': 1,
|
||||
'hall-of-fame': 1,
|
||||
dynasty: 1,
|
||||
yearbook: 1,
|
||||
'nation-betting': 1,
|
||||
traffic: 1,
|
||||
'npc-list': 2,
|
||||
'my-page': 1,
|
||||
'npc-control': 1,
|
||||
tournament: 1,
|
||||
betting: 1,
|
||||
};
|
||||
|
||||
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readDate = (value: unknown): Date | null => {
|
||||
if (typeof value !== 'string' && !(value instanceof Date)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
export const resolveAccessWindows = (
|
||||
now: Date,
|
||||
tickSeconds: number,
|
||||
worldMeta: unknown
|
||||
): { dayStartedAt: Date; scoreStartedAt: Date } => {
|
||||
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const meta = asRecord(worldMeta);
|
||||
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
||||
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
||||
const scoreStartedAt =
|
||||
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
||||
? tickStartedAt
|
||||
: new Date(now.getTime() - fallbackTickMs);
|
||||
return { dayStartedAt, scoreStartedAt };
|
||||
};
|
||||
|
||||
export const upsertGeneralAccess = async (
|
||||
db: Pick<GameApiContext['db'], '$executeRaw'>,
|
||||
input: {
|
||||
generalId: number;
|
||||
userId: string;
|
||||
weight: number;
|
||||
now: Date;
|
||||
dayStartedAt: Date;
|
||||
scoreStartedAt: Date;
|
||||
}
|
||||
): Promise<void> => {
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO general_access_log (
|
||||
general_id,
|
||||
user_id,
|
||||
last_refresh,
|
||||
refresh,
|
||||
refresh_total,
|
||||
refresh_score,
|
||||
refresh_score_total
|
||||
)
|
||||
VALUES (
|
||||
${input.generalId},
|
||||
${input.userId},
|
||||
${input.now},
|
||||
${input.weight},
|
||||
${input.weight},
|
||||
${input.weight},
|
||||
${input.weight}
|
||||
)
|
||||
ON CONFLICT (general_id) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
last_refresh = EXCLUDED.last_refresh,
|
||||
refresh = CASE
|
||||
WHEN general_access_log.last_refresh IS NULL
|
||||
OR general_access_log.last_refresh < ${input.dayStartedAt}
|
||||
THEN EXCLUDED.refresh
|
||||
ELSE general_access_log.refresh + EXCLUDED.refresh
|
||||
END,
|
||||
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
|
||||
refresh_score = CASE
|
||||
WHEN general_access_log.last_refresh IS NULL
|
||||
OR general_access_log.last_refresh < ${input.scoreStartedAt}
|
||||
THEN EXCLUDED.refresh_score
|
||||
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
|
||||
END,
|
||||
refresh_score_total =
|
||||
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const recordGeneralAccess = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||
page: AccessPage,
|
||||
now = new Date()
|
||||
): Promise<boolean> => {
|
||||
const user = ctx.auth?.user;
|
||||
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [general, worldState] = await Promise.all([
|
||||
ctx.db.general.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { tickSeconds: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
if (!general || !worldState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
|
||||
const openTime = readDate(meta.opentime);
|
||||
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const weight = accessPageWeights[page];
|
||||
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||
|
||||
await upsertGeneralAccess(ctx.db, {
|
||||
generalId: general.id,
|
||||
userId: user.id,
|
||||
weight,
|
||||
now,
|
||||
dayStartedAt,
|
||||
scoreStartedAt,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -63,6 +63,10 @@ export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||
|
||||
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||
export const sessionActivityProcedure = t.procedure;
|
||||
|
||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 9_980_071;
|
||||
|
||||
integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
||||
const firstWindow = {
|
||||
generalId,
|
||||
userId: 'access-user-a',
|
||||
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
};
|
||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
upsertGeneralAccess(db, {
|
||||
...firstWindow,
|
||||
now: new Date(firstWindow.now.getTime() + index + 1),
|
||||
weight: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-a',
|
||||
refresh: 22,
|
||||
refreshTotal: 22,
|
||||
refreshScore: 22,
|
||||
refreshScoreTotal: 22,
|
||||
});
|
||||
|
||||
await upsertGeneralAccess(db, {
|
||||
generalId,
|
||||
userId: 'access-user-b',
|
||||
now: new Date('2026-07-27T00:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-b',
|
||||
refresh: 1,
|
||||
refreshTotal: 23,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 23,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import { recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
|
||||
|
||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'access-session',
|
||||
user: {
|
||||
id: 'user-7',
|
||||
username: 'user7',
|
||||
displayName: '사용자7',
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||
const findWorld = vi.fn(async () => ({
|
||||
tickSeconds: 600,
|
||||
meta: {
|
||||
opentime: '2026-07-25T00:00:00.000Z',
|
||||
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
||||
...meta,
|
||||
},
|
||||
}));
|
||||
const db = {
|
||||
$executeRaw: executeRaw,
|
||||
general: { findFirst: findGeneral },
|
||||
worldState: { findFirst: findWorld },
|
||||
} as unknown as DatabaseClient;
|
||||
return { db, executeRaw, findGeneral, findWorld };
|
||||
};
|
||||
|
||||
describe('general access tracking', () => {
|
||||
it('resolves the UTC day and latest processed turn windows', () => {
|
||||
expect(
|
||||
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||
const { db, executeRaw, findGeneral } = buildDb();
|
||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||
expect(findGeneral).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-7' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(statement.values).toEqual([
|
||||
7,
|
||||
'user-7',
|
||||
now,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
new Date('2026-07-26T00:00:00.000Z'),
|
||||
new Date('2026-07-26T03:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||
const anonymous = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const admin = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(admin.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
||||
await expect(
|
||||
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||
).resolves.toBe(false);
|
||||
expect(future.executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
const united = buildDb({ isUnited: 2 });
|
||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(united.executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -711,7 +711,7 @@ export class GeneralAI {
|
||||
const leadership = this.general.stats.leadership;
|
||||
const strength = Math.max(this.general.stats.strength, 1);
|
||||
const intel = Math.max(this.general.stats.intelligence, 1);
|
||||
let genType = 0;
|
||||
let genType: number;
|
||||
|
||||
if (strength >= intel) {
|
||||
genType = t무장;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
|
||||
const force = ai.nationPolicy.combatForce[leader.id];
|
||||
let [fromCityId, toCityId] = force;
|
||||
|
||||
let targetCityId: number | null = null;
|
||||
let targetCityId: number | null;
|
||||
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
|
||||
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
||||
} else {
|
||||
|
||||
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
|
||||
originOutcome: number
|
||||
): { next: number; ratio: number; realOutcome: number } => {
|
||||
let next = current + income;
|
||||
let realOutcome = 0;
|
||||
let realOutcome: number;
|
||||
if (next < baseResource) {
|
||||
realOutcome = 0;
|
||||
next = baseResource;
|
||||
@@ -139,14 +139,11 @@ const processIncomeForNation = (
|
||||
const trait = traitMap.get(nation.typeCode) ?? null;
|
||||
const incomeContext = buildNationIncomeContext(nation, trait);
|
||||
|
||||
let income = 0;
|
||||
if (type === 'gold') {
|
||||
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
} else {
|
||||
income =
|
||||
getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
}
|
||||
const income =
|
||||
type === 'gold'
|
||||
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
|
||||
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||
|
||||
const incomeValue = roundResource(income);
|
||||
const originOutcome = getOutcome(100, nationGenerals);
|
||||
|
||||
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
|
||||
sabotage,
|
||||
dex,
|
||||
unifier,
|
||||
unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
|
||||
unifierAward:
|
||||
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -194,15 +195,11 @@ export const createUnificationHandler = (options: {
|
||||
const meta = asRecord(state.meta);
|
||||
|
||||
const serverId =
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim()
|
||||
? meta.serverId.trim()
|
||||
: options.profileName;
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
|
||||
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
|
||||
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
|
||||
const scenarioName =
|
||||
typeof asRecord(meta.scenarioMeta).title === 'string'
|
||||
? String(asRecord(meta.scenarioMeta).title)
|
||||
: '';
|
||||
typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
|
||||
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
||||
const unitedTime = new Date().toISOString();
|
||||
|
||||
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
|
||||
};
|
||||
|
||||
for (const [typeName, valueType] of hallTypes) {
|
||||
let value = 0;
|
||||
if (valueType === 'natural') {
|
||||
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0;
|
||||
} else if (valueType === 'rank') {
|
||||
value = ranks[typeName] ?? 0;
|
||||
} else {
|
||||
value = calcValues[typeName] ?? 0;
|
||||
}
|
||||
const value =
|
||||
valueType === 'natural'
|
||||
? typeName === 'experience'
|
||||
? general.experience
|
||||
: typeName === 'dedication'
|
||||
? general.dedication
|
||||
: (ranks[typeName] ?? 0)
|
||||
: valueType === 'rank'
|
||||
? (ranks[typeName] ?? 0)
|
||||
: (calcValues[typeName] ?? 0);
|
||||
|
||||
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
|
||||
continue;
|
||||
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
|
||||
const meta = asRecord(state.meta);
|
||||
|
||||
const serverId =
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim()
|
||||
? meta.serverId.trim()
|
||||
: options.profileName;
|
||||
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
|
||||
const serverName =
|
||||
typeof meta.serverName === 'string' && meta.serverName.trim()
|
||||
? meta.serverName.trim()
|
||||
@@ -653,30 +650,30 @@ export const createUnificationHandler = (options: {
|
||||
await Promise.all(
|
||||
oldGeneralTargets.map((general) =>
|
||||
((snapshot) =>
|
||||
prisma.oldGeneral.upsert({
|
||||
where: {
|
||||
by_no: {
|
||||
prisma.oldGeneral.upsert({
|
||||
where: {
|
||||
by_no: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
owner: general.userId ?? null,
|
||||
name: general.name,
|
||||
lastYearMonth: state.currentYear * 100 + state.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: snapshot,
|
||||
},
|
||||
}))( {
|
||||
}))({
|
||||
...general,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
})
|
||||
|
||||
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
|
||||
const attackerG = getRankNumber(attacker, rankKey('g'));
|
||||
const defenderG = getRankNumber(defender, rankKey('g'));
|
||||
|
||||
let attackerGDelta = 0;
|
||||
let defenderGDelta = 0;
|
||||
let attackerGDelta: number;
|
||||
let defenderGDelta: number;
|
||||
let attackerW = 0;
|
||||
let attackerD = 0;
|
||||
let attackerL = 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => {
|
||||
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => {
|
||||
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
|
||||
const nations = [
|
||||
{
|
||||
@@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
|
||||
}) as never,
|
||||
});
|
||||
|
||||
handler.beforeMonthChanged?.({} as never);
|
||||
await handler.beforeMonthChanged?.({} as never);
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
|
||||
@@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
||||
// Nation awards can occur in the same tick and make the general's net
|
||||
// gold delta smaller than the recruitment price. Exact cost scaling is
|
||||
// covered by the unit-set/action contract tests rather than this smoke.
|
||||
}, 60000);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
@@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, 180000);
|
||||
}, 360_000);
|
||||
});
|
||||
|
||||
@@ -13,5 +13,7 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
maxWorkers: 4,
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,6 +23,12 @@ type FixtureState = {
|
||||
permission: 'head' | 'member';
|
||||
myset: number;
|
||||
settingMutations: Array<Record<string, unknown>>;
|
||||
accessPages: string[];
|
||||
};
|
||||
|
||||
type TrpcRequestPayload = {
|
||||
json?: Record<string, unknown>;
|
||||
input?: { json?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
const myGeneral = (state: FixtureState) => ({
|
||||
@@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({
|
||||
|
||||
const install = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'menu-token');
|
||||
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/game/**', async (route) => {
|
||||
@@ -130,7 +136,16 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const results = operations.map((operation) => {
|
||||
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||
const requestBody =
|
||||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||
const results = operations.map((operation, operationIndex) => {
|
||||
const rawPayload =
|
||||
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
||||
const payload =
|
||||
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||||
const jsonInput =
|
||||
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'general.me') return response(myGeneral(state));
|
||||
@@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
if (operation === 'general.getMyLog')
|
||||
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
||||
if (operation === 'general.setMySetting') {
|
||||
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } };
|
||||
state.settingMutations.push(raw.input?.json ?? {});
|
||||
state.settingMutations.push(jsonInput);
|
||||
state.myset = Math.max(0, state.myset - 1);
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'public.recordAccess') {
|
||||
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||||
if (pageName) state.accessPages.push(pageName);
|
||||
return response({ recorded: true });
|
||||
}
|
||||
if (operation === 'nation.getBattleCenter') {
|
||||
if (state.permission === 'member') {
|
||||
return {
|
||||
@@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
};
|
||||
|
||||
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] };
|
||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('traffic');
|
||||
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
||||
await expect.poll(() => state.accessPages).toContain('traffic');
|
||||
|
||||
const geometry = await page.locator('#traffic-container').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -244,12 +264,13 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
|
||||
});
|
||||
|
||||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('my-page');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||
await expect.poll(() => state.accessPages).toContain('my-page');
|
||||
|
||||
const desktop = await page.locator('#container').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -322,7 +343,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
});
|
||||
|
||||
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
||||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, head);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('battle-center');
|
||||
@@ -368,7 +389,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
||||
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] };
|
||||
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, member);
|
||||
await page.reload();
|
||||
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
|
||||
|
||||
interface Props {
|
||||
general: GeneralDraft;
|
||||
options: BattleSimOptions;
|
||||
mode: 'attacker' | 'defender';
|
||||
title: string;
|
||||
@@ -11,6 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const general = defineModel<GeneralDraft>('general', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'import'): void;
|
||||
|
||||
@@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue';
|
||||
import NpcListView from '../views/NpcListView.vue';
|
||||
import TrafficView from '../views/TrafficView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const accessPageByRouteName = {
|
||||
home: 'front-info',
|
||||
'nation-info': 'nation-info',
|
||||
'nation-cities': 'nation-cities',
|
||||
'global-info': 'global-info',
|
||||
'current-city': 'current-city',
|
||||
diplomacy: 'diplomacy',
|
||||
'nation-generals': 'nation-generals',
|
||||
'nation-personnel': 'nation-personnel',
|
||||
'nation-finance': 'nation-finance',
|
||||
'battle-center': 'battle-center',
|
||||
board: 'board',
|
||||
'board-secret': 'board',
|
||||
'best-general': 'best-general',
|
||||
'hall-of-fame': 'hall-of-fame',
|
||||
'dynasty-list': 'dynasty',
|
||||
'dynasty-detail': 'dynasty',
|
||||
yearbook: 'yearbook',
|
||||
'nation-betting': 'nation-betting',
|
||||
traffic: 'traffic',
|
||||
'npc-list': 'npc-list',
|
||||
'my-page': 'my-page',
|
||||
'npc-control': 'npc-control',
|
||||
tournament: 'tournament',
|
||||
betting: 'betting',
|
||||
} as const;
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -370,4 +398,14 @@ router.beforeEach(async (to) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
const session = useSessionStore();
|
||||
const routeName = typeof to.name === 'string' ? to.name : '';
|
||||
const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName];
|
||||
if (!page || !session.hasGeneral) {
|
||||
return;
|
||||
}
|
||||
void trpc.public.recordAccess.mutate({ page }).catch(() => undefined);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
|
||||
return '';
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null = null;
|
||||
let lastIndex = 0;
|
||||
const result: string[] = [];
|
||||
|
||||
while ((match = logRegex.exec(text)) !== null) {
|
||||
for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
|
||||
const partAll = match[0];
|
||||
const subPart = match[1];
|
||||
const index = match.index;
|
||||
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
|
||||
if (subPart === '/') {
|
||||
result.push('</span>');
|
||||
} else if (subPart.length === 2) {
|
||||
result.push(
|
||||
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
|
||||
);
|
||||
result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
|
||||
} else {
|
||||
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
|
||||
}
|
||||
|
||||
@@ -1125,7 +1125,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
|
||||
<BattleGeneralCard
|
||||
v-if="attackerGeneral"
|
||||
:general="attackerGeneral!"
|
||||
v-model:general="attackerGeneral"
|
||||
:options="options!"
|
||||
mode="attacker"
|
||||
title="출병자 설정"
|
||||
@@ -1193,7 +1193,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
<BattleGeneralCard
|
||||
v-for="(defender, index) in defenders"
|
||||
:key="defender.id"
|
||||
:general="defender"
|
||||
v-model:general="defenders[index]"
|
||||
:options="options!"
|
||||
mode="defender"
|
||||
:title="`수비자 설정 ${index + 1}`"
|
||||
|
||||
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const prevOptions = computed(() =>
|
||||
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
|
||||
);
|
||||
const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
|
||||
|
||||
@@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) =>
|
||||
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
|
||||
|
||||
const canDestroy = (letter: DiplomacyLetter) =>
|
||||
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
||||
editable.value &&
|
||||
letter.state === 'ACTIVATED' &&
|
||||
(data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
||||
|
||||
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
|
||||
|
||||
onMounted(() => {
|
||||
loadLetters();
|
||||
void loadLetters();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(국가 내 공개)</div>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: briefEditor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleItalic().run()"
|
||||
:class="{ active: briefEditor?.isActive('italic') }"
|
||||
>
|
||||
기울임
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleUnderline().run()"
|
||||
:class="{ active: briefEditor?.isActive('underline') }"
|
||||
>
|
||||
밑줄
|
||||
</button>
|
||||
<button type="button" @click="addLink('brief')">링크</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
||||
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
||||
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
|
||||
번호 목록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
uploadTarget = 'brief';
|
||||
fileInputRef?.click();
|
||||
"
|
||||
:disabled="uploadBusy"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(외교권자 전용)</div>
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: detailEditor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleItalic().run()"
|
||||
:class="{ active: detailEditor?.isActive('italic') }"
|
||||
>
|
||||
기울임
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleUnderline().run()"
|
||||
:class="{ active: detailEditor?.isActive('underline') }"
|
||||
>
|
||||
밑줄
|
||||
</button>
|
||||
<button type="button" @click="addLink('detail')">링크</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
||||
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
||||
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
|
||||
번호 목록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
uploadTarget = 'detail';
|
||||
fileInputRef?.click();
|
||||
"
|
||||
:disabled="uploadBusy"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
||||
</div>
|
||||
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<div v-if="historyOpen[letter.id]" class="history-panel">
|
||||
<template v-if="getPrevLetter(letter)">
|
||||
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔ {{ getPrevLetter(letter)?.dest.nationName }}</p>
|
||||
<p>
|
||||
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔
|
||||
{{ getPrevLetter(letter)?.dest.nationName }}
|
||||
</p>
|
||||
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
|
||||
</template>
|
||||
<p v-else class="hint">이전 문서를 찾을 수 없습니다.</p>
|
||||
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<footer class="letter-actions">
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
|
||||
승인
|
||||
</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">
|
||||
거부
|
||||
</button>
|
||||
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
||||
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
||||
<button v-if="canRenew(letter)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button>
|
||||
<button
|
||||
v-if="canRenew(letter)"
|
||||
type="button"
|
||||
@click="
|
||||
selectedPrevId = letter.id;
|
||||
applyPrevLetter();
|
||||
"
|
||||
>
|
||||
추가 문서 작성
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
@@ -565,4 +639,4 @@ onBeforeUnmount(() => {
|
||||
.loading {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -242,7 +242,7 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-header">
|
||||
<h2>국가 방침</h2>
|
||||
<div class="panel-actions">
|
||||
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">
|
||||
수정
|
||||
</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
|
||||
저장
|
||||
</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
|
||||
취소
|
||||
</button>
|
||||
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
|
||||
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editingNationMsg" class="editor-toolbar">
|
||||
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
|
||||
<button
|
||||
type="button"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: editor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-card">
|
||||
<h3>자금 예산</h3>
|
||||
<dl>
|
||||
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div>
|
||||
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div>
|
||||
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div>
|
||||
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div>
|
||||
<dt>현재</dt>
|
||||
<dd>{{ data.gold.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>단기 수입</dt>
|
||||
<dd>{{ data.income.gold.war.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>세금</dt>
|
||||
<dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>수입/지출</dt>
|
||||
<dd>
|
||||
+{{ Math.floor(incomeGold).toLocaleString() }} /
|
||||
{{ Math.floor(-outcomeByBill).toLocaleString() }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>국고 예산</dt>
|
||||
<dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<h3>군량 예산</h3>
|
||||
<dl>
|
||||
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div>
|
||||
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div>
|
||||
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div>
|
||||
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd></div>
|
||||
<div>
|
||||
<dt>현재</dt>
|
||||
<dd>{{ data.rice.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>둔전 수입</dt>
|
||||
<dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>세금</dt>
|
||||
<dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>수입/지출</dt>
|
||||
<dd>
|
||||
+{{ Math.floor(incomeRice).toLocaleString() }} /
|
||||
{{ Math.floor(-outcomeByBill).toLocaleString() }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>국고 예산</dt>
|
||||
<dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
|
||||
<div class="panel-card">
|
||||
<h3>기밀 권한</h3>
|
||||
<div class="input-row">
|
||||
<input v-model.number="policyDraft.secretLimit" type="number" min="1" max="99" :disabled="!editable" />
|
||||
<input
|
||||
v-model.number="policyDraft.secretLimit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
:disabled="!editable"
|
||||
/>
|
||||
<span>년</span>
|
||||
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
|
||||
</div>
|
||||
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
전쟁 금지
|
||||
</label>
|
||||
<span class="hint">잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)</span>
|
||||
<span class="hint"
|
||||
>잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
@@ -574,4 +616,4 @@ onBeforeUnmount(() => {
|
||||
.loading {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -134,7 +134,7 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="editor-toolbar">
|
||||
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
|
||||
<button
|
||||
type="button"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
:class="{ active: editor?.isActive('bold') }"
|
||||
>
|
||||
굵게
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -219,6 +219,11 @@ minimum/maximum clamps for gift, donation, and rice trade; donation against
|
||||
available and minimum resources; and gift reserve and self-target rejection.
|
||||
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
||||
deltas.
|
||||
Eleven nation resource cases cover 100-unit rounding and minimum/maximum
|
||||
clamps for award and seizure; award against available gold, the base-rice
|
||||
reserve, and self-target rejection; and seizure against target holdings and
|
||||
self-target rejection. They compare exact target resource deltas, RNG,
|
||||
fallback, and semantic state deltas.
|
||||
Requests that omit the required argument object remain unverified because the
|
||||
reference runner did not terminate within the bounded comparison run.
|
||||
This is not yet a claim that every command-specific
|
||||
|
||||
@@ -16,7 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
|
||||
export interface TradeEnvironment {
|
||||
exchangeFee?: number;
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
|
||||
const ACTION_NAME = '증여';
|
||||
const ACTION_KEY = 'che_증여';
|
||||
|
||||
@@ -21,7 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
|
||||
const ACTION_NAME = '헌납';
|
||||
const ACTION_KEY = 'che_헌납';
|
||||
|
||||
@@ -24,13 +24,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo
|
||||
import { clamp } from 'es-toolkit';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||
|
||||
const ARGS_SCHEMA = z.object({
|
||||
isGold: z.boolean(),
|
||||
amount: z.preprocess(
|
||||
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
|
||||
z.number().int().positive()
|
||||
),
|
||||
amount: z.number(),
|
||||
destGeneralID: z.number(),
|
||||
});
|
||||
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||
@@ -56,9 +54,13 @@ export class ActionDefinition<
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount);
|
||||
if (amount === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000),
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -144,10 +144,10 @@ export class ActionResolver<
|
||||
createLogEffect(
|
||||
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -199,7 +199,7 @@ export class ActionDefinition<
|
||||
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
|
||||
}
|
||||
|
||||
if (ctx.destGeneralId === ctx.actorId) {
|
||||
if (args.destGeneralId === ctx.actorId) {
|
||||
return [denyWithReason('본인입니다')];
|
||||
}
|
||||
|
||||
|
||||
@@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => {
|
||||
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
|
||||
|
||||
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
|
||||
const lhs = target as any;
|
||||
const rhs = source as any;
|
||||
// The cast is type-only: JavaScript still applies its native relational
|
||||
// coercion rules to the original runtime values, matching the legacy
|
||||
// constraint evaluator without opting the whole comparison into `any`.
|
||||
const lhs = target as number;
|
||||
const rhs = source as number;
|
||||
switch (op) {
|
||||
case '<':
|
||||
return lhs < rhs;
|
||||
|
||||
@@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
||||
};
|
||||
};
|
||||
|
||||
const buildTraceUnitSnapshot = (
|
||||
unit: WarUnit,
|
||||
defenderCity: City
|
||||
): WarBattleTraceUnitSnapshot => {
|
||||
const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => {
|
||||
const common = {
|
||||
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
|
||||
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
|
||||
@@ -339,7 +336,6 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
);
|
||||
|
||||
const iter = defenderUnits.values();
|
||||
let defender: WarUnit<TriggerState> | null = null;
|
||||
|
||||
const getNextDefender = (
|
||||
_prevDefender: WarUnit<TriggerState> | null,
|
||||
@@ -359,7 +355,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
return candidate;
|
||||
};
|
||||
|
||||
defender = getNextDefender(null, true);
|
||||
let defender = getNextDefender(null, true);
|
||||
let conquerCity = false;
|
||||
let logWritten = false;
|
||||
let traceSeq = 0;
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('migrated general commands', () => {
|
||||
expect(updatedLord.experience).toBe(700);
|
||||
});
|
||||
|
||||
it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => {
|
||||
it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => {
|
||||
const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
|
||||
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
|
||||
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
|
||||
@@ -246,8 +246,8 @@ describe('migrated general commands', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(world.getGeneral(actor.id)!.gold).toBe(1000);
|
||||
expect(world.getGeneral(dest.id)!.gold).toBe(500);
|
||||
expect(world.getGeneral(actor.id)!.gold).toBe(800);
|
||||
expect(world.getGeneral(dest.id)!.gold).toBe(700);
|
||||
});
|
||||
|
||||
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
|
||||
|
||||
@@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => {
|
||||
|
||||
// 6. Retire (Needs age >= 60)
|
||||
// Manually set age
|
||||
// Manually set age
|
||||
const gToRetire = { ...g1_after_resign, age: 65 };
|
||||
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
|
||||
const retireDef = retireSpec.createDefinition(systemEnv);
|
||||
@@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => {
|
||||
const g1_after_retire = world.getGeneral(1)!;
|
||||
expect(g1_after_retire.age).toBe(20);
|
||||
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
|
||||
expect(g1_after_retire.experience).toBe(142);
|
||||
expect(g1_after_retire.experience).toBe(Math.round(gToRetire.experience * 0.5));
|
||||
});
|
||||
|
||||
it('should execute employ and sabotage commands', async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
|
||||
|
||||
const ignoredLifecyclePaths = [
|
||||
/^generalTurns/,
|
||||
/^nationTurns/,
|
||||
@@ -469,3 +471,159 @@ integration('nation command success matrix', () => {
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceAmountCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
args: Record<string, unknown>;
|
||||
expectedAmount: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'award rounds a half unit up',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'award clamps below the minimum',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'award clamps above the maximum',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'seizure rounds a half unit up',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'seizure clamps below the minimum',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'seizure clamps above the maximum',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation command resource amount normalization matrix', () => {
|
||||
it.each(nationResourceAmountCases)(
|
||||
'$name matches legacy rounding and clamp semantics',
|
||||
async ({ action, args, expectedAmount }) => {
|
||||
const request = buildRequest(action, args);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3);
|
||||
const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3);
|
||||
const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3);
|
||||
const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3);
|
||||
const referenceAmount =
|
||||
action === 'che_포상'
|
||||
? readGold(referenceTargetAfter) - readGold(referenceTargetBefore)
|
||||
: readGold(referenceTargetBefore) - readGold(referenceTargetAfter);
|
||||
const coreAmount =
|
||||
action === 'che_포상'
|
||||
? readGold(coreTargetAfter) - readGold(coreTargetBefore)
|
||||
: readGold(coreTargetBefore) - readGold(coreTargetAfter);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(referenceAmount).toBe(expectedAmount);
|
||||
expect(coreAmount).toBe(expectedAmount);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceBoundaryCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
args: Record<string, unknown>;
|
||||
fixturePatches?: FixturePatches;
|
||||
completed: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: 'award is limited to the available nation gold',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 10_000, destGeneralID: 3 },
|
||||
fixturePatches: { nations: { 1: { gold: 5_000 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'award keeps the legacy base rice reserve',
|
||||
action: 'che_포상',
|
||||
args: { isGold: false, amount: 10_000, destGeneralID: 3 },
|
||||
fixturePatches: { nations: { 1: { rice: 2_100 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'award rejects the actor as its target',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
name: 'seizure is limited to the target general gold',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 1_000, destGeneralID: 3 },
|
||||
fixturePatches: { generals: { 3: { gold: 50 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'seizure rejects the actor as its target',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation command resource balance and target boundaries', () => {
|
||||
it.each(nationResourceBoundaryCases)(
|
||||
'$name matches legacy completion, RNG, and state delta',
|
||||
async ({ action, args, fixturePatches, completed }) => {
|
||||
const request = buildRequest(action, args, fixturePatches);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: completed ? action : '휴식',
|
||||
usedFallback: !completed,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user