Merge branch 'main' into feature/command-argument-ui
This commit is contained in:
@@ -15,6 +15,18 @@ const zGeneralSettings = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
|
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
|
||||||
|
const MAIN_RECORD_LIMIT = 15;
|
||||||
|
|
||||||
|
const trimRecentRecords = <Entry extends { id: number }>(entries: Entry[], cursor: number): Entry[] => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
const result = [...entries];
|
||||||
|
if (result.at(-1)?.id === cursor || result.length > MAIN_RECORD_LIMIT) {
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const readNumber = (value: unknown, fallback: number): number => {
|
const readNumber = (value: unknown, fallback: number): number => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -319,4 +331,54 @@ export const generalRouter = router({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
getRecentRecords: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
lastGeneralRecordId: z.number().int().nonnegative().default(0),
|
||||||
|
lastWorldHistoryId: z.number().int().nonnegative().default(0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const take = MAIN_RECORD_LIMIT + 1;
|
||||||
|
const [global, general, history] = await Promise.all([
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.SUMMARY,
|
||||||
|
id: { gte: input.lastGeneralRecordId },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
}),
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: me.id,
|
||||||
|
id: { gte: input.lastGeneralRecordId },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
}),
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
id: { gte: input.lastWorldHistoryId },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
global: trimRecentRecords(global, input.lastGeneralRecordId),
|
||||||
|
general: trimRecentRecords(general, input.lastGeneralRecordId),
|
||||||
|
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
@@ -241,14 +242,45 @@ export const publicRouter = router({
|
|||||||
return loadMapLayout(ctx.profile.scenario);
|
return loadMapLayout(ctx.profile.scenario);
|
||||||
}),
|
}),
|
||||||
getCachedMap: procedure.query(async ({ ctx }) => {
|
getCachedMap: procedure.query(async ({ ctx }) => {
|
||||||
const map = await loadPublicMap(ctx, true);
|
const cacheKey = buildPublicCacheKey(ctx, 'cachedMapWithHistory');
|
||||||
|
const cached = await ctx.redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(cached) as NonNullable<Awaited<ReturnType<typeof loadPublicMap>>> & {
|
||||||
|
history: { id: number; text: string }[];
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Ignore cache parse errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [map, history] = await Promise.all([
|
||||||
|
loadPublicMap(ctx, true),
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
text: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
if (!map) {
|
if (!map) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: 'World state is not initialized.',
|
message: 'World state is not initialized.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return map;
|
const snapshot = {
|
||||||
|
...map,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
||||||
|
return snapshot;
|
||||||
}),
|
}),
|
||||||
getWorldTrend: procedure.query(async ({ ctx }) => {
|
getWorldTrend: procedure.query(async ({ ctx }) => {
|
||||||
return loadCachedWorldTrend(ctx);
|
return loadCachedWorldTrend(ctx);
|
||||||
|
|||||||
@@ -186,6 +186,50 @@ describe('in-game my information ownership', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the three legacy front-page record streams for the session-owned general', async () => {
|
||||||
|
const fixture = createContext({});
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.general.getRecentRecords({
|
||||||
|
lastGeneralRecordId: 0,
|
||||||
|
lastWorldHistoryId: 0,
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
global: [{ id: 1, text: '기록' }],
|
||||||
|
general: [{ id: 1, text: '기록' }],
|
||||||
|
history: [{ id: 1, text: '기록' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { scope: 'SYSTEM', category: 'SUMMARY', id: { gte: 0 } },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 16,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7, id: { gte: 0 } },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 16,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { scope: 'SYSTEM', category: 'HISTORY', id: { gte: 0 } },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 16,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('battle-center general and user permissions', () => {
|
describe('battle-center general and user permissions', () => {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
sessionId: 'session-owner',
|
||||||
|
user: {
|
||||||
|
id: 'owner',
|
||||||
|
username: 'owner',
|
||||||
|
displayName: 'Owner',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogQuery = {
|
||||||
|
where: {
|
||||||
|
scope: LogScope;
|
||||||
|
category: LogCategory;
|
||||||
|
generalId?: number;
|
||||||
|
id: { gte: number };
|
||||||
|
};
|
||||||
|
orderBy: { id: 'desc' };
|
||||||
|
take: number;
|
||||||
|
select: { id: true; text: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||||
|
({
|
||||||
|
auth,
|
||||||
|
db: {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||||
|
id: 7,
|
||||||
|
userId: where.userId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
logEntry: { findMany },
|
||||||
|
} as unknown as DatabaseClient,
|
||||||
|
}) as GameApiContext;
|
||||||
|
|
||||||
|
describe('general.getRecentRecords', () => {
|
||||||
|
it('derives the general and maps all three legacy dashboard buckets', async () => {
|
||||||
|
const findMany = vi.fn(async (query: LogQuery) => {
|
||||||
|
if (query.where.scope === LogScope.GENERAL) {
|
||||||
|
return [
|
||||||
|
{ id: 31, text: '개인 최신' },
|
||||||
|
{ id: 20, text: '개인 cursor' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (query.where.category === LogCategory.SUMMARY) {
|
||||||
|
return [
|
||||||
|
{ id: 32, text: '장수 최신' },
|
||||||
|
{ id: 20, text: '장수 cursor' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ id: 42, text: '중원 최신' },
|
||||||
|
{ id: 40, text: '중원 cursor' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const caller = appRouter.createCaller(buildContext(findMany));
|
||||||
|
|
||||||
|
const result = await caller.general.getRecentRecords({
|
||||||
|
lastGeneralRecordId: 20,
|
||||||
|
lastWorldHistoryId: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
global: [{ id: 32, text: '장수 최신' }],
|
||||||
|
general: [{ id: 31, text: '개인 최신' }],
|
||||||
|
history: [{ id: 42, text: '중원 최신' }],
|
||||||
|
});
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(3);
|
||||||
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: 7,
|
||||||
|
id: { gte: 20 },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 16,
|
||||||
|
select: { id: true, text: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps an initial bucket at the legacy 15-row limit', async () => {
|
||||||
|
const rows = Array.from({ length: 16 }, (_, index) => ({
|
||||||
|
id: 100 - index,
|
||||||
|
text: `기록 ${index}`,
|
||||||
|
}));
|
||||||
|
const caller = appRouter.createCaller(buildContext(async () => rows));
|
||||||
|
|
||||||
|
const result = await caller.general.getRecentRecords({
|
||||||
|
lastGeneralRecordId: 0,
|
||||||
|
lastWorldHistoryId: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.global).toHaveLength(15);
|
||||||
|
expect(result.general).toHaveLength(15);
|
||||||
|
expect(result.history).toHaveLength(15);
|
||||||
|
expect(result.global.at(-1)?.id).toBe(86);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an authenticated user without an in-game general', async () => {
|
||||||
|
const context = buildContext(async () => []);
|
||||||
|
context.db = {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
logEntry: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseClient;
|
||||||
|
const caller = appRouter.createCaller(context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.general.getRecentRecords({
|
||||||
|
lastGeneralRecordId: 0,
|
||||||
|
lastWorldHistoryId: 0,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
||||||
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const profile: GameProfile = {
|
||||||
|
id: 'che',
|
||||||
|
scenario: 'default',
|
||||||
|
name: 'che:default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = () => {
|
||||||
|
const redis = {
|
||||||
|
get: vi.fn(async () => null),
|
||||||
|
set: vi.fn(async () => 'OK'),
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
currentYear: 190,
|
||||||
|
currentMonth: 3,
|
||||||
|
config: {},
|
||||||
|
meta: { scenarioMeta: { startYear: 184 } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
logEntry: {
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{ id: 9, text: '<Y>최근 정세</>' },
|
||||||
|
{ id: 8, text: '이전 정세' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
$queryRaw: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } },
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const context: GameApiContext = {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
redis: redis as unknown as RedisConnector['client'],
|
||||||
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
|
battleSim: new InMemoryBattleSimTransport(),
|
||||||
|
profile,
|
||||||
|
auth: null as GameSessionTokenPayload | null,
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
accessTokenStore: new RedisAccessTokenStore(redis as unknown as RedisConnector['client'], profile.name),
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
return { context, db, redis };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('public.getCachedMap', () => {
|
||||||
|
it('caches the neutral map and ten latest public history rows as one snapshot', async () => {
|
||||||
|
const fixture = buildContext();
|
||||||
|
const result = await appRouter.createCaller(fixture.context).public.getCachedMap();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
year: 190,
|
||||||
|
month: 3,
|
||||||
|
history: [
|
||||||
|
{ id: 9, text: '<Y>최근 정세</>' },
|
||||||
|
{ id: 8, text: '이전 정세' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { scope: 'SYSTEM', category: 'HISTORY' },
|
||||||
|
select: { id: true, text: true },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
expect(fixture.redis.set).toHaveBeenCalledWith(
|
||||||
|
'sammo:public:cachedMapWithHistory:che:default',
|
||||||
|
expect.stringContaining('최근 정세'),
|
||||||
|
{ EX: 600 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery, useMouseInElement } from '@vueuse/core';
|
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import MapCityBasic from './MapCityBasic.vue';
|
import MapCityBasic from './MapCityBasic.vue';
|
||||||
import MapCityDetail from './MapCityDetail.vue';
|
import MapCityDetail from './MapCityDetail.vue';
|
||||||
@@ -72,6 +72,8 @@ const mapStore = useMapViewerStore();
|
|||||||
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
|
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
|
||||||
|
|
||||||
const mapArea = ref<HTMLElement | null>(null);
|
const mapArea = ref<HTMLElement | null>(null);
|
||||||
|
const mapBody = ref<HTMLElement | null>(null);
|
||||||
|
const { width: mapBodyWidth } = useElementSize(mapBody);
|
||||||
const { elementX, elementY } = useMouseInElement(mapArea);
|
const { elementX, elementY } = useMouseInElement(mapArea);
|
||||||
|
|
||||||
const resolveSeason = (month: number): string => {
|
const resolveSeason = (month: number): string => {
|
||||||
@@ -131,7 +133,15 @@ const dynamicCityById = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapScale = computed(() => (isWide.value ? 1 : SMALL_MAP_SCALE));
|
const mapScale = computed(() => {
|
||||||
|
if (isWide.value) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (mapBodyWidth.value <= 0) {
|
||||||
|
return SMALL_MAP_SCALE;
|
||||||
|
}
|
||||||
|
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
||||||
|
|
||||||
@@ -285,7 +295,7 @@ const selectCity = (cityId: number) => {
|
|||||||
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">
|
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">
|
||||||
지도 데이터를 불러오지 못했습니다.
|
지도 데이터를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="map-body">
|
<div v-else ref="mapBody" class="map-body">
|
||||||
<div
|
<div
|
||||||
ref="mapArea"
|
ref="mapArea"
|
||||||
class="map-area"
|
class="map-area"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { formatLog } from '../../utils/formatLog';
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
logs?: LogEntry[] | null;
|
||||||
|
emptyText?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
logs: null,
|
||||||
|
emptyText: '기록 없음',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedLogs = computed(() =>
|
||||||
|
(props.logs ?? []).map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
html: formatLog(entry.text),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="recent-log-list">
|
||||||
|
<template v-if="formattedLogs.length">
|
||||||
|
<!-- 레거시 색상 tag만 formatLog가 span으로 변환한다. -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div v-for="entry in formattedLogs" :key="entry.id" class="recent-log-line" v-html="entry.html" />
|
||||||
|
</template>
|
||||||
|
<div v-else class="recent-log-empty">{{ emptyText }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recent-log-list {
|
||||||
|
min-width: 0;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-log-line {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-log-empty {
|
||||||
|
color: #aaa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="record-panel">
|
||||||
|
<h2 class="record-title">{{ title }}</h2>
|
||||||
|
<div class="record-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.record-panel {
|
||||||
|
min-width: 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-title {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 23px;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
background-color: #14241b;
|
||||||
|
background-image: url('/image/game/back_green.jpg');
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,9 +26,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
||||||
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
||||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
||||||
|
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
const recordsError = ref<string | null>(null);
|
||||||
const realtimeEnabled = ref(true);
|
const realtimeEnabled = ref(true);
|
||||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||||
|
|
||||||
@@ -42,6 +44,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const boardAccess = ref<BoardAccess | null>(null);
|
const boardAccess = ref<BoardAccess | null>(null);
|
||||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
|
const globalRecords = ref<RecentRecord[]>([]);
|
||||||
|
const generalRecords = ref<RecentRecord[]>([]);
|
||||||
|
const worldHistory = ref<RecentRecord[]>([]);
|
||||||
|
let lastGeneralRecordId = 0;
|
||||||
|
let lastWorldHistoryId = 0;
|
||||||
|
let recordGeneralId: number | null = null;
|
||||||
|
|
||||||
const messageDraftText = ref('');
|
const messageDraftText = ref('');
|
||||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||||
@@ -191,12 +199,30 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
|
||||||
|
const merged = new Map(current.map((entry) => [entry.id, entry]));
|
||||||
|
for (const entry of incoming) {
|
||||||
|
merged.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
return [...merged.values()].sort((left, right) => right.id - left.id).slice(0, 15);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetRecentRecords = (id: number | null) => {
|
||||||
|
globalRecords.value = [];
|
||||||
|
generalRecords.value = [];
|
||||||
|
worldHistory.value = [];
|
||||||
|
lastGeneralRecordId = 0;
|
||||||
|
lastWorldHistoryId = 0;
|
||||||
|
recordGeneralId = id;
|
||||||
|
};
|
||||||
|
|
||||||
const loadMainData = async () => {
|
const loadMainData = async () => {
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
recordsError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await trpc.general.me.query();
|
const context = await trpc.general.me.query();
|
||||||
@@ -206,18 +232,31 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
reservedGeneralTurns.value = null;
|
reservedGeneralTurns.value = null;
|
||||||
reservedNationTurns.value = null;
|
reservedNationTurns.value = null;
|
||||||
boardAccess.value = null;
|
boardAccess.value = null;
|
||||||
|
resetRecentRecords(null);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = context.general.id;
|
const id = context.general.id;
|
||||||
|
if (recordGeneralId !== id) {
|
||||||
|
resetRecentRecords(id);
|
||||||
|
}
|
||||||
const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query();
|
const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query();
|
||||||
const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id });
|
const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id });
|
||||||
const nationTurnsPromise =
|
const nationTurnsPromise =
|
||||||
context.general.nationId > 0 && context.general.officerLevel >= 5
|
context.general.nationId > 0 && context.general.officerLevel >= 5
|
||||||
? trpc.turns.reserved.getNation.query({ generalId: id })
|
? trpc.turns.reserved.getNation.query({ generalId: id })
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
|
const recordsPromise = trpc.general.getRecentRecords
|
||||||
|
.query({
|
||||||
|
lastGeneralRecordId,
|
||||||
|
lastWorldHistoryId,
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
recordsError.value = resolveErrorMessage(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
layoutPromise,
|
layoutPromise,
|
||||||
trpc.lobby.info.query(),
|
trpc.lobby.info.query(),
|
||||||
@@ -228,6 +267,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
trpc.board.getAccess.query(),
|
trpc.board.getAccess.query(),
|
||||||
generalTurnsPromise,
|
generalTurnsPromise,
|
||||||
nationTurnsPromise,
|
nationTurnsPromise,
|
||||||
|
recordsPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mapLayout.value = layout;
|
mapLayout.value = layout;
|
||||||
@@ -239,6 +279,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
boardAccess.value = access;
|
boardAccess.value = access;
|
||||||
reservedGeneralTurns.value = generalTurns;
|
reservedGeneralTurns.value = generalTurns;
|
||||||
reservedNationTurns.value = nationTurns;
|
reservedNationTurns.value = nationTurns;
|
||||||
|
if (records) {
|
||||||
|
globalRecords.value = mergeRecentRecords(globalRecords.value, records.global);
|
||||||
|
generalRecords.value = mergeRecentRecords(generalRecords.value, records.general);
|
||||||
|
worldHistory.value = mergeRecentRecords(worldHistory.value, records.history);
|
||||||
|
lastGeneralRecordId = Math.max(
|
||||||
|
lastGeneralRecordId,
|
||||||
|
records.global[0]?.id ?? 0,
|
||||||
|
records.general[0]?.id ?? 0
|
||||||
|
);
|
||||||
|
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
|
||||||
|
}
|
||||||
if (initializedMailboxGeneralId !== id) {
|
if (initializedMailboxGeneralId !== id) {
|
||||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||||
initializedMailboxGeneralId = id;
|
initializedMailboxGeneralId = id;
|
||||||
@@ -587,6 +638,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
recordsError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
realtimeStatus,
|
realtimeStatus,
|
||||||
generalContext,
|
generalContext,
|
||||||
@@ -603,6 +655,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
|
globalRecords,
|
||||||
|
generalRecords,
|
||||||
|
worldHistory,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxGroups,
|
mailboxGroups,
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ import CityBasicCard from '../components/main/CityBasicCard.vue';
|
|||||||
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
||||||
import MessagePanel from '../components/main/MessagePanel.vue';
|
import MessagePanel from '../components/main/MessagePanel.vue';
|
||||||
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
||||||
|
import RecordPanel from '../components/main/RecordPanel.vue';
|
||||||
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const dashboard = useMainDashboardStore();
|
const dashboard = useMainDashboardStore();
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
const isMobile = useMediaQuery('(max-width: 991px)');
|
||||||
|
|
||||||
const mobileTabs = [
|
const mobileTabs = [
|
||||||
{ key: 'map', label: '지도' },
|
{ key: 'map', label: '지도' },
|
||||||
@@ -35,11 +37,11 @@ const tournamentStage = ref(0);
|
|||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
recordsError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
nation,
|
nation,
|
||||||
lobbyInfo,
|
|
||||||
worldMap,
|
worldMap,
|
||||||
mapLayout,
|
mapLayout,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
@@ -48,6 +50,9 @@ const {
|
|||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
|
globalRecords,
|
||||||
|
generalRecords,
|
||||||
|
worldHistory,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxGroups,
|
mailboxGroups,
|
||||||
@@ -203,23 +208,49 @@ watch(
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'world'" class="mobile-panel">
|
<div v-if="mobileTab === 'world'" class="mobile-panel record-zone-mobile">
|
||||||
<PanelCard title="장수 동향">
|
<RecordPanel title="장수 동향">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
</PanelCard>
|
<div v-else class="record-list" data-record-bucket="global">
|
||||||
<PanelCard title="개인 기록">
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<div
|
||||||
<div v-else class="placeholder">개인 기록 영역</div>
|
v-for="entry in globalRecords"
|
||||||
</PanelCard>
|
:key="entry.id"
|
||||||
<PanelCard title="중원 정세">
|
class="record-line"
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
v-html="formatLog(entry.text)"
|
||||||
<div v-else class="placeholder">
|
/>
|
||||||
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
|
||||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</RecordPanel>
|
||||||
|
<RecordPanel title="개인 기록">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
|
<div v-else class="record-list" data-record-bucket="general">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-for="entry in generalRecords"
|
||||||
|
:key="entry.id"
|
||||||
|
class="record-line"
|
||||||
|
v-html="formatLog(entry.text)"
|
||||||
|
/>
|
||||||
|
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</RecordPanel>
|
||||||
|
<RecordPanel title="중원 정세">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
|
<div v-else class="record-list" data-record-bucket="history">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-for="entry in worldHistory"
|
||||||
|
:key="entry.id"
|
||||||
|
class="record-line"
|
||||||
|
v-html="formatLog(entry.text)"
|
||||||
|
/>
|
||||||
|
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</RecordPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
||||||
@@ -254,14 +285,6 @@ watch(
|
|||||||
<PanelCard title="선택 도시">
|
<PanelCard title="선택 도시">
|
||||||
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="중원 정세">
|
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
|
||||||
<div v-else class="placeholder">
|
|
||||||
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
|
||||||
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
|
||||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
@@ -282,21 +305,57 @@ watch(
|
|||||||
<PanelCard title="장수 스탯">
|
<PanelCard title="장수 스탯">
|
||||||
<GeneralBasicCard :general="general" :loading="loading" />
|
<GeneralBasicCard :general="general" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 동향">
|
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
|
||||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
|
||||||
</PanelCard>
|
|
||||||
<PanelCard title="도시 정보">
|
<PanelCard title="도시 정보">
|
||||||
<CityBasicCard :city="city" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="국가 정보">
|
<PanelCard title="국가 정보">
|
||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="개인 기록">
|
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
|
||||||
<div v-else class="placeholder">개인 기록 영역</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
<section class="record-zone">
|
||||||
|
<RecordPanel title="장수 동향">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
|
<div v-else class="record-list" data-record-bucket="global">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-for="entry in globalRecords"
|
||||||
|
:key="entry.id"
|
||||||
|
class="record-line"
|
||||||
|
v-html="formatLog(entry.text)"
|
||||||
|
/>
|
||||||
|
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</RecordPanel>
|
||||||
|
<RecordPanel title="개인 기록">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
|
<div v-else class="record-list" data-record-bucket="general">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-for="entry in generalRecords"
|
||||||
|
:key="entry.id"
|
||||||
|
class="record-line"
|
||||||
|
v-html="formatLog(entry.text)"
|
||||||
|
/>
|
||||||
|
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</RecordPanel>
|
||||||
|
<RecordPanel class="world-history-panel" title="중원 정세">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
|
<div v-else class="record-list" data-record-bucket="history">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-for="entry in worldHistory"
|
||||||
|
:key="entry.id"
|
||||||
|
class="record-line"
|
||||||
|
v-html="formatLog(entry.text)"
|
||||||
|
/>
|
||||||
|
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</RecordPanel>
|
||||||
|
</section>
|
||||||
<MessagePanel
|
<MessagePanel
|
||||||
class="desktop-message-panel"
|
class="desktop-message-panel"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
@@ -415,6 +474,42 @@ button {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-zone {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
width: calc(100% + 48px);
|
||||||
|
margin-left: -24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-history-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-list {
|
||||||
|
min-height: 21px;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-line {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-empty {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-error {
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-zone-mobile {
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: -24px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-message-panel {
|
.mobile-message-panel {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMediaQuery } from '@vueuse/core';
|
|||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import MapViewer from '../components/main/MapViewer.vue';
|
import MapViewer from '../components/main/MapViewer.vue';
|
||||||
|
import RecentLogList from '../components/main/RecentLogList.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
@@ -121,12 +122,7 @@ onMounted(() => {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="중원 정세">
|
<PanelCard title="중원 정세">
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
<SkeletonLines v-if="loading" :lines="3" />
|
||||||
<div v-else class="placeholder">
|
<RecentLogList v-else :logs="mapData?.history" />
|
||||||
<div>유저 {{ worldTrend?.userCnt ?? '-' }} / {{ worldTrend?.maxUserCnt ?? '-' }}</div>
|
|
||||||
<div>NPC {{ worldTrend?.npcCnt ?? '-' }}</div>
|
|
||||||
<div>세력 {{ worldTrend?.nationCnt ?? '-' }}</div>
|
|
||||||
<div>상성 {{ worldTrend?.fictionMode ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="세력 일람">
|
<PanelCard title="세력 일람">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
@@ -193,13 +189,7 @@ onMounted(() => {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="중원 정세">
|
<PanelCard title="중원 정세">
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
<SkeletonLines v-if="loading" :lines="3" />
|
||||||
<div v-else class="placeholder">
|
<RecentLogList v-else :logs="mapData?.history" />
|
||||||
<div>유저 {{ worldTrend?.userCnt ?? '-' }} / {{ worldTrend?.maxUserCnt ?? '-' }}</div>
|
|
||||||
<div>NPC {{ worldTrend?.npcCnt ?? '-' }}</div>
|
|
||||||
<div>세력 {{ worldTrend?.nationCnt ?? '-' }}</div>
|
|
||||||
<div>상성 {{ worldTrend?.fictionMode ?? '-' }}</div>
|
|
||||||
<div>기타 {{ worldTrend?.otherTextInfo ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type MapLayout = Awaited<ReturnType<typeof trpc.public.getMapLayout.query>>;
|
|||||||
type HistoryData = {
|
type HistoryData = {
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
map: Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>;
|
map: Omit<Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>, 'history'>;
|
||||||
nations: Array<{
|
nations: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g;
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
R: 'red',
|
||||||
|
B: 'blue',
|
||||||
|
G: 'green',
|
||||||
|
M: 'magenta',
|
||||||
|
C: 'cyan',
|
||||||
|
L: 'limegreen',
|
||||||
|
S: 'skyblue',
|
||||||
|
O: 'orangered',
|
||||||
|
D: 'orangered',
|
||||||
|
Y: 'yellow',
|
||||||
|
W: 'white',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatLog = (text: string): string =>
|
||||||
|
text.replace(logRegex, (_all, tag: string) => {
|
||||||
|
if (tag === '/') {
|
||||||
|
return '</span>';
|
||||||
|
}
|
||||||
|
const color = colorMap[tag[0] ?? ''];
|
||||||
|
const small = tag.includes('1');
|
||||||
|
const styles = [color ? `color: ${color}` : '', small ? 'font-size: 0.9em' : ''].filter(Boolean).join('; ');
|
||||||
|
return `<span style="${styles}">`;
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import MapPreview from '../components/MapPreview.vue';
|
|||||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||||
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
|
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
import { formatLog } from '../utils/formatLog';
|
||||||
|
|
||||||
type GatewayOutput = inferRouterOutputs<AppRouter>;
|
type GatewayOutput = inferRouterOutputs<AppRouter>;
|
||||||
type GameOutput = inferRouterOutputs<GameRouter>;
|
type GameOutput = inferRouterOutputs<GameRouter>;
|
||||||
@@ -173,6 +174,11 @@ const handlePasswordReset = async (): Promise<void> => {
|
|||||||
<li>유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · {{ info.nationCnt }}국 경쟁중</li>
|
<li>유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · {{ info.nationCnt }}국 경쟁중</li>
|
||||||
<li>{{ info.turnTerm }}분 턴 서버</li>
|
<li>{{ info.turnTerm }}분 턴 서버</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-if="mapData?.history?.length" class="status-history">
|
||||||
|
<!-- 레거시 색상 tag만 formatLog가 span으로 변환한다. -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div v-for="entry in mapData.history" :key="entry.id" v-html="formatLog(entry.text)" />
|
||||||
|
</div>
|
||||||
<button type="button" class="refresh-button" :disabled="statusLoading" @click="loadPublicStatus">
|
<button type="button" class="refresh-button" :disabled="statusLoading" @click="loadPublicStatus">
|
||||||
현황 새로고침
|
현황 새로고침
|
||||||
</button>
|
</button>
|
||||||
@@ -303,6 +309,15 @@ const handlePasswordReset = async (): Promise<void> => {
|
|||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-history {
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #ddd;
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.status-card > header {
|
.status-card > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -21,13 +21,10 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { NationTurnCommandSpec } from './index.js';
|
import type { NationTurnCommandSpec } from './index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destCityId: z.preprocess(
|
destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int().positive()),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value) : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
export type MobilizePeopleArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type MobilizePeopleArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const frontendPort = 15112;
|
||||||
|
const apiPort = 15113;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: ['main-records.spec.ts'],
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
reporter: [['list']],
|
||||||
|
outputDir: resolve(repositoryRoot, 'test-results/main-records-live'),
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
baseURL: `http://127.0.0.1:${frontendPort}/che/`,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command:
|
||||||
|
`GAME_API_HOST=127.0.0.1 GAME_API_PORT=${apiPort} ` +
|
||||||
|
'GAME_TRPC_PATH=/che/api/trpc GAME_API_EVENTS_PATH=/che/api/events ' +
|
||||||
|
'PROFILE=che SCENARIO=default GAME_PROFILE_NAME=che:default node app/game-api/dist/index.js',
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: `http://127.0.0.1:${apiPort}/che/api/trpc/health.ping?input=%7B%22json%22%3Anull%7D`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command:
|
||||||
|
`VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc ` +
|
||||||
|
`VITE_GAME_SSE_URL=http://127.0.0.1:${apiPort}/che/api/events ` +
|
||||||
|
`pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: `http://127.0.0.1:${frontendPort}/che/`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
createGamePostgresConnector,
|
||||||
|
createRedisConnector,
|
||||||
|
resolvePostgresConfigFromEnv,
|
||||||
|
resolveRedisConfigFromEnv,
|
||||||
|
type GamePrismaClient,
|
||||||
|
type RedisConnector,
|
||||||
|
} from '../../packages/infra/src/index.js';
|
||||||
|
|
||||||
|
const RECORD_MARKER = `main-records-live-${randomUUID()}`;
|
||||||
|
const accessToken = `ga_${randomUUID()}`;
|
||||||
|
let postgres: ReturnType<typeof createGamePostgresConnector>;
|
||||||
|
let redis: RedisConnector;
|
||||||
|
let prisma: GamePrismaClient;
|
||||||
|
let createdIds: number[] = [];
|
||||||
|
|
||||||
|
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
|
||||||
|
const trpcResponse = (data: unknown) => ({ result: { data } });
|
||||||
|
const trpcError = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const pathname = decodeURIComponent(new URL(route.request().url()).pathname);
|
||||||
|
return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const inspectRecordLayout = async (page: Page) =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const inspect = (selector: string) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
throw new Error(`missing ${selector}`);
|
||||||
|
}
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
padding: style.padding,
|
||||||
|
color: style.color,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderTop: style.borderTop,
|
||||||
|
borderBottom: style.borderBottom,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const buckets = [...document.querySelectorAll<HTMLElement>('[data-record-bucket]')];
|
||||||
|
const mobileZone = document.querySelector<HTMLElement>('.record-zone-mobile');
|
||||||
|
const zoneSelector =
|
||||||
|
mobileZone && getComputedStyle(mobileZone).display !== 'none' ? '.record-zone-mobile' : '.record-zone';
|
||||||
|
return {
|
||||||
|
zone: inspect(zoneSelector),
|
||||||
|
global: inspect('[data-record-bucket="global"]'),
|
||||||
|
general: inspect('[data-record-bucket="general"]'),
|
||||||
|
history: inspect('[data-record-bucket="history"]'),
|
||||||
|
title: inspect('[data-record-bucket="global"]'),
|
||||||
|
firstLine: inspect('[data-record-bucket="global"] .record-line'),
|
||||||
|
panels: buckets.map((bucket) => {
|
||||||
|
const panel = bucket.closest<HTMLElement>('.record-panel');
|
||||||
|
const title = panel?.querySelector<HTMLElement>('.record-title');
|
||||||
|
if (!panel || !title) throw new Error('record panel structure missing');
|
||||||
|
return {
|
||||||
|
bucket: bucket.dataset.recordBucket,
|
||||||
|
panel: panel.getBoundingClientRect().toJSON(),
|
||||||
|
title: title.getBoundingClientRect().toJSON(),
|
||||||
|
lineCount: bucket.querySelectorAll('.record-line').length,
|
||||||
|
titleStyle: {
|
||||||
|
fontSize: getComputedStyle(title).fontSize,
|
||||||
|
fontWeight: getComputedStyle(title).fontWeight,
|
||||||
|
lineHeight: getComputedStyle(title).lineHeight,
|
||||||
|
textAlign: getComputedStyle(title).textAlign,
|
||||||
|
borderTop: getComputedStyle(title).borderTop,
|
||||||
|
borderBottom: getComputedStyle(title).borderBottom,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' }));
|
||||||
|
redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
await postgres.connect();
|
||||||
|
await redis.connect();
|
||||||
|
prisma = postgres.prisma;
|
||||||
|
|
||||||
|
const general = await prisma.general.findFirst({
|
||||||
|
where: { userId: { not: null } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, userId: true, name: true },
|
||||||
|
});
|
||||||
|
if (!general?.userId) {
|
||||||
|
throw new Error('The live che profile needs an owned general.');
|
||||||
|
}
|
||||||
|
const world = await prisma.worldState.findFirst({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { currentYear: true, currentMonth: true },
|
||||||
|
});
|
||||||
|
if (!world) {
|
||||||
|
throw new Error('The live che profile needs a world state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const drafts = [
|
||||||
|
...Array.from({ length: 15 }, (_, index) => ({
|
||||||
|
scope: 'SYSTEM' as const,
|
||||||
|
category: 'SUMMARY' as const,
|
||||||
|
text: `<C>●</>${RECORD_MARKER} 장수 동향 ${15 - index}`,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: 7 }, (_, index) => ({
|
||||||
|
scope: 'GENERAL' as const,
|
||||||
|
category: 'ACTION' as const,
|
||||||
|
generalId: general.id,
|
||||||
|
text: `<Y>●</>${RECORD_MARKER} 개인 기록 ${7 - index}`,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: 15 }, (_, index) => ({
|
||||||
|
scope: 'SYSTEM' as const,
|
||||||
|
category: 'HISTORY' as const,
|
||||||
|
text: `<R>●</>${RECORD_MARKER} 중원 정세 ${15 - index}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const rows = await Promise.all(
|
||||||
|
drafts.map((draft) =>
|
||||||
|
prisma.logEntry.create({
|
||||||
|
data: {
|
||||||
|
...draft,
|
||||||
|
year: world.currentYear,
|
||||||
|
month: world.currentMonth,
|
||||||
|
meta: { fixture: RECORD_MARKER },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
createdIds = rows.map((row: { id: number }) => row.id);
|
||||||
|
|
||||||
|
const issuedAt = new Date();
|
||||||
|
const expiresAt = new Date(issuedAt.getTime() + 30 * 60 * 1000);
|
||||||
|
await redis.client.set(
|
||||||
|
accessKey(accessToken),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: issuedAt.toISOString(),
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
sessionId: `main-records-${randomUUID()}`,
|
||||||
|
user: {
|
||||||
|
id: general.userId,
|
||||||
|
username: 'main-records-live',
|
||||||
|
displayName: general.name,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
}),
|
||||||
|
{ EX: 1800 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (createdIds.length > 0) {
|
||||||
|
await prisma.logEntry.deleteMany({ where: { id: { in: createdIds } } });
|
||||||
|
}
|
||||||
|
await redis.client.del(accessKey(accessToken));
|
||||||
|
await redis.disconnect();
|
||||||
|
await postgres.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders all three real database record buckets with legacy computed geometry', async ({ page }) => {
|
||||||
|
const liveResponse = await page.request.get(
|
||||||
|
`http://127.0.0.1:15113/che/api/trpc/general.getRecentRecords?input=${encodeURIComponent(
|
||||||
|
JSON.stringify({
|
||||||
|
json: {
|
||||||
|
lastGeneralRecordId: 0,
|
||||||
|
lastWorldHistoryId: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)}`,
|
||||||
|
{ headers: { authorization: `Bearer ${accessToken}` } }
|
||||||
|
);
|
||||||
|
expect(liveResponse.ok()).toBe(true);
|
||||||
|
const livePayload = (await liveResponse.json()) as {
|
||||||
|
result: { data: { global: unknown[]; general: unknown[]; history: unknown[] } };
|
||||||
|
};
|
||||||
|
const liveRecords = livePayload.result.data;
|
||||||
|
expect([liveRecords.global.length, liveRecords.general.length, liveRecords.history.length]).toEqual([15, 7, 15]);
|
||||||
|
let failRecords = false;
|
||||||
|
|
||||||
|
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 === 'general.me') {
|
||||||
|
return trpcResponse({
|
||||||
|
general: {
|
||||||
|
id: 1,
|
||||||
|
name: '관리자',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 0,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
stats: { leadership: 55, strength: 55, intelligence: 55 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
injury: 0,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
city: null,
|
||||||
|
nation: null,
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'general.getRecentRecords') {
|
||||||
|
return failRecords
|
||||||
|
? trpcError(operation, '동향 정보를 불러오지 못했습니다.')
|
||||||
|
: trpcResponse(liveRecords);
|
||||||
|
}
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return trpcResponse({
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
userCnt: 1,
|
||||||
|
npcCnt: 0,
|
||||||
|
nationCnt: 0,
|
||||||
|
maxUserCnt: 50,
|
||||||
|
turnTerm: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
otherTextInfo: '',
|
||||||
|
starttime: '0190-01-01T00:00:00.000Z',
|
||||||
|
myGeneral: { id: 1, name: '관리자', nationId: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'world.getMapLayout') {
|
||||||
|
return trpcResponse({
|
||||||
|
mapName: 'che',
|
||||||
|
cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }],
|
||||||
|
regionMap: { 1: '사예' },
|
||||||
|
levelMap: { 7: '수도' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'world.getMap') {
|
||||||
|
return trpcResponse({
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
startYear: 190,
|
||||||
|
cityList: [[1, 7, 0, 0, 1, 1]],
|
||||||
|
nationList: [],
|
||||||
|
spyList: {},
|
||||||
|
shownByGeneralList: [],
|
||||||
|
myCity: 1,
|
||||||
|
myNation: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return trpcResponse({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
|
return trpcResponse([]);
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getRecent') {
|
||||||
|
return trpcResponse({
|
||||||
|
private: [],
|
||||||
|
public: [],
|
||||||
|
national: [],
|
||||||
|
diplomacy: [],
|
||||||
|
permission: 0,
|
||||||
|
latestRead: { private: 0, diplomacy: 0 },
|
||||||
|
canRespondDiplomacy: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getContacts') return trpcResponse([]);
|
||||||
|
if (operation === 'board.getAccess') {
|
||||||
|
return trpcResponse({ canMeeting: false, canSecret: false, permission: 0 });
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.getState') return trpcResponse({ stage: 0 });
|
||||||
|
if (operation === 'public.recordAccess') return trpcResponse({ recorded: true });
|
||||||
|
throw new Error(`Unhandled live main operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/image/**', async (route) => {
|
||||||
|
const source = new URL(route.request().url());
|
||||||
|
const response = await route.fetch({
|
||||||
|
url: `https://dev-sam-ref.hided.net${source.pathname}`,
|
||||||
|
});
|
||||||
|
await route.fulfill({ response });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('./', { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('[data-record-bucket="global"] .record-line')).toHaveCount(15);
|
||||||
|
await expect(page.locator('[data-record-bucket="general"] .record-line')).toHaveCount(7);
|
||||||
|
await expect(page.locator('[data-record-bucket="history"] .record-line')).toHaveCount(15);
|
||||||
|
await expect(page.locator('[data-record-bucket="global"]')).toContainText(RECORD_MARKER);
|
||||||
|
|
||||||
|
const desktop = await inspectRecordLayout(page);
|
||||||
|
expect(desktop.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]);
|
||||||
|
expect(desktop.panels[0]?.panel.width).toBeCloseTo(500, 0);
|
||||||
|
expect(desktop.panels[1]?.panel.x).toBeCloseTo(500, 0);
|
||||||
|
expect(desktop.panels[2]?.panel.width).toBeCloseTo(1000, 0);
|
||||||
|
expect(desktop.panels[0]?.panel.height).toBeCloseTo(338, 0);
|
||||||
|
expect(desktop.panels[2]?.panel.height).toBeCloseTo(338, 0);
|
||||||
|
expect(desktop.panels[0]?.title.height).toBeCloseTo(23, 0);
|
||||||
|
expect(desktop.panels[0]?.titleStyle).toMatchObject({
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '400',
|
||||||
|
lineHeight: '21px',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderTop: '1px solid rgb(128, 128, 128)',
|
||||||
|
borderBottom: '1px solid rgb(128, 128, 128)',
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.getByRole('button', { name: '동향', exact: true }).click();
|
||||||
|
await expect(page.locator('.record-zone-mobile')).toBeVisible();
|
||||||
|
const mobile = await inspectRecordLayout(page);
|
||||||
|
expect(mobile.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]);
|
||||||
|
expect(mobile.panels.every((panel) => Math.round(panel.panel.width) === 500)).toBe(true);
|
||||||
|
expect(mobile.panels[0]?.panel.height).toBeCloseTo(338, 0);
|
||||||
|
expect(mobile.panels[1]?.panel.height).toBeCloseTo(170, 0);
|
||||||
|
expect(mobile.panels[2]?.panel.height).toBeCloseTo(338, 0);
|
||||||
|
|
||||||
|
failRecords = true;
|
||||||
|
await page.getByRole('button', { name: '새로고침', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.');
|
||||||
|
await expect(page.getByRole('heading', { name: '장수 동향', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, extname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||||
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fulfill = async (route: Route, results: unknown[]) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const installImages = async (page: Page) => {
|
||||||
|
await page.route('**/image/**', async (route) => {
|
||||||
|
const pathname = decodeURIComponent(new URL(route.request().url()).pathname);
|
||||||
|
const relative = pathname.replace(/^\/image\//, '');
|
||||||
|
const candidates = imageRoots.flatMap((root) => [resolve(root, relative), resolve(root, 'game', relative)]);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const body = await readFile(candidate);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Worktree 위치에 따라 다음 image root를 확인한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await route.abort('failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedHistory = [
|
||||||
|
{ id: 31, text: '<Y>유비</>가 <C>촉</>을 건국하였습니다.' },
|
||||||
|
{ id: 30, text: '<S>낙양</>에 새로운 소식이 있습니다.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const frontRecords = {
|
||||||
|
global: [
|
||||||
|
{ id: 51, text: '<Y>관우</>가 장수 동향을 남겼습니다.' },
|
||||||
|
{ id: 50, text: '<C>조조</>가 이동하였습니다.' },
|
||||||
|
],
|
||||||
|
general: [{ id: 41, text: '<L>유비</>의 개인 기록입니다.' }],
|
||||||
|
history: cachedHistory,
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalContext = {
|
||||||
|
general: {
|
||||||
|
id: 1,
|
||||||
|
name: '유비',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
stats: { leadership: 80, strength: 70, intelligence: 75 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1200,
|
||||||
|
crew: 500,
|
||||||
|
train: 80,
|
||||||
|
atmos: 90,
|
||||||
|
injury: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 200,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
city: {
|
||||||
|
id: 1,
|
||||||
|
name: '낙양',
|
||||||
|
level: 7,
|
||||||
|
nationId: 1,
|
||||||
|
population: 10000,
|
||||||
|
agriculture: 100,
|
||||||
|
commerce: 100,
|
||||||
|
security: 100,
|
||||||
|
defence: 100,
|
||||||
|
wall: 100,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
id: 1,
|
||||||
|
name: '촉',
|
||||||
|
color: '#d32f2f',
|
||||||
|
level: 3,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 12000,
|
||||||
|
tech: 500,
|
||||||
|
typeCode: 'che_법가',
|
||||||
|
capitalCityId: 1,
|
||||||
|
},
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyMessages = {
|
||||||
|
private: [],
|
||||||
|
public: [],
|
||||||
|
national: [],
|
||||||
|
diplomacy: [],
|
||||||
|
permission: 0,
|
||||||
|
latestRead: { private: 0, diplomacy: 0 },
|
||||||
|
canRespondDiplomacy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const installGatewayMapFixture = async (page: Page) => {
|
||||||
|
await installImages(page);
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'me') return response(null);
|
||||||
|
if (operation === 'lobby.profiles') return response([fixture.gateway.profile]);
|
||||||
|
return response(null);
|
||||||
|
});
|
||||||
|
await fulfill(route, results);
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') return response(fixture.game.lobby);
|
||||||
|
if (operation === 'public.getMapLayout') return response(fixture.game.mapLayout);
|
||||||
|
if (operation === 'public.getCachedMap') {
|
||||||
|
return response({ ...fixture.game.map, history: cachedHistory });
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled gateway map operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await fulfill(route, results);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const installMainFixture = async (page: Page, failRecords = false) => {
|
||||||
|
await installImages(page);
|
||||||
|
await page.addInitScript(
|
||||||
|
({ token, profile }) => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', token);
|
||||||
|
window.localStorage.setItem('sammo-game-profile', profile);
|
||||||
|
},
|
||||||
|
{ token: fixture.game.session.gameToken, profile: fixture.game.session.profile }
|
||||||
|
);
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ ...fixture.game.lobby, myGeneral: fixture.game.session.general });
|
||||||
|
}
|
||||||
|
if (operation === 'general.me') return response(generalContext);
|
||||||
|
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||||
|
if (operation === 'world.getMap') {
|
||||||
|
return response({
|
||||||
|
...fixture.game.map,
|
||||||
|
spyList: {},
|
||||||
|
shownByGeneralList: [],
|
||||||
|
myCity: 1,
|
||||||
|
myNation: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral') return response([]);
|
||||||
|
if (operation === 'messages.getRecent') return response(emptyMessages);
|
||||||
|
if (operation === 'messages.getContacts') return response([]);
|
||||||
|
if (operation === 'board.getAccess') {
|
||||||
|
return response({ canMeeting: false, canSecret: false, permission: 0 });
|
||||||
|
}
|
||||||
|
if (operation === 'general.getRecentRecords') {
|
||||||
|
return failRecords
|
||||||
|
? errorResponse(operation, '동향 정보를 불러오지 못했습니다.')
|
||||||
|
: response(frontRecords);
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
|
if (operation === 'public.recordAccess') return response({ recorded: true });
|
||||||
|
return errorResponse(operation, `Unhandled main operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await fulfill(route, results);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shows the representative cached map and cached history before login', async ({ page }) => {
|
||||||
|
await installGatewayMapFixture(page);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto('http://127.0.0.1:15100/gateway/');
|
||||||
|
|
||||||
|
await expect(page.locator('.map-preview-body')).toBeVisible();
|
||||||
|
await expect(page.locator('.status-history')).toContainText('유비가 촉을 건국하였습니다.');
|
||||||
|
const historyStyle = await page.locator('.status-history').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return { width: rect.width, fontFamily: style.fontFamily, fontSize: style.fontSize };
|
||||||
|
});
|
||||||
|
expect(historyStyle.width).toBeCloseTo(698, 0);
|
||||||
|
expect(historyStyle.fontFamily).toContain('Times New Roman');
|
||||||
|
expect(historyStyle.fontSize).toBe('14px');
|
||||||
|
await expect(page.locator('.status-history span').first()).toHaveCSS('color', 'rgb(255, 255, 0)');
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'gateway-cached-map-history.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the current in-game map and all three recent record streams', async ({ page }) => {
|
||||||
|
await installMainFixture(page);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/');
|
||||||
|
|
||||||
|
await expect(page.locator('.map-area')).toBeVisible();
|
||||||
|
await expect(page.getByText('관우가 장수 동향을 남겼습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('유비의 개인 기록입니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('유비가 촉을 건국하였습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('실시간 스트림으로 연결 예정')).toHaveCount(0);
|
||||||
|
|
||||||
|
const geometry = await page.locator('.map-area').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { width: rect.width, height: rect.height };
|
||||||
|
});
|
||||||
|
expect(geometry).toEqual({ width: 700, height: 500 });
|
||||||
|
await expect(page.locator('[data-record-bucket="global"] .record-line span').first()).toHaveCSS(
|
||||||
|
'color',
|
||||||
|
'rgb(255, 255, 0)'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'ingame-map-trends-desktop.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the current map visible when the recent record request fails', async ({ page }) => {
|
||||||
|
await installMainFixture(page, true);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/');
|
||||||
|
|
||||||
|
await expect(page.locator('.map-area')).toBeVisible();
|
||||||
|
await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.');
|
||||||
|
await expect(page.getByRole('link', { name: '낙양', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows map and trend tabs in the mobile in-game layout', async ({ page }) => {
|
||||||
|
await installMainFixture(page);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/');
|
||||||
|
|
||||||
|
await expect(page.locator('.map-area')).toBeVisible();
|
||||||
|
const mapGeometry = await page.locator('.map-area').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { width: rect.width, height: rect.height };
|
||||||
|
});
|
||||||
|
expect(mapGeometry.width).toBe(438);
|
||||||
|
expect(mapGeometry.height).toBeCloseTo(312.86, 1);
|
||||||
|
expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '동향', exact: true }).click();
|
||||||
|
await expect(page.getByText('관우가 장수 동향을 남겼습니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('유비의 개인 기록입니다.')).toBeVisible();
|
||||||
|
await expect(page.getByText('유비가 촉을 건국하였습니다.')).toBeVisible();
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'ingame-map-trends-mobile.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
'tournament-betting.spec.ts',
|
'tournament-betting.spec.ts',
|
||||||
'dynasty-parity.spec.ts',
|
'dynasty-parity.spec.ts',
|
||||||
'inheritance-management.spec.ts',
|
'inheritance-management.spec.ts',
|
||||||
|
'map-trend.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||||
|
const passwordFile =
|
||||||
|
process.env.REF_USER_PASSWORD_FILE ??
|
||||||
|
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
|
||||||
|
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`reference login failed: HTTP ${response.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1000, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||||
|
await page.locator('.RecordZone').waitFor({ state: 'visible' });
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
[...document.querySelectorAll('.RecordZone > div')].every((panel) => panel.children.length > 1)
|
||||||
|
);
|
||||||
|
const measurement = await page.evaluate(() => {
|
||||||
|
const inspect = (selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
throw new Error(`missing ${selector}`);
|
||||||
|
}
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
display: style.display,
|
||||||
|
padding: style.padding,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderTop: style.borderTop,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
zone: inspect('.RecordZone'),
|
||||||
|
global: inspect('.PublicRecord'),
|
||||||
|
general: inspect('.GeneralLog'),
|
||||||
|
history: inspect('.WorldHistory'),
|
||||||
|
title: inspect('.PublicRecord .title'),
|
||||||
|
firstLine: inspect('.PublicRecord > div:nth-child(2)'),
|
||||||
|
counts: {
|
||||||
|
global: document.querySelectorAll('.PublicRecord > div:not(.title)').length,
|
||||||
|
general: document.querySelectorAll('.GeneralLog > div:not(.title)').length,
|
||||||
|
history: document.querySelectorAll('.WorldHistory > div:not(.title)').length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ viewport, measurement }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const pm2Candidates = [
|
||||||
|
'/home/letrhee/.nvm/versions/node/v22.15.1/bin/pm2',
|
||||||
|
'/home/letrhee/.nvm/versions/node/v24.13.0/bin/pm2',
|
||||||
|
];
|
||||||
|
|
||||||
|
let processes;
|
||||||
|
for (const candidate of pm2Candidates) {
|
||||||
|
try {
|
||||||
|
const output = execFileSync(candidate, ['jlist'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
processes = JSON.parse(output.slice(output.indexOf('[')));
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// Try the next locally installed PM2 client.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!processes) {
|
||||||
|
throw new Error('Unable to read the local PM2 process list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = processes.find((process) => process.name === 'sammo-verify-che-api')?.pm2_env;
|
||||||
|
if (!source) {
|
||||||
|
throw new Error('sammo-verify-che-api is required as the live environment source.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = /^(DATABASE_URL|POSTGRES_|REDIS_|GAME_|GATEWAY_|PROFILE$|SCENARIO$)/;
|
||||||
|
const liveEnvironment = Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([key, value]) => allowed.test(key) && typeof value === 'string')
|
||||||
|
);
|
||||||
|
const result = spawnSync(
|
||||||
|
'pnpm',
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
'playwright',
|
||||||
|
'test',
|
||||||
|
'main-records.spec.ts',
|
||||||
|
'--config',
|
||||||
|
'tools/frontend-legacy-parity/main-records.playwright.config.mjs',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: { ...process.env, ...liveEnvironment },
|
||||||
|
stdio: 'inherit',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
process.exitCode = result.status ?? 1;
|
||||||
@@ -174,6 +174,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
if (operation === 'vote.getAdminStatus') return { ok: false };
|
if (operation === 'vote.getAdminStatus') return { ok: false };
|
||||||
|
if (operation === 'public.recordAccess') return { recorded: true };
|
||||||
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
|
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ interface GeneralCooldownSelector {
|
|||||||
actionName: string;
|
actionName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NationCooldownSelector {
|
||||||
|
nationId: number;
|
||||||
|
actionName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnCommandFixtureRequest {
|
export interface TurnCommandFixtureRequest {
|
||||||
kind: 'general' | 'nation';
|
kind: 'general' | 'nation';
|
||||||
actorGeneralId: number;
|
actorGeneralId: number;
|
||||||
@@ -63,6 +68,7 @@ export interface TurnCommandFixtureRequest {
|
|||||||
logAfterId?: number;
|
logAfterId?: number;
|
||||||
messageAfterId?: number;
|
messageAfterId?: number;
|
||||||
generalCooldowns?: GeneralCooldownSelector[];
|
generalCooldowns?: GeneralCooldownSelector[];
|
||||||
|
nationCooldowns?: NationCooldownSelector[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +479,7 @@ const projectWorld = (
|
|||||||
cityIds: Set<number>;
|
cityIds: Set<number>;
|
||||||
nationIds: Set<number>;
|
nationIds: Set<number>;
|
||||||
generalCooldowns: GeneralCooldownSelector[];
|
generalCooldowns: GeneralCooldownSelector[];
|
||||||
|
nationCooldowns: NationCooldownSelector[];
|
||||||
}
|
}
|
||||||
): CanonicalTurnSnapshot => {
|
): CanonicalTurnSnapshot => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
@@ -542,6 +549,15 @@ const projectWorld = (
|
|||||||
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
|
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
nationCooldowns: selector.nationCooldowns.map(({ nationId, actionName }) => {
|
||||||
|
const nation = world.getNationById(nationId);
|
||||||
|
const raw = nation?.meta[`next_execute_${actionName}`];
|
||||||
|
return {
|
||||||
|
nationId,
|
||||||
|
actionName,
|
||||||
|
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
generals,
|
generals,
|
||||||
rankData: world
|
rankData: world
|
||||||
@@ -656,6 +672,7 @@ export const runCoreTurnCommandTrace = async (
|
|||||||
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
|
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
|
||||||
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
|
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
|
||||||
generalCooldowns: request.observe?.generalCooldowns ?? [],
|
generalCooldowns: request.observe?.generalCooldowns ?? [],
|
||||||
|
nationCooldowns: request.observe?.nationCooldowns ?? [],
|
||||||
};
|
};
|
||||||
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
|
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
|
||||||
maxGeneralTurns: 10,
|
maxGeneralTurns: 10,
|
||||||
|
|||||||
@@ -311,7 +311,17 @@ const buildRequest = (
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
observe: {
|
observe: {
|
||||||
generalIds: [1, 2, 3, 4, 5, 6],
|
generalIds: [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
...Object.keys(fixturePatches.generals ?? {})
|
||||||
|
.map(Number)
|
||||||
|
.filter((id) => ![1, 2, 3, 4, 5, 6].includes(id)),
|
||||||
|
],
|
||||||
cityIds: [
|
cityIds: [
|
||||||
3,
|
3,
|
||||||
70,
|
70,
|
||||||
@@ -321,6 +331,7 @@ const buildRequest = (
|
|||||||
...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70),
|
...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70),
|
||||||
],
|
],
|
||||||
nationIds: [1, 2],
|
nationIds: [1, 2],
|
||||||
|
...(action === 'che_백성동원' ? { nationCooldowns: [{ nationId: 1, actionName: '백성동원' }] } : {}),
|
||||||
logAfterId: 0,
|
logAfterId: 0,
|
||||||
messageAfterId: 0,
|
messageAfterId: 0,
|
||||||
},
|
},
|
||||||
@@ -2701,3 +2712,255 @@ integration('nation event research turn, reserve, and duplicate-state boundaries
|
|||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mobilizePeopleBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
destCityId: unknown;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
completed?: boolean;
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedDefence?: number;
|
||||||
|
expectedWall?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a missing destination city',
|
||||||
|
destCityId: 9999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts a numeric string destination city ID',
|
||||||
|
destCityId: '70',
|
||||||
|
completed: true,
|
||||||
|
expectedPostReqTurn: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a source city occupied by another nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a destination city occupied by another nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 2 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } },
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied source city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedPostReqTurn: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied destination city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 70: { supplyState: 0 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedPostReqTurn: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows the actor city as destination',
|
||||||
|
destCityId: 3,
|
||||||
|
completed: true,
|
||||||
|
expectedPostReqTurn: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rounds the 80-percent defence and wall floors like MariaDB',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
70: {
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 5_001,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 5_001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedDefence: 4_001,
|
||||||
|
expectedWall: 4_001,
|
||||||
|
expectedPostReqTurn: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preserves defence and wall already above their 80-percent floors',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
70: {
|
||||||
|
defence: 4_500,
|
||||||
|
defenceMax: 5_000,
|
||||||
|
wall: 4_600,
|
||||||
|
wallMax: 5_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedDefence: 4_500,
|
||||||
|
expectedWall: 4_600,
|
||||||
|
expectedPostReqTurn: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist global-delay modifier',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } },
|
||||||
|
completed: true,
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
expectedPostReqTurn: 47,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { generalCount: 11 } },
|
||||||
|
generals: {
|
||||||
|
4: { nationId: 1 },
|
||||||
|
5: { nationId: 1 },
|
||||||
|
6: { nationId: 1 },
|
||||||
|
7: { nationId: 1 },
|
||||||
|
8: { nationId: 1 },
|
||||||
|
9: { nationId: 1 },
|
||||||
|
10: { nationId: 1 },
|
||||||
|
11: { nationId: 1 },
|
||||||
|
12: { nationId: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedPostReqTurn: 66,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation mobilize-people target, delay, and city-effect boundaries', () => {
|
||||||
|
it.each(mobilizePeopleBoundaryCases)(
|
||||||
|
'$name matches legacy fallback, cooldown, effects, logs, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
destCityId,
|
||||||
|
fixturePatches,
|
||||||
|
completed = false,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedDefence,
|
||||||
|
expectedWall,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
}) => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_백성동원',
|
||||||
|
{ destCityID: destCityId },
|
||||||
|
{
|
||||||
|
...fixturePatches,
|
||||||
|
cities: {
|
||||||
|
...fixturePatches?.cities,
|
||||||
|
70: {
|
||||||
|
nationId: 1,
|
||||||
|
...fixturePatches?.cities?.[70],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
const targetCityId = destCityId === 3 ? 3 : 70;
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
if (coreResolution) {
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_백성동원',
|
||||||
|
actionKey: completed ? 'che_백성동원' : '휴식',
|
||||||
|
usedFallback: !completed,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(core.execution.outcome).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snapshot of [reference, core]) {
|
||||||
|
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||||
|
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||||
|
const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||||
|
const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||||
|
const cityBefore = snapshot.before.cities.find((entry) => entry.id === targetCityId);
|
||||||
|
const cityAfter = snapshot.after.cities.find((entry) => entry.id === targetCityId);
|
||||||
|
|
||||||
|
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||||
|
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||||
|
expect(
|
||||||
|
readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience')
|
||||||
|
).toBe(completed ? 5 : 0);
|
||||||
|
expect(
|
||||||
|
readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication')
|
||||||
|
).toBe(completed ? 5 : 0);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit);
|
||||||
|
if (expectedDefence !== undefined) {
|
||||||
|
expect(readNumericField(cityAfter, 'defence')).toBe(expectedDefence);
|
||||||
|
}
|
||||||
|
if (expectedWall !== undefined) {
|
||||||
|
expect(readNumericField(cityAfter, 'wall')).toBe(expectedWall);
|
||||||
|
}
|
||||||
|
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||||
|
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('백성동원 발동')])
|
||||||
|
);
|
||||||
|
const broadcastFragment = '<M>백성동원</>을 하였습니다.';
|
||||||
|
expect(
|
||||||
|
addedLogs.some(
|
||||||
|
(entry) => entry.generalId === 3 && String(entry.text).includes(broadcastFragment)
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
addedLogs.some(
|
||||||
|
(entry) => entry.generalId === 2 && String(entry.text).includes(broadcastFragment)
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(addedLogs.some((entry) => String(entry.text).includes('에 <M>백성동원</>을 발동'))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
expect(readNumericField(cityAfter, 'defence')).toBe(readNumericField(cityBefore, 'defence'));
|
||||||
|
expect(readNumericField(cityAfter, 'wall')).toBe(readNumericField(cityBefore, 'wall'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||||
|
if (expectedPostReqTurn === 66) {
|
||||||
|
expect(reference.before.generals.filter((entry) => entry.nationId === 1)).toHaveLength(11);
|
||||||
|
expect(core.before.generals.filter((entry) => entry.nationId === 1)).toHaveLength(11);
|
||||||
|
}
|
||||||
|
expect(reference.after.world.nationCooldowns).toEqual([
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
actionName: '백성동원',
|
||||||
|
nextAvailableTurn: expectedNextAvailableTurn,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||||
|
}
|
||||||
|
expect(reference.rng).toEqual([]);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user