merge: 최신 core2026 main을 접속 제한 작업에 통합
This commit is contained in:
@@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
|
||||
export const lobbyRouter = router({
|
||||
@@ -26,6 +27,7 @@ export const lobbyRouter = router({
|
||||
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
||||
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
||||
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
|
||||
let myGeneral = null;
|
||||
if (ctx.auth?.user.id) {
|
||||
@@ -54,6 +56,8 @@ export const lobbyRouter = router({
|
||||
starttime: worldState.meta.starttime ?? '',
|
||||
opentime: worldState.meta.opentime ?? '',
|
||||
turntime: worldState.meta.turntime ?? '',
|
||||
serverTime: gameTime.now.toISOString(),
|
||||
clockMode: gameTime.mode ?? 'realtime',
|
||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
|
||||
@@ -119,6 +119,7 @@ describe('buildTurnCommandTable', () => {
|
||||
'che_단련',
|
||||
'che_숙련전환',
|
||||
'che_견문',
|
||||
'che_은퇴',
|
||||
'che_장비매매',
|
||||
'che_군량매매',
|
||||
'che_내정특기초기화',
|
||||
@@ -135,9 +136,67 @@ describe('buildTurnCommandTable', () => {
|
||||
'che_주민선정',
|
||||
],
|
||||
군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
|
||||
인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||
인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||
계략: ['che_화계'],
|
||||
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||
국가: [
|
||||
'che_증여',
|
||||
'che_헌납',
|
||||
'che_물자조달',
|
||||
'che_하야',
|
||||
'che_거병',
|
||||
'che_건국',
|
||||
'che_선양',
|
||||
'che_해산',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||
buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general,
|
||||
city: buildCity(),
|
||||
nation,
|
||||
nationGenerals: null,
|
||||
});
|
||||
const findCommand = (table: Awaited<ReturnType<typeof buildTable>>, key: string) =>
|
||||
table.general.flatMap((group) => group.values).find((command) => command.key === key);
|
||||
|
||||
const ordinary = await buildTable(buildGeneral());
|
||||
expect(findCommand(ordinary, 'che_강행')).toMatchObject({
|
||||
name: '강행',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
inputFields: [{ key: 'destCityId', optionSource: 'cities' }],
|
||||
});
|
||||
expect(findCommand(ordinary, 'che_은퇴')).toMatchObject({
|
||||
name: '은퇴',
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '나이가 60세 이상이어야 합니다.',
|
||||
});
|
||||
expect(findCommand(ordinary, 'che_하야')).toMatchObject({
|
||||
name: '하야',
|
||||
possible: true,
|
||||
status: 'available',
|
||||
});
|
||||
|
||||
const oldEnough = await buildTable({ ...buildGeneral(), age: 60 } as GeneralRow);
|
||||
expect(findCommand(oldEnough, 'che_은퇴')).toMatchObject({ possible: true, status: 'available' });
|
||||
|
||||
const ruler = await buildTable({ ...buildGeneral(), officerLevel: 12 } as GeneralRow);
|
||||
expect(findCommand(ruler, 'che_하야')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: expect.stringContaining('군주'),
|
||||
});
|
||||
|
||||
const neutral = await buildTable({ ...buildGeneral(), nationId: 0, officerLevel: 0 } as GeneralRow, null);
|
||||
expect(findCommand(neutral, 'che_하야')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '재야입니다.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
||||
const buildContext = (
|
||||
meta: Record<string, unknown>,
|
||||
clock: {
|
||||
baseTime?: Date;
|
||||
tick?: bigint;
|
||||
mode?: string;
|
||||
wallAnchor?: Date;
|
||||
} = {}
|
||||
): GameApiContext =>
|
||||
({
|
||||
auth: null,
|
||||
db: {
|
||||
@@ -16,6 +24,10 @@ const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
||||
tickSeconds: 3_600,
|
||||
config: {},
|
||||
meta,
|
||||
clockBaseTime: clock.baseTime ?? null,
|
||||
clockTick: clock.tick ?? null,
|
||||
clockMode: clock.mode ?? 'realtime',
|
||||
clockWallAnchor: clock.wallAnchor ?? null,
|
||||
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||
})),
|
||||
},
|
||||
@@ -36,4 +48,23 @@ describe('lobby season state', () => {
|
||||
|
||||
expect(result.isUnited).toBe(isunited);
|
||||
});
|
||||
|
||||
it('returns the projected server game time and whether the clock is running', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||
tick: 72_000_000n,
|
||||
mode: 'manual',
|
||||
wallAnchor: new Date('2026-08-15T17:00:00.000Z'),
|
||||
}
|
||||
)
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||
expect(result.clockMode).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
||||
return new Date(baseTime + alignedSeconds * 1000);
|
||||
};
|
||||
|
||||
const resolveTurnTime = (
|
||||
rng: RandUtil,
|
||||
export const resolveJoinTurnTime = (
|
||||
rng: Pick<RandUtil, 'nextRangeInt'>,
|
||||
worldState: WorldStateRow,
|
||||
acceptedAt: Date,
|
||||
runtimeTurnTime: Date,
|
||||
@@ -348,7 +348,12 @@ const resolveTurnTime = (
|
||||
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||
} else {
|
||||
turnTimeBase = base;
|
||||
// Ref normally uses game_env.turntime as a near-current cursor. Core's
|
||||
// durable daemon can legitimately be catching up from an older cursor,
|
||||
// so scheduling from runtimeTurnTime may put a newly created general
|
||||
// hours behind the game clock. The accepted game time is the equivalent
|
||||
// current-time boundary for a new general.
|
||||
turnTimeBase = acceptedAt;
|
||||
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||
}
|
||||
@@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
}
|
||||
|
||||
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||
const turnTime = resolveTurnTime(
|
||||
const turnTime = resolveJoinTurnTime(
|
||||
rng,
|
||||
worldState,
|
||||
acceptedAt,
|
||||
|
||||
@@ -18,13 +18,16 @@ const GENERAL_AI_ACTIONS = [
|
||||
|
||||
const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const;
|
||||
const GENERAL_REF_EDITOR_ACTIONS = [
|
||||
'che_은퇴',
|
||||
'che_임관',
|
||||
'che_랜덤임관',
|
||||
'che_강행',
|
||||
'che_징병',
|
||||
'che_출병',
|
||||
'che_농지개간',
|
||||
'che_화계',
|
||||
'che_증여',
|
||||
'che_하야',
|
||||
'che_장비매매',
|
||||
] as const;
|
||||
const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildJoinCreateGeneralSeed,
|
||||
cutJoinTurnTime,
|
||||
JOIN_WELCOME_MESSAGE,
|
||||
resolveJoinTurnTime,
|
||||
} from '../src/turn/joinCreateGeneralService.js';
|
||||
|
||||
describe('generic join legacy time contracts', () => {
|
||||
@@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => {
|
||||
const calls: Array<[number, number]> = [];
|
||||
const values = [59, 250_000];
|
||||
const rng = {
|
||||
nextRangeInt(min: number, max: number) {
|
||||
calls.push([min, max]);
|
||||
return values.shift() ?? min;
|
||||
},
|
||||
};
|
||||
const acceptedAt = new Date('2026-08-15T17:57:05.837Z');
|
||||
const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z');
|
||||
|
||||
const turnTime = resolveJoinTurnTime(
|
||||
rng,
|
||||
{ tickSeconds: 120 } as Parameters<typeof resolveJoinTurnTime>[1],
|
||||
acceptedAt,
|
||||
staleRuntimeTurnTime,
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z');
|
||||
expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime());
|
||||
expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000);
|
||||
expect(calls).toEqual([
|
||||
[0, 119],
|
||||
[0, 999_999],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
|
||||
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
||||
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
||||
|
||||
@@ -294,7 +294,7 @@ const chiefCenter = {
|
||||
})),
|
||||
};
|
||||
|
||||
const install = async (page: Page, rejectGeneral = false) => {
|
||||
const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => {
|
||||
const requests: unknown[] = [];
|
||||
const generalTurns = turns(30);
|
||||
const nationTurns = turns(12);
|
||||
@@ -344,7 +344,7 @@ const install = async (page: Page, rejectGeneral = false) => {
|
||||
? {
|
||||
kind: 'snapshot',
|
||||
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||||
data: commandTable,
|
||||
data: commandTableResponse,
|
||||
}
|
||||
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
|
||||
boardAccess: initial
|
||||
@@ -400,7 +400,7 @@ const install = async (page: Page, rejectGeneral = false) => {
|
||||
myCity: 1,
|
||||
myNation: 1,
|
||||
});
|
||||
if (name === 'turns.getCommandTable') return response(commandTable);
|
||||
if (name === 'turns.getCommandTable') return response(commandTableResponse);
|
||||
if (name === 'nation.getChiefCenter') return response(chiefCenter);
|
||||
if (name === 'turns.reserved.getGeneral')
|
||||
return response({ turns: generalTurns, revision: generalRevision });
|
||||
@@ -461,6 +461,117 @@ const install = async (page: Page, rejectGeneral = false) => {
|
||||
return requests;
|
||||
};
|
||||
|
||||
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
|
||||
const specialCommandTable = {
|
||||
general: [
|
||||
{
|
||||
category: '개인',
|
||||
values: [
|
||||
{
|
||||
key: 'che_은퇴',
|
||||
name: '은퇴',
|
||||
reqArg: false,
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '나이가 60세 이상이어야 합니다.',
|
||||
inputFields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '인사',
|
||||
values: [
|
||||
{
|
||||
key: 'che_강행',
|
||||
name: '강행',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'available',
|
||||
inputFields: [
|
||||
{
|
||||
key: 'destCityId',
|
||||
label: '대상 도시',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
optionSource: 'cities',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '국가',
|
||||
values: [
|
||||
{
|
||||
key: 'che_하야',
|
||||
name: '하야',
|
||||
reqArg: false,
|
||||
possible: true,
|
||||
status: 'available',
|
||||
inputFields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
nation: [],
|
||||
inputOptions,
|
||||
};
|
||||
const requests = await install(page, false, specialCommandTable);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/');
|
||||
|
||||
const editor = page.locator('[data-command-scope="general"]');
|
||||
|
||||
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
let picker = page.getByTestId('command-picker');
|
||||
const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
|
||||
await expect(retirement).toHaveClass(/blocked/);
|
||||
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
|
||||
await retirement.hover();
|
||||
await retirement.focus();
|
||||
await expect(retirement).toBeFocused();
|
||||
await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') });
|
||||
await retirement.click();
|
||||
await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴');
|
||||
|
||||
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '하야', exact: true }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야');
|
||||
|
||||
await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '강행', exact: true }).click();
|
||||
const forceMoveForm = picker.getByTestId('command-argument-form');
|
||||
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
|
||||
await forceMoveForm.locator('select').selectOption('2');
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행');
|
||||
|
||||
const serialized = JSON.stringify(requests);
|
||||
expect(serialized).toContain('"action":"che_은퇴","args":{}');
|
||||
expect(serialized).toContain('"action":"che_하야","args":{}');
|
||||
expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}');
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']);
|
||||
const mobileGeometry = await picker.evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
|
||||
}));
|
||||
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
|
||||
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
|
||||
await picker.getByRole('button', { name: '개인', exact: true }).click();
|
||||
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
|
||||
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
|
||||
});
|
||||
|
||||
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
@@ -656,17 +656,32 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
|
||||
await expect(page.locator('.main-page')).not.toContainText('che_');
|
||||
});
|
||||
|
||||
test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄에 표시한다', async ({ page }) => {
|
||||
test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 유지한다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permission: 'head',
|
||||
myset: 3,
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
recentRecords: {
|
||||
global: [],
|
||||
general: [
|
||||
global: [
|
||||
{
|
||||
id: 18611,
|
||||
text:
|
||||
'<C>●</>9월:<D><b>위</b></>의 <Y>Administrator</>가 <G><b>낙양</b></>으로 ' +
|
||||
'진격합니다.<span class="hidden_but_copyable">(전투시드: 0123456789abcdef)</span>',
|
||||
},
|
||||
{
|
||||
id: 18610,
|
||||
text: '<C>●</>9월:<Y>Administrator</>가 <D><b>위</b></>에 <S>임관</>했습니다.',
|
||||
},
|
||||
{
|
||||
id: 18609,
|
||||
text: '<C>●</>9월:<Y>뇌동</>의 기병이 퇴각했습니다.',
|
||||
},
|
||||
],
|
||||
general: [
|
||||
{
|
||||
id: 18608,
|
||||
text:
|
||||
'<S>◆</>186년 9월:<div class="small_war_log">' +
|
||||
'<span class="me"><span class="crew_type">귀병</span> ' +
|
||||
@@ -686,6 +701,31 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('');
|
||||
|
||||
const inspectGlobalRhythm = async (selector: string) => {
|
||||
const lines = page.locator(selector);
|
||||
await expect(lines).toHaveCount(3);
|
||||
return lines.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
lineHeight: getComputedStyle(element).lineHeight,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
const assertUniformGlobalRhythm = (geometry: Awaited<ReturnType<typeof inspectGlobalRhythm>>) => {
|
||||
expect(geometry.map((line) => line.height)).toEqual([21, 21, 21]);
|
||||
expect(geometry.map((line) => line.lineHeight)).toEqual(['21px', '21px', '21px']);
|
||||
expect(geometry[1]!.top - geometry[0]!.top).toBe(21);
|
||||
expect(geometry[2]!.top - geometry[1]!.top).toBe(21);
|
||||
};
|
||||
|
||||
const desktopGlobalGeometry = await inspectGlobalRhythm('.record-zone [data-record-bucket="global"] .record-line');
|
||||
assertUniformGlobalRhythm(desktopGlobalGeometry);
|
||||
await persistParityArtifact(page, 'core-main-trend-log-rhythm-desktop', desktopGlobalGeometry);
|
||||
|
||||
const expectedText = '◆186년 9월:귀병 【Administrator】 0(-2209) ← 1361(-5539) 기병 【ⓝ뇌동】 12:54';
|
||||
const inspect = async (line: Locator) => {
|
||||
await expect(line).toContainText(expectedText);
|
||||
@@ -723,6 +763,11 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄
|
||||
page.locator('.record-zone-mobile [data-record-bucket="general"] .record-line').first()
|
||||
);
|
||||
assertSingleLine(mobileGeometry);
|
||||
const mobileGlobalGeometry = await inspectGlobalRhythm(
|
||||
'.record-zone-mobile [data-record-bucket="global"] .record-line'
|
||||
);
|
||||
assertUniformGlobalRhythm(mobileGlobalGeometry);
|
||||
await persistParityArtifact(page, 'core-main-trend-log-rhythm-mobile', mobileGlobalGeometry);
|
||||
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
|
||||
});
|
||||
|
||||
|
||||
@@ -179,26 +179,54 @@ test('prioritizes core general fields and keeps context and inheritance progress
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
borderTopWidth: style.borderTopWidth,
|
||||
borderRightWidth: style.borderRightWidth,
|
||||
borderBottomWidth: style.borderBottomWidth,
|
||||
borderBottomColor: style.borderBottomColor,
|
||||
marginTop: style.marginTop,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
cursor: style.cursor,
|
||||
};
|
||||
});
|
||||
expect(defaultButtonStyle).toEqual({
|
||||
expect(defaultButtonStyle).toMatchObject({
|
||||
height: 40,
|
||||
backgroundColor: 'rgb(0, 88, 44)',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
borderTopWidth: '0px',
|
||||
borderRightWidth: '1px',
|
||||
borderBottomWidth: '4px',
|
||||
borderBottomColor: 'rgb(0, 79, 40)',
|
||||
marginTop: '0px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
await randomButton.hover();
|
||||
await expect
|
||||
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe('rgb(0, 109, 55)');
|
||||
const hoverButtonStyle = await randomButton.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderBottomWidth: style.borderBottomWidth,
|
||||
borderBottomColor: style.borderBottomColor,
|
||||
marginTop: style.marginTop,
|
||||
};
|
||||
});
|
||||
expect(hoverButtonStyle).toMatchObject({
|
||||
top: defaultButtonStyle.top + 1,
|
||||
height: 39,
|
||||
backgroundColor: 'rgb(0, 88, 44)',
|
||||
borderBottomWidth: '3px',
|
||||
borderBottomColor: 'rgb(0, 79, 40)',
|
||||
marginTop: '1px',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true });
|
||||
await randomButton.focus();
|
||||
await expect(randomButton).toBeFocused();
|
||||
@@ -210,9 +238,26 @@ test('prioritizes core general fields and keeps context and inheritance progress
|
||||
randomButtonBox!.y + randomButtonBox!.height / 2
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect
|
||||
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe('rgb(0, 69, 35)');
|
||||
const activeButtonStyle = await randomButton.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderBottomWidth: style.borderBottomWidth,
|
||||
borderBottomColor: style.borderBottomColor,
|
||||
marginTop: style.marginTop,
|
||||
};
|
||||
});
|
||||
expect(activeButtonStyle).toMatchObject({
|
||||
top: defaultButtonStyle.top + 2,
|
||||
height: 38,
|
||||
backgroundColor: 'rgb(0, 88, 44)',
|
||||
borderBottomWidth: '2px',
|
||||
borderBottomColor: 'rgb(0, 79, 40)',
|
||||
marginTop: '2px',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true });
|
||||
await page.mouse.up();
|
||||
const setRandomValues = async (values: number[]) => {
|
||||
|
||||
@@ -28,6 +28,8 @@ type NavigationFixture = {
|
||||
operations: string[];
|
||||
generalName?: string;
|
||||
generalTurnTime?: string;
|
||||
serverTime?: string;
|
||||
clockMode?: 'realtime' | 'manual';
|
||||
cityDefence?: number;
|
||||
cityState?: number;
|
||||
nationRate?: number;
|
||||
@@ -371,6 +373,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
year: state.currentYear ?? 185,
|
||||
month: state.currentMonth ?? 1,
|
||||
turnTerm: 10,
|
||||
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
|
||||
clockMode: state.clockMode ?? 'realtime',
|
||||
scenarioTitle: state.scenarioTitle ?? '',
|
||||
});
|
||||
}
|
||||
@@ -803,7 +807,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('main general card and command clock render the next turn with second precision', async ({ page }) => {
|
||||
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 0,
|
||||
permission: 0,
|
||||
@@ -813,22 +817,29 @@ test('main general card and command clock render the next turn with second preci
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
generalName: 'Administrator',
|
||||
generalTurnTime: '2026-08-13T00:07:06.713Z',
|
||||
generalTurnTime: '2026-08-13T00:09:10.713Z',
|
||||
serverTime: '2026-08-13T00:07:06.250Z',
|
||||
clockMode: 'realtime',
|
||||
currentYear: 179,
|
||||
currentMonth: 8,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' });
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await waitForMain(page);
|
||||
|
||||
const title = page.locator('[data-main-target="general"] .general-title').first();
|
||||
await expect(title).toContainText('Administrator');
|
||||
await expect(title).toContainText('용장');
|
||||
await expect(title).toContainText('09:07:06');
|
||||
await expect(title).not.toContainText('00:07');
|
||||
await expect(title).toContainText('09:09:10');
|
||||
await expect(title).not.toContainText('00:09');
|
||||
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||
await expect(commandClock).toHaveText('09:07:06');
|
||||
await expect(commandClock).not.toHaveText('00:07');
|
||||
await page.clock.runFor(1_000);
|
||||
await expect(commandClock).toHaveText('09:07:07');
|
||||
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
|
||||
await expect(generalCard).toContainText('수비 함(훈사80)');
|
||||
await expect(generalCard).toContainText('5 턴');
|
||||
@@ -873,9 +884,9 @@ test('main general card and command clock render the next turn with second preci
|
||||
const target = resolve(artifactRoot);
|
||||
await mkdir(target, { recursive: true });
|
||||
await Promise.all([
|
||||
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
|
||||
page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }),
|
||||
writeFile(
|
||||
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
|
||||
resolve(target, 'main-turn-time-local-desktop-1200.json'),
|
||||
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
|
||||
),
|
||||
]);
|
||||
@@ -883,9 +894,9 @@ test('main general card and command clock render the next turn with second preci
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
||||
await expect(mobileTitle).toContainText('09:07:06');
|
||||
await expect(mobileTitle).toContainText('09:09:10');
|
||||
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||
await expect(mobileCommandClock).toHaveText('09:07:06');
|
||||
await expect(mobileCommandClock).toHaveText('09:07:07');
|
||||
const mobileGeometry = {
|
||||
title: await mobileTitle.evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
@@ -911,15 +922,23 @@ test('main general card and command clock render the next turn with second preci
|
||||
if (artifactRoot) {
|
||||
await Promise.all([
|
||||
page.screenshot({
|
||||
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
|
||||
path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'),
|
||||
fullPage: true,
|
||||
}),
|
||||
writeFile(
|
||||
resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'),
|
||||
resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'),
|
||||
`${JSON.stringify(mobileGeometry, null, 2)}\n`
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
state.clockMode = 'manual';
|
||||
state.serverTime = '2026-08-13T00:08:30.000Z';
|
||||
await page.reload();
|
||||
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||
await expect(frozenClock).toHaveText('09:08:30');
|
||||
await page.clock.runFor(2_000);
|
||||
await expect(frozenClock).toHaveText('09:08:30');
|
||||
});
|
||||
|
||||
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { addMinutes } from 'date-fns';
|
||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import type {
|
||||
CommandMapData,
|
||||
CommandMapLayout,
|
||||
@@ -19,6 +19,8 @@ const props = defineProps<{
|
||||
currentYear?: number;
|
||||
currentMonth?: number;
|
||||
turnTermMinutes?: number;
|
||||
serverTime?: string;
|
||||
clockMode?: 'realtime' | 'manual';
|
||||
autorunLimit?: number | null;
|
||||
storageKey?: string;
|
||||
mapData?: CommandMapData | null;
|
||||
@@ -56,16 +58,48 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
||||
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
|
||||
time: date
|
||||
? term >= 5
|
||||
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
|
||||
: `${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}`
|
||||
? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
: `${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||
: '--:--',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const currentTurnTime = computed(() =>
|
||||
props.general?.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '--:--:--'
|
||||
const currentServerTime = ref('--:--:--');
|
||||
let sampledServerTimeMs: number | null = null;
|
||||
let sampledClientTimeMs = 0;
|
||||
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const updateServerClock = () => {
|
||||
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||
serverClockTimer = undefined;
|
||||
if (sampledServerTimeMs === null) {
|
||||
currentServerTime.value = '--:--:--';
|
||||
return;
|
||||
}
|
||||
const projectedTime = new Date(
|
||||
props.clockMode === 'manual' ? sampledServerTimeMs : sampledServerTimeMs + Date.now() - sampledClientTimeMs
|
||||
);
|
||||
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
|
||||
if (props.clockMode !== 'manual') {
|
||||
serverClockTimer = setTimeout(updateServerClock, 1_000 - projectedTime.getMilliseconds());
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.serverTime, props.clockMode] as const,
|
||||
([serverTime]) => {
|
||||
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN;
|
||||
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
|
||||
sampledClientTimeMs = Date.now();
|
||||
updateServerClock();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,7 +109,7 @@ const currentTurnTime = computed(() =>
|
||||
:command-table="props.commandTable"
|
||||
:loading="props.loading"
|
||||
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
||||
:current-time="currentTurnTime"
|
||||
:current-time="currentServerTime"
|
||||
:map-data="props.mapData"
|
||||
:map-layout="props.mapLayout"
|
||||
@reserve-bulk="emit('set-general-turns', $event)"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
||||
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||
@@ -232,7 +232,7 @@ const specialText = computed(() => {
|
||||
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
||||
<span data-general-turn-time>{{
|
||||
props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-'
|
||||
props.general.turnTime ? formatLocalTimeSeconds(props.general.turnTime) : '-'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,3 +7,11 @@ export const formatSeoulHourMinute = (value: string | Date): string =>
|
||||
|
||||
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
||||
formatServerDateTime(value, { format: 'timeSeconds' });
|
||||
|
||||
export const formatLocalTimeSeconds = (value: string | Date): string => {
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime())) return '-';
|
||||
return [parsed.getHours(), parsed.getMinutes(), parsed.getSeconds()]
|
||||
.map((part) => String(part).padStart(2, '0'))
|
||||
.join(':');
|
||||
};
|
||||
|
||||
@@ -726,10 +726,26 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="stat-actions" role="group" aria-label="능력치 빠른 설정">
|
||||
<button type="button" @click="applyRandomStats">랜덤형</button>
|
||||
<button type="button" @click="applyLeadpowStats">통솔무력형</button>
|
||||
<button type="button" @click="applyLeadintStats">통솔지력형</button>
|
||||
<button type="button" @click="applyPowintStats">무력지력형</button>
|
||||
<button class="legacy-button legacy-button--navigation" type="button" @click="applyRandomStats">
|
||||
랜덤형
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
@click="applyLeadpowStats"
|
||||
>
|
||||
통솔무력형
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
@click="applyLeadintStats"
|
||||
>
|
||||
통솔지력형
|
||||
</button>
|
||||
<button class="legacy-button legacy-button--navigation" type="button" @click="applyPowintStats">
|
||||
무력지력형
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="accountIcons.length" class="icon-choice">
|
||||
@@ -1385,26 +1401,21 @@ onUnmounted(() => {
|
||||
|
||||
.stat-actions button {
|
||||
flex: 1 1 140px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
border: 1px solid #004f28;
|
||||
border-radius: 3px;
|
||||
background: #00582c;
|
||||
padding: 8px 14px;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-actions button:hover {
|
||||
border-color: #005f30;
|
||||
background: #006d37;
|
||||
.stat-actions button:not(:disabled):hover {
|
||||
height: 39px;
|
||||
min-height: 39px;
|
||||
}
|
||||
|
||||
.stat-actions button:active {
|
||||
border-color: #003d1f;
|
||||
background: #004523;
|
||||
.stat-actions button:not(:disabled):active {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.stat-summary {
|
||||
|
||||
@@ -205,6 +205,8 @@ watch(
|
||||
:current-year="lobbyInfo?.year"
|
||||
:current-month="lobbyInfo?.month"
|
||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||
:server-time="lobbyInfo?.serverTime"
|
||||
:clock-mode="lobbyInfo?.clockMode"
|
||||
:autorun-limit="reservedGeneralAutorunLimit"
|
||||
:map-data="worldMap"
|
||||
:map-layout="mapLayout"
|
||||
@@ -331,6 +333,8 @@ watch(
|
||||
:current-year="lobbyInfo?.year"
|
||||
:current-month="lobbyInfo?.month"
|
||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||
:server-time="lobbyInfo?.serverTime"
|
||||
:clock-mode="lobbyInfo?.clockMode"
|
||||
:autorun-limit="reservedGeneralAutorunLimit"
|
||||
:map-data="worldMap"
|
||||
:map-layout="mapLayout"
|
||||
@@ -666,11 +670,21 @@ button {
|
||||
}
|
||||
|
||||
.record-line {
|
||||
box-sizing: border-box;
|
||||
height: 21px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: normal;
|
||||
line-height: 21px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-line :deep(.small_war_log) {
|
||||
height: 21px;
|
||||
line-height: 21px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.record-line :deep(.hidden_but_copyable) {
|
||||
color: transparent !important;
|
||||
font-size: 0;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { formatSeoulDateTime, formatSeoulHourMinute, formatSeoulTimeSeconds } from '../src/utils/legacyDateTime.ts';
|
||||
import {
|
||||
formatLocalTimeSeconds,
|
||||
formatSeoulDateTime,
|
||||
formatSeoulHourMinute,
|
||||
formatSeoulTimeSeconds,
|
||||
} from '../src/utils/legacyDateTime.ts';
|
||||
|
||||
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
||||
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
||||
@@ -14,3 +19,13 @@ void test('keeps legacy timezone-less server timestamps unchanged', () => {
|
||||
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
||||
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
||||
});
|
||||
|
||||
void test('formats an ISO instant with the client local clock', () => {
|
||||
const instant = new Date('2026-08-13T00:07:06.713Z');
|
||||
const expected = [instant.getHours(), instant.getMinutes(), instant.getSeconds()]
|
||||
.map((part) => String(part).padStart(2, '0'))
|
||||
.join(':');
|
||||
assert.equal(formatLocalTimeSeconds(instant), expected);
|
||||
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
|
||||
assert.equal(formatLocalTimeSeconds('invalid'), '-');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user