merge: 최신 main을 국가색 대비 전경색 작업에 통합

This commit is contained in:
2026-08-19 14:19:10 +00:00
17 changed files with 423 additions and 81 deletions
+3 -2
View File
@@ -810,7 +810,7 @@ export const generalRouter = router({
const [onlineAccess, ownNation, latestVote] = await Promise.all([ const [onlineAccess, ownNation, latestVote] = await Promise.all([
ctx.db.generalAccessLog.findMany({ ctx.db.generalAccessLog.findMany({
where: { where: {
lastRefresh: { lastActionAt: {
gte: scoreStartedAt, gte: scoreStartedAt,
}, },
}, },
@@ -868,8 +868,9 @@ export const generalRouter = router({
onlineByNation.set(general.nationId, bucket); onlineByNation.set(general.nationId, bucket);
} }
const onlineNations = [...onlineByNation.entries()] const onlineNations = [...onlineByNation.entries()]
.filter(([nationId]) => nationId > 0)
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0]) .sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
.map(([nationId]) => `${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}`) .map(([nationId]) => `${nationNames.get(nationId) ?? `세력 ${nationId}`}`)
.join(', '); .join(', ');
const myOnlineGenerals = onlineGenerals const myOnlineGenerals = onlineGenerals
.filter((general) => general.nationId === me.nationId) .filter((general) => general.nationId === me.nationId)
@@ -0,0 +1,44 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
/**
* Records a completed, authenticated user mutation without changing the Ref
* refresh counters. Page loads and read-model refreshes never call this path.
*/
export const recordGeneralActivity = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
now = new Date()
): Promise<boolean> => {
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return false;
}
const written = await ctx.db.$executeRaw(
GamePrisma.sql`
INSERT INTO general_access_log (
general_id,
user_id,
last_action_at
)
SELECT
id,
${user.id},
${now}
FROM "general"
WHERE user_id = ${user.id}
ORDER BY id ASC
LIMIT 1
ON CONFLICT (general_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
last_action_at = GREATEST(
general_access_log.last_action_at,
EXCLUDED.last_action_at
)
`
);
return written > 0;
};
+54 -10
View File
@@ -16,6 +16,7 @@ import {
type GeneralAccessEndpoint, type GeneralAccessEndpoint,
} from './services/generalAccess.js'; } from './services/generalAccess.js';
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js'; import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
import { recordGeneralActivity } from './services/generalActivity.js';
const t = initTRPC.context<GameApiContext>().create(); const t = initTRPC.context<GameApiContext>().create();
@@ -41,6 +42,21 @@ const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
}); });
}); });
const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
const result = await next();
if (type !== 'mutation' || !result.ok || ctx.generalAccessTracking !== true) {
return result;
}
try {
await recordGeneralActivity(ctx);
} catch {
// 활동 표시는 업무 transaction보다 약한 보조 기록이다. 배포 중 schema
// 전환이나 일시 DB 오류가 이미 완료된 사용자 mutation을 실패시키지 않는다.
}
return result;
});
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) { if (type !== 'mutation' || !ctx.db.$transaction) {
return next(); return next();
@@ -146,7 +162,10 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router; 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 = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -154,16 +173,20 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle
export const accessAuthedProcedure: typeof procedure = t.procedure export const accessAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware) .use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware); .use(inputEventMiddleware);
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로 // 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더 // 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다. // 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); export const engineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware);
export const engineProcedure: typeof procedure = t.procedure; export const engineProcedure: typeof procedure = t.procedure;
export const accessEngineAuthedProcedure: typeof procedure = t.procedure export const accessEngineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware); .use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조 // 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다. // mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
@@ -171,22 +194,43 @@ 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)
.use(generalActivityMiddleware);
export const accessLimitAuthedProcedure: typeof procedure = t.procedure export const accessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalAccessLimitMiddleware); .use(generalAccessLimitMiddleware)
.use(generalActivityMiddleware);
export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(deferredGeneralAccessLimitMiddleware); .use(deferredGeneralAccessLimitMiddleware)
.use(generalActivityMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를 // 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다. // 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) => export const accessInputProcedure: typeof procedure.input = (input) =>
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware); t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
export const accessAuthedInputProcedure: typeof procedure.input = (input) => export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware); t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) => export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware); t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware);
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) => export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware); t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware);
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) => export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware); t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessLimitMiddleware)
.use(generalActivityMiddleware);
@@ -427,9 +427,7 @@ integration('general access tracking persistence', () => {
if (!initialContext?.sourceRevision) { if (!initialContext?.sourceRevision) {
throw new Error('dashboard snapshot did not include its source revision'); throw new Error('dashboard snapshot did not include its source revision');
} }
await expect( await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })
).resolves.toBeNull();
expect(redisEval).toHaveBeenCalledTimes(1); expect(redisEval).toHaveBeenCalledTimes(1);
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [ await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
@@ -482,15 +480,22 @@ integration('general access tracking persistence', () => {
).resolves.toMatchObject({ ).resolves.toMatchObject({
refresh: 2, refresh: 2,
refreshTotal: 2, refreshTotal: 2,
lastActionAt: null,
}); });
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true }); await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
await expect( const completedActionAccess = await db.generalAccessLog.findUniqueOrThrow({
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) where: { generalId: endpointGeneralId },
).resolves.toMatchObject({ });
expect(completedActionAccess).toMatchObject({
refresh: 2, refresh: 2,
refreshTotal: 2, refreshTotal: 2,
}); });
expect(completedActionAccess.lastActionAt).toBeInstanceOf(Date);
await expect(dashboardCaller.general.getFrontStatus()).resolves.toMatchObject({
onlineNations: expect.not.stringContaining('재야'),
onlineGenerals: expect.stringContaining('접속경계'),
});
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({ await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@@ -501,6 +506,7 @@ integration('general access tracking persistence', () => {
).resolves.toMatchObject({ ).resolves.toMatchObject({
refresh: 3, refresh: 3,
refreshTotal: 3, refreshTotal: 3,
lastActionAt: completedActionAccess.lastActionAt,
}); });
const adminCaller = endpointBoundaryRouter.createCaller({ const adminCaller = endpointBoundaryRouter.createCaller({
@@ -0,0 +1,71 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext } from '../src/context.js';
import { authedProcedure, router, sessionActivityProcedure } from '../src/trpc.js';
const auth = (roles: string[] = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-08-19T00:00:00.000Z',
expiresAt: '2026-08-20T00:00:00.000Z',
sessionId: 'activity-session',
user: {
id: 'activity-user',
username: 'activity-user',
displayName: '활동 사용자',
roles,
},
sanctions: {},
});
const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token = auth()) =>
({
auth: token,
db: { $executeRaw: executeRaw },
generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' },
}) as unknown as GameApiContext;
const activityRouter = router({
read: authedProcedure.query(() => 'read'),
act: authedProcedure.mutation(() => 'acted'),
rejected: authedProcedure.mutation(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'rejected' });
}),
pageRefresh: sessionActivityProcedure.mutation(() => 'refreshed'),
});
describe('general action tracking', () => {
it('records only a completed authenticated mutation, not reads, page refreshes, or rejected actions', async () => {
const executeRaw = vi.fn(async (_query: unknown) => 1);
const caller = activityRouter.createCaller(buildContext(executeRaw));
await expect(caller.read()).resolves.toBe('read');
await expect(caller.pageRefresh()).resolves.toBe('refreshed');
await expect(caller.rejected()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(executeRaw).not.toHaveBeenCalled();
await expect(caller.act()).resolves.toBe('acted');
expect(executeRaw).toHaveBeenCalledTimes(1);
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('INSERT INTO general_access_log');
expect(statement.sql).toContain('last_action_at');
expect(statement.sql).toContain('FROM "general"');
expect(statement.values).toContain('activity-user');
});
it('does not mark admin mutations and never overturns a completed action when presence persistence fails', async () => {
const adminWrite = vi.fn(async (_query: unknown) => 1);
await expect(activityRouter.createCaller(buildContext(adminWrite, auth(['admin']))).act()).resolves.toBe(
'acted'
);
expect(adminWrite).not.toHaveBeenCalled();
const failedWrite = vi.fn(async (_query: unknown) => Promise.reject(new Error('presence unavailable')));
await expect(activityRouter.createCaller(buildContext(failedWrite)).act()).resolves.toBe('acted');
expect(failedWrite).toHaveBeenCalledTimes(1);
});
});
@@ -34,6 +34,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
{ id: 7, name: '유비', nationId: 2 }, { id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 }, { id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 }, { id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]), ]),
}, },
worldState: { worldState: {
@@ -46,7 +47,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
})), })),
}, },
generalAccessLog: { generalAccessLog: {
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]), findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }, { generalId: 10 }]),
}, },
nation: { nation: {
findUnique: vi.fn(async () => ({ findUnique: vi.fn(async () => ({
@@ -81,7 +82,7 @@ describe('general.getFrontStatus', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => { it('returns action-based current-turn online data without listing the free nation', async () => {
const context = buildContext(); const context = buildContext();
const caller = appRouter.createCaller(context); const caller = appRouter.createCaller(context);
@@ -89,7 +90,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({ expect(result).toEqual({
serverId: 'che_260819_front', serverId: 'che_260819_front',
onlineUserCount: 3, onlineUserCount: 4,
onlineNations: '【촉】, 【위】', onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우', onlineGenerals: '유비, 관우',
nationNotice: '<p>북벌 준비</p>', nationNotice: '<p>북벌 준비</p>',
@@ -102,7 +103,7 @@ describe('general.getFrontStatus', () => {
}); });
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({ expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
where: { where: {
lastRefresh: { lastActionAt: {
gte: new Date('2026-07-26T10:00:00.000Z'), gte: new Date('2026-07-26T10:00:00.000Z'),
}, },
}, },
+84 -2
View File
@@ -63,6 +63,7 @@ type FixtureState = {
joinConfig?: Record<string, unknown>; joinConfig?: Record<string, unknown>;
createGeneralInputs?: Array<Record<string, unknown>>; createGeneralInputs?: Array<Record<string, unknown>>;
mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
hiddenSeedLogText?: string;
recentRecords?: { recentRecords?: {
global: Array<{ id: number; text: string; createdAt?: string }>; global: Array<{ id: number; text: string; createdAt?: string }>;
general: Array<{ id: number; text: string; createdAt?: string }>; general: Array<{ id: number; text: string; createdAt?: string }>;
@@ -484,7 +485,10 @@ const install = async (page: Page, state: FixtureState) => {
}); });
if (operation === 'general.getMyLog') { if (operation === 'general.getMyLog') {
state.generalLogQueries = (state.generalLogQueries ?? 0) + 1; state.generalLogQueries = (state.generalLogQueries ?? 0) + 1;
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] }); return response({
type: 'generalAction',
logs: [{ id: 1, text: state.hiddenSeedLogText ?? '<Y>기록</>' }],
});
} }
if (operation === 'general.instantRetreat') { if (operation === 'general.instantRetreat') {
state.instantRetreatInputs?.push(jsonInput); state.instantRetreatInputs?.push(jsonInput);
@@ -560,7 +564,11 @@ const install = async (page: Page, state: FixtureState) => {
['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type) ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type)
? jsonInput.type ? jsonInput.type
: 'generalAction'; : 'generalAction';
return response({ type, generalId: 7, logs: [{ id: 1, text: `<Y>${type} 감찰 기록</>` }] }); return response({
type,
generalId: 7,
logs: [{ id: 1, text: state.hiddenSeedLogText ?? `<Y>${type} 감찰 기록</>` }],
});
} }
return response({ ok: true }); return response({ ok: true });
}); });
@@ -819,6 +827,80 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
}); });
test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => {
const seedText = '(전투시드: 0123456789abcdef)';
const logText =
'<D><b>위</b></>의 <Y>검증장수</>가 <G><b>낙양</b></>으로 ' +
`진격합니다.<span class="hidden_but_copyable">${seedText}</span>`;
const state: FixtureState = {
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
hiddenSeedLogText: logText,
recentRecords: {
global: [{ id: 18611, text: logText }],
general: [],
history: [],
},
};
await install(page, state);
const inspectHiddenSeed = async (selector: string) => {
const seed = page.locator(selector);
await expect(seed).toHaveCount(1);
return seed.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const result = {
text: element.textContent,
selectedText: selection?.toString(),
color: style.color,
fontSize: style.fontSize,
width: rect.width,
height: rect.height,
};
selection?.removeAllRanges();
return result;
});
};
const assertHiddenSeed = (result: Awaited<ReturnType<typeof inspectHiddenSeed>>) => {
expect(result.text).toBe(seedText);
expect(result.selectedText).toBe(seedText);
expect(result.color).toBe('rgba(0, 0, 0, 0)');
expect(result.fontSize).toBe('0px');
expect(result.width).toBe(0);
expect(result.height).toBe(0);
};
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto('');
const mainSeed = await inspectHiddenSeed('[data-record-bucket="global"] .hidden_but_copyable');
assertHiddenSeed(mainSeed);
await persistParityArtifact(page, `core-hidden-battle-seed-main-${viewport.width}`, mainSeed);
await page.goto('my-page');
const myPageSeed = await inspectHiddenSeed('.log-panel:first-child .hidden_but_copyable');
assertHiddenSeed(myPageSeed);
await persistParityArtifact(page, `core-hidden-battle-seed-my-page-${viewport.width}`, myPageSeed);
await page.goto('battle-center');
const battleCenterSeed = await inspectHiddenSeed('[data-log-type="generalAction"] .hidden_but_copyable');
assertHiddenSeed(battleCenterSeed);
await persistParityArtifact(page, `core-hidden-battle-seed-battle-center-${viewport.width}`, battleCenterSeed);
}
});
test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => { test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', permission: 'head',
+11
View File
@@ -88,6 +88,17 @@ textarea {
display: inline-block; display: inline-block;
} }
/*
* Ref's common stylesheet keeps diagnostic seeds in the DOM so they remain
* copyable, while removing them from every normal log rendering surface.
* This marker can arrive through main, personal, audit, and history log APIs,
* so the display contract belongs to the app-wide legacy markup boundary.
*/
.hidden_but_copyable {
color: rgba(0, 0, 0, 0) !important;
font-size: 0;
}
.small_war_log .war_type_attack { .small_war_log .war_type_attack {
color: cyan; color: cyan;
} }
@@ -84,11 +84,6 @@ const unavailableText: Record<GeneralRecordType, string> = {
border-bottom: 0; border-bottom: 0;
} }
.log-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.empty { .empty {
padding: 2px 8px; padding: 2px 8px;
color: #999; color: #999;
-5
View File
@@ -763,11 +763,6 @@ button {
vertical-align: top; vertical-align: top;
} }
.record-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.record-empty { .record-empty {
color: #aaa; color: #aaa;
} }
+2 -2
View File
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260818001000_add_game_cancellation_operation', gatewaySchemaHead: '20260819000000_backfill_profile_release_source',
gameSchemaHead: '20260818010000_add_legacy_battle_result_logs', gameSchemaHead: '20260819000000_add_general_last_action',
}); });
}); });
+7
View File
@@ -137,6 +137,13 @@ REF_PERSONAL_WAR_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \
`MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed `MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed
style JSON을 남깁니다. style JSON을 남깁니다.
같은 Ref collector는 “진격합니다.” 전투 seed marker도 함께 렌더링하여
desktop/mobile의 투명색, 0px 글자 크기, 0×0 rect와 selection text 보존을
수집합니다. Core의 대응 test는 `inGameMenus.spec.ts`의 “전투시드는 메인·내
정보·감찰부에서 숨긴 채 선택할 수 있다”이며 `PLAYWRIGHT_FRONTEND_MODE=production`
`MENU_PARITY_ARTIFACT_DIR`를 함께 지정하면 세 화면의 1000×900·500×900
screenshot과 computed JSON을 남깁니다.
개인 전투 결과 요약의 Ref 색상·글자 크기는 실제 `small_war_log` class 구조와 개인 전투 결과 요약의 Ref 색상·글자 크기는 실제 `small_war_log` class 구조와
빌드 CSS를 사용하는 별도 정적 Chromium fixture로 재현합니다. 방어 화살표, 빌드 CSS를 사용하는 별도 정적 Chromium fixture로 재현합니다. 방어 화살표,
장수명 괄호, 병력 수치와 병종·장수/병력 글자 크기를 desktop/mobile에서 장수명 괄호, 병력 수치와 병종·장수/병력 글자 크기를 desktop/mobile에서
+2
View File
@@ -271,12 +271,14 @@ model GeneralAccessLog {
generalId Int @unique @map("general_id") generalId Int @unique @map("general_id")
userId String? @map("user_id") userId String? @map("user_id")
lastRefresh DateTime? @map("last_refresh") lastRefresh DateTime? @map("last_refresh")
lastActionAt DateTime? @map("last_action_at")
refresh Int @default(0) refresh Int @default(0)
refreshTotal Int @default(0) @map("refresh_total") refreshTotal Int @default(0) @map("refresh_total")
refreshScore Int @default(0) @map("refresh_score") refreshScore Int @default(0) @map("refresh_score")
refreshScoreTotal Int @default(0) @map("refresh_score_total") refreshScoreTotal Int @default(0) @map("refresh_score_total")
@@index([userId]) @@index([userId])
@@index([lastActionAt])
@@map("general_access_log") @@map("general_access_log")
} }
@@ -0,0 +1,5 @@
ALTER TABLE "general_access_log"
ADD COLUMN "last_action_at" TIMESTAMP(3);
CREATE INDEX "general_access_log_last_action_at_idx"
ON "general_access_log"("last_action_at");
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source", "gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs", "gameSchemaHead": "20260819000000_add_general_last_action",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }
@@ -16,7 +16,8 @@ const accessToken = `ga_${randomUUID()}`;
let postgres: ReturnType<typeof createGamePostgresConnector>; let postgres: ReturnType<typeof createGamePostgresConnector>;
let redis: RedisConnector; let redis: RedisConnector;
let prisma: GamePrismaClient; let prisma: GamePrismaClient;
let fixture: { generalId: number; nationId: number; userId: string; voteId: number } | null = null; let fixture: { generalId: number; freeGeneralId: number; nationId: number; userId: string; voteId: number } | null =
null;
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`; const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
const response = (data: unknown) => ({ result: { data } }); const response = (data: unknown) => ({ result: { data } });
@@ -49,7 +50,9 @@ test.beforeAll(async () => {
]); ]);
const nationId = (nationMax._max.id ?? 0) + 10_000; const nationId = (nationMax._max.id ?? 0) + 10_000;
const generalId = (generalMax._max.id ?? 0) + 10_000; const generalId = (generalMax._max.id ?? 0) + 10_000;
const freeGeneralId = generalId + 1;
const userId = `main-front-status-${randomUUID()}`; const userId = `main-front-status-${randomUUID()}`;
const freeUserId = `main-front-status-free-${randomUUID()}`;
await prisma.$executeRaw( await prisma.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO nation (id, name, color, meta) INSERT INTO nation (id, name, color, meta)
@@ -64,19 +67,35 @@ test.beforeAll(async () => {
await prisma.$executeRaw( await prisma.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time) INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time)
VALUES (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()}) VALUES
(${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()}),
(${freeGeneralId}, ${freeUserId}, ${'재야행동검증장수'}, ${0}, ${0}, ${new Date()})
` `
); );
await prisma.generalAccessLog.create({ const actionAt = new Date();
data: { await prisma.generalAccessLog.createMany({
data: [
{
generalId, generalId,
userId, userId,
lastRefresh: new Date(), lastRefresh: actionAt,
lastActionAt: actionAt,
refresh: 1, refresh: 1,
refreshTotal: 1, refreshTotal: 1,
refreshScore: 1, refreshScore: 1,
refreshScoreTotal: 1, refreshScoreTotal: 1,
}, },
{
generalId: freeGeneralId,
userId: freeUserId,
lastRefresh: actionAt,
lastActionAt: actionAt,
refresh: 1,
refreshTotal: 1,
refreshScore: 1,
refreshScoreTotal: 1,
},
],
}); });
const poll = await prisma.votePoll.create({ const poll = await prisma.votePoll.create({
data: { data: {
@@ -92,7 +111,7 @@ test.beforeAll(async () => {
}, },
select: { id: true }, select: { id: true },
}); });
fixture = { generalId, nationId, userId, voteId: poll.id }; fixture = { generalId, freeGeneralId, nationId, userId, voteId: poll.id };
const issuedAt = new Date(); const issuedAt = new Date();
await redis.client.set( await redis.client.set(
@@ -118,14 +137,15 @@ test.beforeAll(async () => {
test.afterAll(async () => { test.afterAll(async () => {
if (fixture) { if (fixture) {
await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } }); await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } });
await prisma.generalAccessLog.deleteMany({ where: { generalId: fixture.generalId } }); const generalIds = [fixture.generalId, fixture.freeGeneralId];
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM general WHERE id = ${fixture.generalId}`); await prisma.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
await prisma.general.deleteMany({ where: { id: { in: generalIds } } });
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`); await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`);
const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([ const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([
prisma.general.count({ where: { id: fixture.generalId } }), prisma.general.count({ where: { id: { in: generalIds } } }),
prisma.nation.count({ where: { id: fixture.nationId } }), prisma.nation.count({ where: { id: fixture.nationId } }),
prisma.votePoll.count({ where: { id: fixture.voteId } }), prisma.votePoll.count({ where: { id: fixture.voteId } }),
prisma.generalAccessLog.count({ where: { generalId: fixture.generalId } }), prisma.generalAccessLog.count({ where: { generalId: { in: generalIds } } }),
]); ]);
expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]); expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]);
} }
@@ -146,17 +166,7 @@ const installMainFixture = async (
}, },
failStatus: () => boolean failStatus: () => boolean
) => { ) => {
await page.addInitScript( const generalContext = {
({ token }) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', 'che:default');
},
{ token: accessToken }
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'general.me') {
return response({
general: { general: {
id: fixture?.generalId ?? 1, id: fixture?.generalId ?? 1,
name: '현황검증장수', name: '현황검증장수',
@@ -182,8 +192,38 @@ const installMainFixture = async (
nation: null, nation: null,
settings: {}, settings: {},
penalties: {}, penalties: {},
};
await page.addInitScript(
({ token }) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', 'che:default');
},
{ token: accessToken }
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'dashboard.getContextBundleDelta') {
return response({
context: {
kind: 'snapshot',
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
data: generalContext,
},
commandTable: {
kind: 'snapshot',
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
data: { general: [], nation: [] },
},
boardAccess: {
kind: 'snapshot',
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
data: { canMeeting: false, canSecret: false, permission: 0 },
},
}); });
} }
if (operation === 'general.me') {
return response(generalContext);
}
if (operation === 'general.getFrontStatus') { if (operation === 'general.getFrontStatus') {
return failStatus() return failStatus()
? errorResponse(operation, '상단 현황을 불러오지 못했습니다.') ? errorResponse(operation, '상단 현황을 불러오지 못했습니다.')
@@ -214,6 +254,15 @@ const installMainFixture = async (
levelMap: { 7: '수도' }, levelMap: { 7: '수도' },
}); });
} }
if (operation === 'world.getState') {
return response({
currentYear: 190,
currentMonth: 1,
tickSeconds: 60,
config: { npcMode: 0, const: {}, environment: {} },
meta: {},
});
}
if (operation === 'world.getMap') { if (operation === 'world.getMap') {
return response({ return response({
year: 190, year: 190,
@@ -280,6 +329,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
}; };
} }
).result.data; ).result.data;
expect(liveStatus.onlineUserCount).toBe(2);
expect(liveStatus.onlineNations).toContain('【검증국】');
expect(liveStatus.onlineNations).not.toContain('재야');
expect(liveStatus.onlineGenerals).toContain('현황검증장수'); expect(liveStatus.onlineGenerals).toContain('현황검증장수');
expect(liveStatus.nationNotice).toContain(marker); expect(liveStatus.nationNotice).toContain(marker);
expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false }); expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false });
@@ -337,7 +389,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0); expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0); expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
expect(desktop.voteRow.height).toBeCloseTo(36, 0); expect(desktop.voteRow.height).toBeCloseTo(36, 0);
expect(desktop.backgroundImage).toContain('/image/game/back_walnut.jpg'); expect(desktop.backgroundImage).toContain('/game/back_walnut.jpg');
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(status).toHaveCSS('width', '500px'); await expect(status).toHaveCSS('width', '500px');
@@ -349,7 +401,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0); for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0);
failStatus = true; failStatus = true;
await page.getByRole('button', { name: '새로고침', exact: true }).click(); await page.getByRole('button', { name: '갱 신', exact: true }).click();
await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible(); await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible();
await expect(status).toContainText(marker); await expect(status).toContainText(marker);
@@ -12,6 +12,8 @@ const css = await readFile(resolve(refRoot, 'dist_js/hwe_dynamic/vue/v_main.css'
const records = [ const records = [
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다. <1>12:54</>', '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다. <1>12:54</>',
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다. <1>12:55</>', '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다. <1>12:55</>',
'<D><b>위</b></>의 <Y>검증장수</>가 <G><b>낙양</b></>으로 진격합니다.' +
'<span class="hidden_but_copyable">(전투시드: 0123456789abcdef)</span>',
]; ];
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
@@ -43,6 +45,29 @@ try {
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')); const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
const name = spans.find((span) => span.textContent === 'ⓝ염행'); const name = spans.find((span) => span.textContent === 'ⓝ염행');
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격'); const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
const hiddenSeed = element.querySelector('.hidden_but_copyable');
if (hiddenSeed instanceof HTMLElement) {
const range = document.createRange();
range.selectNodeContents(hiddenSeed);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const hiddenSeedRect = hiddenSeed.getBoundingClientRect();
const hiddenSeedStyle = getComputedStyle(hiddenSeed);
const result = {
text: hiddenSeed.textContent,
selectedText: selection?.toString(),
color: hiddenSeedStyle.color,
fontSize: hiddenSeedStyle.fontSize,
width: hiddenSeedRect.width,
height: hiddenSeedRect.height,
};
selection?.removeAllRanges();
return {
text: element.textContent,
hiddenSeed: result,
};
}
if ( if (
!(time instanceof HTMLElement) || !(time instanceof HTMLElement) ||
!(name instanceof HTMLElement) || !(name instanceof HTMLElement) ||
@@ -62,6 +87,7 @@ try {
timeFontSize: getComputedStyle(time).fontSize, timeFontSize: getComputedStyle(time).fontSize,
nameFontSize: getComputedStyle(name).fontSize, nameFontSize: getComputedStyle(name).fontSize,
actionFontSize: getComputedStyle(action).fontSize, actionFontSize: getComputedStyle(action).fontSize,
hiddenSeed: null,
}; };
}) })
); );