Merge branch 'main' into feature/best-general-data-parity

This commit is contained in:
2026-07-26 06:03:32 +00:00
37 changed files with 858 additions and 155 deletions
+1 -1
View File
@@ -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 {
+9 -7
View File
@@ -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' };
}),
});
});
+8 -2
View File
@@ -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);
}),
+184
View File
@@ -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;
};
+4
View File
@@ -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 {
+6 -9
View File
@@ -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);
+36 -39
View File
@@ -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);
});
+2
View File
@@ -13,5 +13,7 @@ export default defineConfig({
environment: 'node',
globals: true,
include: ['test/**/*.test.ts'],
maxWorkers: 4,
testTimeout: 10_000,
},
});
+29 -8
View File
@@ -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;
+38
View File
@@ -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;
+2 -5
View File
@@ -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}`"
+94 -20
View File
@@ -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('본인입니다')];
}
+5 -2
View File
@@ -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;
+2 -6
View File
@@ -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
);
});