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; continue;
} }
let job: BattleSimJob | null = null; let job: BattleSimJob;
try { try {
job = JSON.parse(raw) as BattleSimJob; job = JSON.parse(raw) as BattleSimJob;
} catch { } catch {
+8 -6
View File
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; 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 { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.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 resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
const nation = await ctx.db.nation.findUnique({ const nation = await ctx.db.nation.findUnique({
where: { id: nationId }, where: { id: nationId },
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
return resolveNationPermission(general, nation.meta, true); 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 === 'ACTIVATED') return 'ACTIVATED';
if (state === 'CANCELLED') return 'CANCELLED'; if (state === 'CANCELLED') return 'CANCELLED';
if (state === 'REPLACED') return 'REPLACED'; if (state === 'REPLACED') return 'REPLACED';
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
select: { id: true }, select: { id: true },
}); });
if (newer) { if (newer) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' }); throw new TRPCError({
code: 'BAD_REQUEST',
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
});
} }
if (prevLetter.state === 'PROPOSED') { 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({ const nations = await ctx.db.nation.findMany({
+8 -2
View File
@@ -1,13 +1,14 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js'; import { loadPublicMap } from '../../maps/worldMap.js';
import { 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 { loadTraitNames } from '../nation/shared.js';
import { z } from 'zod';
type WorldTrendSnapshot = { type WorldTrendSnapshot = {
year: number; year: number;
@@ -231,6 +232,11 @@ const sortNpcList = <T extends {
}); });
export const publicRouter = router({ 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 }) => { getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario); 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 procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
@@ -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 leadership = this.general.stats.leadership;
const strength = Math.max(this.general.stats.strength, 1); const strength = Math.max(this.general.stats.strength, 1);
const intel = Math.max(this.general.stats.intelligence, 1); const intel = Math.max(this.general.stats.intelligence, 1);
let genType = 0; let genType: number;
if (strength >= intel) { if (strength >= intel) {
genType = t무장; genType = t무장;
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
const force = ai.nationPolicy.combatForce[leader.id]; const force = ai.nationPolicy.combatForce[leader.id];
let [fromCityId, toCityId] = force; let [fromCityId, toCityId] = force;
let targetCityId: number | null = null; let targetCityId: number | null;
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) { if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
targetCityId = pickRandomCityId(ai, ai.frontCities); targetCityId = pickRandomCityId(ai, ai.frontCities);
} else { } else {
+6 -9
View File
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
originOutcome: number originOutcome: number
): { next: number; ratio: number; realOutcome: number } => { ): { next: number; ratio: number; realOutcome: number } => {
let next = current + income; let next = current + income;
let realOutcome = 0; let realOutcome: number;
if (next < baseResource) { if (next < baseResource) {
realOutcome = 0; realOutcome = 0;
next = baseResource; next = baseResource;
@@ -139,14 +139,11 @@ const processIncomeForNation = (
const trait = traitMap.get(nation.typeCode) ?? null; const trait = traitMap.get(nation.typeCode) ?? null;
const incomeContext = buildNationIncomeContext(nation, trait); const incomeContext = buildNationIncomeContext(nation, trait);
let income = 0; const income =
if (type === 'gold') { type === 'gold'
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); ? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
} else { : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
income = getWallIncome(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 incomeValue = roundResource(income);
const originOutcome = getOutcome(100, nationGenerals); const originOutcome = getOutcome(100, nationGenerals);
+36 -39
View File
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
sabotage, sabotage,
dex, dex,
unifier, 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 meta = asRecord(state.meta);
const serverId = const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
? meta.serverId.trim()
: options.profileName;
const season = readMetaNumberOrNull(meta, 'season') ?? 1; const season = readMetaNumberOrNull(meta, 'season') ?? 1;
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0; const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
const scenarioName = const scenarioName =
typeof asRecord(meta.scenarioMeta).title === 'string' typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
? String(asRecord(meta.scenarioMeta).title)
: '';
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = new Date().toISOString(); const unitedTime = new Date().toISOString();
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
}; };
for (const [typeName, valueType] of hallTypes) { for (const [typeName, valueType] of hallTypes) {
let value = 0; const value =
if (valueType === 'natural') { valueType === 'natural'
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0; ? typeName === 'experience'
} else if (valueType === 'rank') { ? general.experience
value = ranks[typeName] ?? 0; : typeName === 'dedication'
} else { ? general.dedication
value = calcValues[typeName] ?? 0; : (ranks[typeName] ?? 0)
} : valueType === 'rank'
? (ranks[typeName] ?? 0)
: (calcValues[typeName] ?? 0);
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) { if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
continue; continue;
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
const serverId = const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
? meta.serverId.trim()
: options.profileName;
const serverName = const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim() typeof meta.serverName === 'string' && meta.serverName.trim()
? meta.serverName.trim() ? meta.serverName.trim()
@@ -653,30 +650,30 @@ export const createUnificationHandler = (options: {
await Promise.all( await Promise.all(
oldGeneralTargets.map((general) => oldGeneralTargets.map((general) =>
((snapshot) => ((snapshot) =>
prisma.oldGeneral.upsert({ prisma.oldGeneral.upsert({
where: { where: {
by_no: { 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, serverId,
generalNo: general.id, 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, ...general,
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
}) })
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
const attackerG = getRankNumber(attacker, rankKey('g')); const attackerG = getRankNumber(attacker, rankKey('g'));
const defenderG = getRankNumber(defender, rankKey('g')); const defenderG = getRankNumber(defender, rankKey('g'));
let attackerGDelta = 0; let attackerGDelta: number;
let defenderGDelta = 0; let defenderGDelta: number;
let attackerW = 0; let attackerW = 0;
let attackerD = 0; let attackerD = 0;
let attackerL = 0; let attackerL = 0;
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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'; 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 updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
const nations = [ const nations = [
{ {
@@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
}) as never, }) as never,
}); });
handler.beforeMonthChanged?.({} as never); await handler.beforeMonthChanged?.({} as never);
expect(updates).toEqual([ expect(updates).toEqual([
{ {
@@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
// Nation awards can occur in the same tick and make the general's net // 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 // gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke. // covered by the unit-set/action contract tests rather than this smoke.
}, 60000); }, 300_000);
}); });
@@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
} }
throw error; throw error;
} }
}, 180000); }, 360_000);
}); });
+2
View File
@@ -13,5 +13,7 @@ export default defineConfig({
environment: 'node', environment: 'node',
globals: true, globals: true,
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
maxWorkers: 4,
testTimeout: 10_000,
}, },
}); });
+29 -8
View File
@@ -23,6 +23,12 @@ type FixtureState = {
permission: 'head' | 'member'; permission: 'head' | 'member';
myset: number; myset: number;
settingMutations: Array<Record<string, unknown>>; settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
};
type TrpcRequestPayload = {
json?: Record<string, unknown>;
input?: { json?: Record<string, unknown> };
}; };
const myGeneral = (state: FixtureState) => ({ const myGeneral = (state: FixtureState) => ({
@@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({
const install = async (page: Page, state: FixtureState) => { const install = async (page: Page, state: FixtureState) => {
await page.addInitScript(() => { 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'); localStorage.setItem('sammo-game-profile', 'che:default');
}); });
await page.route('**/image/game/**', async (route) => { 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) => { await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(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 === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
if (operation === 'join.getConfig') return response({}); if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(myGeneral(state)); if (operation === 'general.me') return response(myGeneral(state));
@@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => {
if (operation === 'general.getMyLog') if (operation === 'general.getMyLog')
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] }); return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
if (operation === 'general.setMySetting') { if (operation === 'general.setMySetting') {
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } }; state.settingMutations.push(jsonInput);
state.settingMutations.push(raw.input?.json ?? {});
state.myset = Math.max(0, state.myset - 1); state.myset = Math.max(0, state.myset - 1);
return response({ ok: true }); 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 (operation === 'nation.getBattleCenter') {
if (state.permission === 'member') { if (state.permission === 'member') {
return { return {
@@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => {
}; };
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { 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 install(page, state);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('traffic'); await page.goto('traffic');
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량'); 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 geometry = await page.locator('#traffic-container').evaluate((element) => {
const rect = element.getBoundingClientRect(); 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 }) => { 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 install(page, state);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('my-page'); await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible(); 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 desktop = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect(); 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 }) => { 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 install(page, head);
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('battle-center'); 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 persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
await page.unrouteAll({ behavior: 'wait' }); 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 install(page, member);
await page.reload(); await page.reload();
await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes'; import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
interface Props { interface Props {
general: GeneralDraft;
options: BattleSimOptions; options: BattleSimOptions;
mode: 'attacker' | 'defender'; mode: 'attacker' | 'defender';
title: string; title: string;
@@ -11,6 +10,7 @@ interface Props {
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const general = defineModel<GeneralDraft>('general', { required: true });
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'import'): void; (event: 'import'): void;
+38
View File
@@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue'; import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue'; import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session'; 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 = [ const routes = [
{ {
@@ -370,4 +398,14 @@ router.beforeEach(async (to) => {
return true; 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; export default router;
+2 -5
View File
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
return ''; return '';
} }
let match: RegExpExecArray | null = null;
let lastIndex = 0; let lastIndex = 0;
const result: string[] = []; 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 partAll = match[0];
const subPart = match[1]; const subPart = match[1];
const index = match.index; const index = match.index;
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
if (subPart === '/') { if (subPart === '/') {
result.push('</span>'); result.push('</span>');
} else if (subPart.length === 2) { } else if (subPart.length === 2) {
result.push( result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
);
} else { } else {
result.push(`<span style="${convertMap[subPart] ?? ''}">`); result.push(`<span style="${convertMap[subPart] ?? ''}">`);
} }
@@ -1125,7 +1125,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard <BattleGeneralCard
v-if="attackerGeneral" v-if="attackerGeneral"
:general="attackerGeneral!" v-model:general="attackerGeneral"
:options="options!" :options="options!"
mode="attacker" mode="attacker"
title="출병자 설정" title="출병자 설정"
@@ -1193,7 +1193,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard <BattleGeneralCard
v-for="(defender, index) in defenders" v-for="(defender, index) in defenders"
:key="defender.id" :key="defender.id"
:general="defender" v-model:general="defenders[index]"
:options="options!" :options="options!"
mode="defender" mode="defender"
:title="`수비자 설정 ${index + 1}`" :title="`수비자 설정 ${index + 1}`"
+93 -19
View File
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
} }
}; };
const prevOptions = computed(() => const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
);
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR'); 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'; editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
const canDestroy = (letter: DiplomacyLetter) => 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'; const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
onMounted(() => { onMounted(() => {
loadLetters(); void loadLetters();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
<div class="editor-group"> <div class="editor-group">
<div class="editor-label">내용(국가 공개)</div> <div class="editor-label">내용(국가 공개)</div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button> <button
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button> type="button"
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</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="addLink('brief')">링크</button>
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</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="briefEditor?.chain().focus().toggleOrderedList().run()">
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button> 번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'brief';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div> </div>
<EditorContent v-if="briefEditor" :editor="briefEditor" /> <EditorContent v-if="briefEditor" :editor="briefEditor" />
</div> </div>
<div class="editor-group"> <div class="editor-group">
<div class="editor-label">내용(외교권자 전용)</div> <div class="editor-label">내용(외교권자 전용)</div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button> <button
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button> type="button"
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</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="addLink('detail')">링크</button>
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</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="detailEditor?.chain().focus().toggleOrderedList().run()">
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button> 번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'detail';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div> </div>
<EditorContent v-if="detailEditor" :editor="detailEditor" /> <EditorContent v-if="detailEditor" :editor="detailEditor" />
</div> </div>
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
</button> </button>
<div v-if="historyOpen[letter.id]" class="history-panel"> <div v-if="historyOpen[letter.id]" class="history-panel">
<template v-if="getPrevLetter(letter)"> <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" /> <div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
</template> </template>
<p v-else class="hint">이전 문서를 찾을 없습니다.</p> <p v-else class="hint">이전 문서를 찾을 없습니다.</p>
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<footer class="letter-actions"> <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, true)">
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button> 승인
</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="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(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> </footer>
</article> </article>
</section> </section>
@@ -242,7 +242,7 @@ watch(
); );
onMounted(() => { onMounted(() => {
loadData(); void loadData();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
<div class="panel-header"> <div class="panel-header">
<h2>국가 방침</h2> <h2>국가 방침</h2>
<div class="panel-actions"> <div class="panel-actions">
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg"> <button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
수정 <button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
</button> <button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
저장
</button>
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
취소
</button>
</div> </div>
</div> </div>
<div v-if="editingNationMsg" class="editor-toolbar"> <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>
<button <button
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
<div class="panel-card"> <div class="panel-card">
<h3>자금 예산</h3> <h3>자금 예산</h3>
<dl> <dl>
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div> <div>
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div> <dt>현재</dt>
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div> <dd>{{ data.gold.toLocaleString() }}</dd>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div> </div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).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> </dl>
</div> </div>
<div class="panel-card"> <div class="panel-card">
<h3>군량 예산</h3> <h3>군량 예산</h3>
<dl> <dl>
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div> <div>
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div> <dt>현재</dt>
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div> <dd>{{ data.rice.toLocaleString() }}</dd>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div> </div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).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> </dl>
</div> </div>
<div class="panel-card"> <div class="panel-card">
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
<div class="panel-card"> <div class="panel-card">
<h3>기밀 권한</h3> <h3>기밀 권한</h3>
<div class="input-row"> <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> <span></span>
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button> <button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
</div> </div>
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
/> />
전쟁 금지 전쟁 금지
</label> </label>
<span class="hint">잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span> <span class="hint"
>잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span
>
</div> </div>
</div> </div>
<div class="panel-card"> <div class="panel-card">
@@ -134,7 +134,7 @@ watch(
); );
onMounted(() => { onMounted(() => {
loadData(); void loadData();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
</div> </div>
<div v-if="editing" class="editor-toolbar"> <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>
<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. available and minimum resources; and gift reserve and self-target rejection.
They compare normalized last-turn arguments, RNG, fallback, and semantic state They compare normalized last-turn arguments, RNG, fallback, and semantic state
deltas. 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 Requests that omit the required argument object remain unverified because the
reference runner did not terminate within the bounded comparison run. reference runner did not terminate within the bounded comparison run.
This is not yet a claim that every command-specific 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js'; import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js'; import { normalizeResourceActionAmount } from '../resourceAmount.js';
export interface TradeEnvironment { export interface TradeEnvironment {
exchangeFee?: number; 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js'; import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js'; import { normalizeResourceActionAmount } from '../resourceAmount.js';
const ACTION_NAME = '증여'; const ACTION_NAME = '증여';
const ACTION_KEY = 'che_증여'; 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js'; import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js'; import { normalizeResourceActionAmount } from '../resourceAmount.js';
const ACTION_NAME = '헌납'; const ACTION_NAME = '헌납';
const ACTION_KEY = 'che_헌납'; const ACTION_KEY = 'che_헌납';
@@ -24,13 +24,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo
import { clamp } from 'es-toolkit'; import { clamp } from 'es-toolkit';
import { z } from 'zod'; import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js'; import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
const ARGS_SCHEMA = z.object({ const ARGS_SCHEMA = z.object({
isGold: z.boolean(), isGold: z.boolean(),
amount: z.preprocess( amount: z.number(),
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
z.number().int().positive()
),
destGeneralID: z.number(), destGeneralID: z.number(),
}); });
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>; export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -56,9 +54,13 @@ export class ActionDefinition<
if (!data) { if (!data) {
return null; return null;
} }
const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount);
if (amount === null) {
return null;
}
return { return {
...data, ...data,
amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000), amount,
}; };
} }
@@ -144,10 +144,10 @@ export class ActionResolver<
createLogEffect( createLogEffect(
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`, `<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
{ {
scope: LogScope.GENERAL, scope: LogScope.GENERAL,
category: LogCategory.ACTION, category: LogCategory.ACTION,
format: LogFormat.MONTH, format: LogFormat.MONTH,
} }
) )
); );
@@ -199,7 +199,7 @@ export class ActionDefinition<
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId }); requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
} }
if (ctx.destGeneralId === ctx.actorId) { if (args.destGeneralId === ctx.actorId) {
return [denyWithReason('본인입니다')]; return [denyWithReason('본인입니다')];
} }
+5 -2
View File
@@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => {
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!=='; export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => { export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
const lhs = target as any; // The cast is type-only: JavaScript still applies its native relational
const rhs = source as any; // 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) { switch (op) {
case '<': case '<':
return lhs < rhs; return lhs < rhs;
+2 -6
View File
@@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
}; };
}; };
const buildTraceUnitSnapshot = ( const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => {
unit: WarUnit,
defenderCity: City
): WarBattleTraceUnitSnapshot => {
const common = { const common = {
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const), kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(), 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(); const iter = defenderUnits.values();
let defender: WarUnit<TriggerState> | null = null;
const getNextDefender = ( const getNextDefender = (
_prevDefender: WarUnit<TriggerState> | null, _prevDefender: WarUnit<TriggerState> | null,
@@ -359,7 +355,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
return candidate; return candidate;
}; };
defender = getNextDefender(null, true); let defender = getNextDefender(null, true);
let conquerCity = false; let conquerCity = false;
let logWritten = false; let logWritten = false;
let traceSeq = 0; let traceSeq = 0;
@@ -225,7 +225,7 @@ describe('migrated general commands', () => {
expect(updatedLord.experience).toBe(700); 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 actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 }); const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); 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(actor.id)!.gold).toBe(800);
expect(world.getGeneral(dest.id)!.gold).toBe(500); expect(world.getGeneral(dest.id)!.gold).toBe(700);
}); });
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => { it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
@@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => {
// 6. Retire (Needs age >= 60) // 6. Retire (Needs age >= 60)
// Manually set age // Manually set age
// Manually set age
const gToRetire = { ...g1_after_resign, age: 65 }; const gToRetire = { ...g1_after_resign, age: 65 };
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g)); world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
const retireDef = retireSpec.createDefinition(systemEnv); const retireDef = retireSpec.createDefinition(systemEnv);
@@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => {
const g1_after_retire = world.getGeneral(1)!; const g1_after_retire = world.getGeneral(1)!;
expect(g1_after_retire.age).toBe(20); expect(g1_after_retire.age).toBe(20);
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다. // 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 () => { 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 workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); 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 = [ const ignoredLifecyclePaths = [
/^generalTurns/, /^generalTurns/,
/^nationTurns/, /^nationTurns/,
@@ -469,3 +471,159 @@ integration('nation command success matrix', () => {
120_000 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
);
});