merge: refresh mobilize people parity branch
This commit is contained in:
@@ -15,6 +15,7 @@ const zGeneralSettings = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
|
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
|
||||||
|
const FRONT_RECORD_LIMIT = 15;
|
||||||
|
|
||||||
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 +320,49 @@ export const generalRouter = router({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
getFrontRecords: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
text: true,
|
||||||
|
} as const;
|
||||||
|
const orderBy = { id: 'desc' } as const;
|
||||||
|
|
||||||
|
const [global, general, history] = await Promise.all([
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
orderBy,
|
||||||
|
take: FRONT_RECORD_LIMIT,
|
||||||
|
}),
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: me.id,
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
orderBy,
|
||||||
|
take: FRONT_RECORD_LIMIT,
|
||||||
|
}),
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
orderBy,
|
||||||
|
take: FRONT_RECORD_LIMIT,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
global,
|
||||||
|
general,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,42 @@ 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.getFrontRecords()).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: 'ACTION' },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 15,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7 },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 15,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { scope: 'SYSTEM', category: 'HISTORY' },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 15,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('battle-center general and user permissions', () => {
|
describe('battle-center general and user permissions', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -24,11 +24,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
||||||
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
||||||
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
||||||
|
type FrontRecords = Awaited<ReturnType<typeof trpc.general.getFrontRecords.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];
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
const frontRecordsError = 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');
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const commandTable = ref<CommandTable | null>(null);
|
const commandTable = ref<CommandTable | null>(null);
|
||||||
const messages = ref<MessageBundle | null>(null);
|
const messages = ref<MessageBundle | null>(null);
|
||||||
const messageContacts = ref<MessageContacts | null>(null);
|
const messageContacts = ref<MessageContacts | null>(null);
|
||||||
|
const frontRecords = ref<FrontRecords | null>(null);
|
||||||
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);
|
||||||
@@ -197,6 +200,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
frontRecordsError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await trpc.general.me.query();
|
const context = await trpc.general.me.query();
|
||||||
@@ -217,7 +221,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
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 frontRecordsPromise = trpc.general.getFrontRecords.query().catch((err: unknown) => {
|
||||||
|
frontRecordsError.value = resolveErrorMessage(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [layout, lobby, map, commands, messageData, contacts, access, records, generalTurns, nationTurns] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
layoutPromise,
|
layoutPromise,
|
||||||
trpc.lobby.info.query(),
|
trpc.lobby.info.query(),
|
||||||
@@ -226,6 +234,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
trpc.messages.getRecent.query({ generalId: id }),
|
trpc.messages.getRecent.query({ generalId: id }),
|
||||||
trpc.messages.getContacts.query({ generalId: id }),
|
trpc.messages.getContacts.query({ generalId: id }),
|
||||||
trpc.board.getAccess.query(),
|
trpc.board.getAccess.query(),
|
||||||
|
frontRecordsPromise,
|
||||||
generalTurnsPromise,
|
generalTurnsPromise,
|
||||||
nationTurnsPromise,
|
nationTurnsPromise,
|
||||||
]);
|
]);
|
||||||
@@ -237,6 +246,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
messages.value = messageData;
|
messages.value = messageData;
|
||||||
messageContacts.value = contacts;
|
messageContacts.value = contacts;
|
||||||
boardAccess.value = access;
|
boardAccess.value = access;
|
||||||
|
frontRecords.value = records;
|
||||||
reservedGeneralTurns.value = generalTurns;
|
reservedGeneralTurns.value = generalTurns;
|
||||||
reservedNationTurns.value = nationTurns;
|
reservedNationTurns.value = nationTurns;
|
||||||
if (initializedMailboxGeneralId !== id) {
|
if (initializedMailboxGeneralId !== id) {
|
||||||
@@ -587,6 +597,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
frontRecordsError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
realtimeStatus,
|
realtimeStatus,
|
||||||
generalContext,
|
generalContext,
|
||||||
@@ -600,6 +611,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
commandTable,
|
commandTable,
|
||||||
messages,
|
messages,
|
||||||
messageContacts,
|
messageContacts,
|
||||||
|
frontRecords,
|
||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 RecentLogList from '../components/main/RecentLogList.vue';
|
||||||
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';
|
||||||
@@ -35,16 +36,17 @@ const tournamentStage = ref(0);
|
|||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
frontRecordsError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
nation,
|
nation,
|
||||||
lobbyInfo,
|
|
||||||
worldMap,
|
worldMap,
|
||||||
mapLayout,
|
mapLayout,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
commandTable,
|
commandTable,
|
||||||
messages,
|
messages,
|
||||||
|
frontRecords,
|
||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
@@ -206,19 +208,18 @@ watch(
|
|||||||
<div v-if="mobileTab === 'world'" class="mobile-panel">
|
<div v-if="mobileTab === 'world'" class="mobile-panel">
|
||||||
<PanelCard title="장수 동향">
|
<PanelCard title="장수 동향">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
|
||||||
|
<RecentLogList v-else :logs="frontRecords?.global" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="개인 기록">
|
<PanelCard title="개인 기록">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">개인 기록 영역</div>
|
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
|
||||||
|
<RecentLogList v-else :logs="frontRecords?.general" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="중원 정세">
|
<PanelCard title="중원 정세">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">
|
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
|
||||||
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
<RecentLogList v-else :logs="frontRecords?.history" />
|
||||||
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
|
||||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,12 +256,9 @@ watch(
|
|||||||
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="중원 정세">
|
<PanelCard title="중원 정세">
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">
|
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
|
||||||
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
<RecentLogList v-else :logs="frontRecords?.history" />
|
||||||
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
|
||||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,7 +282,8 @@ watch(
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 동향">
|
<PanelCard title="장수 동향">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
|
||||||
|
<RecentLogList v-else :logs="frontRecords?.global" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보">
|
<PanelCard title="도시 정보">
|
||||||
<CityBasicCard :city="city" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
@@ -294,7 +293,8 @@ watch(
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="개인 기록">
|
<PanelCard title="개인 기록">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">개인 기록 영역</div>
|
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
|
||||||
|
<RecentLogList v-else :logs="frontRecords?.general" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
<MessagePanel
|
<MessagePanel
|
||||||
@@ -339,6 +339,10 @@ watch(
|
|||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-error {
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
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.getFrontRecords') {
|
||||||
|
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('.recent-log-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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user