Merge branch 'main' into feature/main-signup-kakao-gate
# Conflicts: # app/gateway-frontend/src/views/LobbyView.vue
This commit is contained in:
@@ -5,7 +5,9 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
|
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
import { resolveNationNotice } from '../nation/shared.js';
|
||||||
|
|
||||||
const zGeneralSettings = z.object({
|
const zGeneralSettings = z.object({
|
||||||
tnmt: z.number().int().optional(),
|
tnmt: z.number().int().optional(),
|
||||||
@@ -381,4 +383,119 @@ export const generalRouter = router({
|
|||||||
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
getFrontStatus: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const worldState = await ctx.db.worldState.findFirst({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: {
|
||||||
|
tickSeconds: true,
|
||||||
|
meta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
||||||
|
const [onlineAccess, ownNation, latestVote] = await Promise.all([
|
||||||
|
ctx.db.generalAccessLog.findMany({
|
||||||
|
where: {
|
||||||
|
lastRefresh: {
|
||||||
|
gte: scoreStartedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { generalId: true },
|
||||||
|
}),
|
||||||
|
me.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({
|
||||||
|
where: { id: me.nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
ctx.db.votePoll.findFirst({
|
||||||
|
where: {
|
||||||
|
startAt: { lte: now },
|
||||||
|
closedAt: null,
|
||||||
|
OR: [{ endAt: null }, { endAt: { gte: now } }],
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||||
|
const onlineGenerals =
|
||||||
|
onlineGeneralIds.length > 0
|
||||||
|
? await ctx.db.general.findMany({
|
||||||
|
where: { id: { in: onlineGeneralIds } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nationId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const nationIds = [...new Set(onlineGenerals.map((general) => general.nationId).filter((id) => id > 0))];
|
||||||
|
const nations =
|
||||||
|
nationIds.length > 0
|
||||||
|
? await ctx.db.nation.findMany({
|
||||||
|
where: { id: { in: nationIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const nationNames = new Map(nations.map((nation) => [nation.id, nation.name]));
|
||||||
|
const onlineByNation = new Map<number, typeof onlineGenerals>();
|
||||||
|
for (const general of onlineGenerals) {
|
||||||
|
const bucket = onlineByNation.get(general.nationId) ?? [];
|
||||||
|
bucket.push(general);
|
||||||
|
onlineByNation.set(general.nationId, bucket);
|
||||||
|
}
|
||||||
|
const onlineNations = [...onlineByNation.entries()]
|
||||||
|
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
|
||||||
|
.map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`)
|
||||||
|
.join(', ');
|
||||||
|
const myOnlineGenerals = onlineGenerals
|
||||||
|
.filter((general) => general.nationId === me.nationId)
|
||||||
|
.map((general) => general.name)
|
||||||
|
.join(', ');
|
||||||
|
const myVote = latestVote
|
||||||
|
? await ctx.db.vote.findFirst({
|
||||||
|
where: {
|
||||||
|
voteId: latestVote.id,
|
||||||
|
generalId: me.id,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const worldMeta = asRecord(worldState.meta);
|
||||||
|
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||||
|
const parsedLastExecuted =
|
||||||
|
typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onlineUserCount: onlineGenerals.length,
|
||||||
|
onlineNations,
|
||||||
|
onlineGenerals: myOnlineGenerals,
|
||||||
|
nationNotice: ownNation ? resolveNationNotice(asRecord(ownNation.meta)) : '',
|
||||||
|
lastExecuted:
|
||||||
|
parsedLastExecuted && Number.isFinite(parsedLastExecuted.getTime())
|
||||||
|
? parsedLastExecuted.toISOString()
|
||||||
|
: null,
|
||||||
|
latestVote: latestVote
|
||||||
|
? {
|
||||||
|
id: latestVote.id,
|
||||||
|
title: latestVote.title,
|
||||||
|
hasVoted: Boolean(myVote),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
|
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: 'front-status-owner',
|
||||||
|
user: {
|
||||||
|
id: 'owner',
|
||||||
|
username: 'owner',
|
||||||
|
displayName: 'Owner',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean } = {}) =>
|
||||||
|
({
|
||||||
|
auth: options.auth === undefined ? auth : options.auth,
|
||||||
|
db: {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
id: 7,
|
||||||
|
userId: 'owner',
|
||||||
|
nationId: 2,
|
||||||
|
})),
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{ id: 7, name: '유비', nationId: 2 },
|
||||||
|
{ id: 8, name: '관우', nationId: 2 },
|
||||||
|
{ id: 9, name: '조조', nationId: 3 },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
tickSeconds: 3600,
|
||||||
|
meta: {
|
||||||
|
lastTurnTime: '2026-07-26T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
generalAccessLog: {
|
||||||
|
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findUnique: vi.fn(async () => ({
|
||||||
|
meta: {
|
||||||
|
notice: '<p>북벌 준비</p>',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{ id: 2, name: '촉' },
|
||||||
|
{ id: 3, name: '위' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
votePoll: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
id: 12,
|
||||||
|
title: '다음 시즌 턴 시간',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
vote: {
|
||||||
|
findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)),
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseClient,
|
||||||
|
}) as GameApiContext;
|
||||||
|
|
||||||
|
describe('general.getFrontStatus', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-07-26T10:30:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => {
|
||||||
|
const context = buildContext();
|
||||||
|
const caller = appRouter.createCaller(context);
|
||||||
|
|
||||||
|
const result = await caller.general.getFrontStatus();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
onlineUserCount: 3,
|
||||||
|
onlineNations: '【촉】, 【위】',
|
||||||
|
onlineGenerals: '유비, 관우',
|
||||||
|
nationNotice: '<p>북벌 준비</p>',
|
||||||
|
lastExecuted: '2026-07-26T10:00:00.000Z',
|
||||||
|
latestVote: {
|
||||||
|
id: 12,
|
||||||
|
title: '다음 시즌 턴 시간',
|
||||||
|
hasVoted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
lastRefresh: {
|
||||||
|
gte: new Date('2026-07-26T10:00:00.000Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { generalId: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports that the session-owned general already voted', async () => {
|
||||||
|
const caller = appRouter.createCaller(buildContext({ hasVoted: true }));
|
||||||
|
|
||||||
|
await expect(caller.general.getFrontStatus()).resolves.toMatchObject({
|
||||||
|
latestVote: {
|
||||||
|
id: 12,
|
||||||
|
hasVoted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a game session and does not expose names or policy publicly', async () => {
|
||||||
|
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
||||||
|
|
||||||
|
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -482,12 +482,9 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null {
|
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null {
|
||||||
if (srcNationId === destNationId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const key = buildDiplomacyKey(srcNationId, destNationId);
|
const key = buildDiplomacyKey(srcNationId, destNationId);
|
||||||
let entry = this.diplomacy.get(key);
|
let entry = this.diplomacy.get(key);
|
||||||
if (!entry && this.nations.has(srcNationId) && this.nations.has(destNationId)) {
|
if (!entry && srcNationId !== destNationId && this.nations.has(srcNationId) && this.nations.has(destNationId)) {
|
||||||
entry = buildDefaultDiplomacy(srcNationId, destNationId);
|
entry = buildDefaultDiplomacy(srcNationId, destNationId);
|
||||||
this.diplomacy.set(key, entry);
|
this.diplomacy.set(key, entry);
|
||||||
this.dirtyDiplomacyKeys.add(key);
|
this.dirtyDiplomacyKeys.add(key);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
status: {
|
||||||
|
onlineUserCount: number;
|
||||||
|
onlineNations: string;
|
||||||
|
onlineGenerals: string;
|
||||||
|
nationNotice: string;
|
||||||
|
lastExecuted: string | null;
|
||||||
|
latestVote: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
hasVoted: boolean;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
||||||
|
<div class="status-row vote-status">
|
||||||
|
<RouterLink v-if="status?.latestVote" to="/survey">
|
||||||
|
<span class="vote-label">설문 진행 중: </span>{{ status.latestVote.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else class="vote-empty">진행중인 설문 없음</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
|
||||||
|
<div class="status-row online-users">【 접속자 】 {{ status?.onlineGenerals ?? '' }}</div>
|
||||||
|
<div class="status-row nation-notice">
|
||||||
|
<div class="notice-title">【 국가방침 】</div>
|
||||||
|
<!-- 레거시 국가 방침은 같은 저장 형식의 HTML 본문을 그대로 표시한다. -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div class="nation-notice-body" v-html="status?.nationNotice ?? ''" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.front-status {
|
||||||
|
width: calc(100% + 48px);
|
||||||
|
margin-left: -24px;
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 36px;
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-notice {
|
||||||
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-notice-body {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-notice-body :deep(p) {
|
||||||
|
min-height: 1em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-status {
|
||||||
|
width: 33.333333%;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-status a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: gray underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-label {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-empty {
|
||||||
|
color: magenta;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.vote-status {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,10 +27,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
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];
|
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||||
|
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||||
|
|
||||||
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 recordsError = ref<string | null>(null);
|
||||||
|
const frontStatusError = 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');
|
||||||
|
|
||||||
@@ -47,6 +49,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const globalRecords = ref<RecentRecord[]>([]);
|
const globalRecords = ref<RecentRecord[]>([]);
|
||||||
const generalRecords = ref<RecentRecord[]>([]);
|
const generalRecords = ref<RecentRecord[]>([]);
|
||||||
const worldHistory = ref<RecentRecord[]>([]);
|
const worldHistory = ref<RecentRecord[]>([]);
|
||||||
|
const frontStatus = ref<FrontStatus | null>(null);
|
||||||
|
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
||||||
let lastGeneralRecordId = 0;
|
let lastGeneralRecordId = 0;
|
||||||
let lastWorldHistoryId = 0;
|
let lastWorldHistoryId = 0;
|
||||||
let recordGeneralId: number | null = null;
|
let recordGeneralId: number | null = null;
|
||||||
@@ -199,6 +203,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateFrontStatus = (nextStatus: FrontStatus) => {
|
||||||
|
frontStatus.value = nextStatus;
|
||||||
|
const latestVote = nextStatus.latestVote;
|
||||||
|
if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') {
|
||||||
|
surveyNotice.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serverId = session.profile?.split(':', 1)[0] ?? 'game';
|
||||||
|
const storageKey = `state.${serverId}.lastVote`;
|
||||||
|
const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10);
|
||||||
|
if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) {
|
||||||
|
surveyNotice.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(storageKey, latestVote.id.toString());
|
||||||
|
surveyNotice.value = latestVote;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSurveyNotice = () => {
|
||||||
|
surveyNotice.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
|
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
|
||||||
const merged = new Map(current.map((entry) => [entry.id, entry]));
|
const merged = new Map(current.map((entry) => [entry.id, entry]));
|
||||||
for (const entry of incoming) {
|
for (const entry of incoming) {
|
||||||
@@ -214,6 +240,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
lastGeneralRecordId = 0;
|
lastGeneralRecordId = 0;
|
||||||
lastWorldHistoryId = 0;
|
lastWorldHistoryId = 0;
|
||||||
recordGeneralId = id;
|
recordGeneralId = id;
|
||||||
|
frontStatus.value = null;
|
||||||
|
surveyNotice.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMainData = async () => {
|
const loadMainData = async () => {
|
||||||
@@ -223,6 +251,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
recordsError.value = null;
|
recordsError.value = null;
|
||||||
|
frontStatusError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await trpc.general.me.query();
|
const context = await trpc.general.me.query();
|
||||||
@@ -256,19 +285,35 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
recordsError.value = resolveErrorMessage(err);
|
recordsError.value = resolveErrorMessage(err);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] =
|
const frontStatusPromise = trpc.general.getFrontStatus.query().catch((err: unknown) => {
|
||||||
await Promise.all([
|
frontStatusError.value = resolveErrorMessage(err);
|
||||||
layoutPromise,
|
return null;
|
||||||
trpc.lobby.info.query(),
|
});
|
||||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
const [
|
||||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
layout,
|
||||||
trpc.messages.getRecent.query({ generalId: id }),
|
lobby,
|
||||||
trpc.messages.getContacts.query({ generalId: id }),
|
map,
|
||||||
trpc.board.getAccess.query(),
|
commands,
|
||||||
generalTurnsPromise,
|
messageData,
|
||||||
nationTurnsPromise,
|
contacts,
|
||||||
recordsPromise,
|
access,
|
||||||
]);
|
generalTurns,
|
||||||
|
nationTurns,
|
||||||
|
records,
|
||||||
|
nextFrontStatus,
|
||||||
|
] = await Promise.all([
|
||||||
|
layoutPromise,
|
||||||
|
trpc.lobby.info.query(),
|
||||||
|
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||||
|
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||||
|
trpc.messages.getRecent.query({ generalId: id }),
|
||||||
|
trpc.messages.getContacts.query({ generalId: id }),
|
||||||
|
trpc.board.getAccess.query(),
|
||||||
|
generalTurnsPromise,
|
||||||
|
nationTurnsPromise,
|
||||||
|
recordsPromise,
|
||||||
|
frontStatusPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
mapLayout.value = layout;
|
mapLayout.value = layout;
|
||||||
lobbyInfo.value = lobby;
|
lobbyInfo.value = lobby;
|
||||||
@@ -290,6 +335,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
);
|
);
|
||||||
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
|
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
|
||||||
}
|
}
|
||||||
|
if (nextFrontStatus) {
|
||||||
|
updateFrontStatus(nextFrontStatus);
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -639,6 +687,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
recordsError,
|
recordsError,
|
||||||
|
frontStatusError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
realtimeStatus,
|
realtimeStatus,
|
||||||
generalContext,
|
generalContext,
|
||||||
@@ -658,12 +707,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
globalRecords,
|
globalRecords,
|
||||||
generalRecords,
|
generalRecords,
|
||||||
worldHistory,
|
worldHistory,
|
||||||
|
frontStatus,
|
||||||
|
surveyNotice,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxGroups,
|
mailboxGroups,
|
||||||
statusLine,
|
statusLine,
|
||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
setRealtimeEnabled,
|
setRealtimeEnabled,
|
||||||
|
dismissSurveyNotice,
|
||||||
loadMainData,
|
loadMainData,
|
||||||
refreshMessages,
|
refreshMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { onUnmounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
@@ -12,6 +12,7 @@ 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 RecordPanel from '../components/main/RecordPanel.vue';
|
||||||
|
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
|
||||||
import { formatLog } from '../utils/formatLog';
|
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';
|
||||||
@@ -38,6 +39,7 @@ const {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
recordsError,
|
recordsError,
|
||||||
|
frontStatusError,
|
||||||
realtimeEnabled,
|
realtimeEnabled,
|
||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
@@ -53,6 +55,8 @@ const {
|
|||||||
globalRecords,
|
globalRecords,
|
||||||
generalRecords,
|
generalRecords,
|
||||||
worldHistory,
|
worldHistory,
|
||||||
|
frontStatus,
|
||||||
|
surveyNotice,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxGroups,
|
mailboxGroups,
|
||||||
@@ -60,6 +64,22 @@ const {
|
|||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
} = storeToRefs(dashboard);
|
} = storeToRefs(dashboard);
|
||||||
|
|
||||||
|
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
watch(surveyNotice, (notice) => {
|
||||||
|
if (surveyNoticeTimer) {
|
||||||
|
clearTimeout(surveyNoticeTimer);
|
||||||
|
surveyNoticeTimer = null;
|
||||||
|
}
|
||||||
|
if (notice) {
|
||||||
|
surveyNoticeTimer = setTimeout(() => dashboard.dismissSurveyNotice(), 60_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (surveyNoticeTimer) {
|
||||||
|
clearTimeout(surveyNoticeTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const reserveGeneralTurn = (payload: { index: number; action: string; args: Record<string, unknown> }) => {
|
const reserveGeneralTurn = (payload: { index: number; action: string; args: Record<string, unknown> }) => {
|
||||||
void dashboard.setGeneralTurn(payload.index, payload.action, payload.args);
|
void dashboard.setGeneralTurn(payload.index, payload.action, payload.args);
|
||||||
};
|
};
|
||||||
@@ -153,11 +173,22 @@ watch(
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
|
||||||
|
|
||||||
<div v-if="session.needsGeneral" class="warning">
|
<div v-if="session.needsGeneral" class="warning">
|
||||||
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MainFrontStatus :status="frontStatus" />
|
||||||
|
|
||||||
|
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||||||
|
<div class="survey-notice-title">
|
||||||
|
<strong>설문조사 안내</strong>
|
||||||
|
<button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<section v-if="isMobile" class="layout-mobile">
|
<section v-if="isMobile" class="layout-mobile">
|
||||||
<div class="mobile-tabs">
|
<div class="mobile-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -453,11 +484,62 @@ button {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.front-status-error {
|
||||||
|
color: #ff8a80;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: #f5d08a;
|
color: #f5d08a;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.survey-notice {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1080;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(350px, calc(100vw - 32px));
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.75);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(32, 28, 16, 0.96);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-notice-title {
|
||||||
|
display: flex;
|
||||||
|
min-height: 35px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid rgba(255, 193, 7, 0.45);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-notice-title button {
|
||||||
|
padding: 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-notice > a {
|
||||||
|
display: block;
|
||||||
|
padding: 12px;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-notice > a:hover,
|
||||||
|
.survey-notice > a:focus-visible {
|
||||||
|
background: rgba(255, 193, 7, 0.12);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-desktop {
|
.layout-desktop {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
|
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
|
||||||
|
|||||||
@@ -394,6 +394,24 @@ describe('gateway auth flow', () => {
|
|||||||
|
|
||||||
expect(validated?.user.username).toBe('tester');
|
expect(validated?.user.username).toBe('tester');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('revokes the gateway session and every linked game session on logout', async () => {
|
||||||
|
const { caller, users, sessions } = buildCaller();
|
||||||
|
const user = await users.createUser({
|
||||||
|
username: 'logout-user',
|
||||||
|
password: 'secretpass',
|
||||||
|
});
|
||||||
|
const session = await sessions.createSession(user);
|
||||||
|
const gameSession = await sessions.createGameSession(session.sessionToken, 'che:default');
|
||||||
|
expect(gameSession).not.toBeNull();
|
||||||
|
|
||||||
|
await caller.auth.logout({ sessionToken: session.sessionToken });
|
||||||
|
|
||||||
|
expect(await sessions.getSession(session.sessionToken)).toBeNull();
|
||||||
|
expect(
|
||||||
|
gameSession ? await sessions.getGameSession(gameSession.profile, gameSession.gameToken) : undefined
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('account self service', () => {
|
describe('account self service', () => {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32603,
|
||||||
|
data: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
httpStatus: 500,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const installLobbyFixture = async (page: Page, options: { failLogout?: boolean } = {}) => {
|
||||||
|
let loggedOut = false;
|
||||||
|
let logoutBody = '';
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-session-token', 'playwright-session');
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'playwright-game-session');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'me') {
|
||||||
|
return response(
|
||||||
|
loggedOut
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'tester',
|
||||||
|
displayName: '테스터',
|
||||||
|
roles: [],
|
||||||
|
createdAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation === 'lobby.notice') {
|
||||||
|
return response('');
|
||||||
|
}
|
||||||
|
if (operation === 'lobby.profiles') {
|
||||||
|
return response([]);
|
||||||
|
}
|
||||||
|
if (operation === 'auth.logout') {
|
||||||
|
logoutBody = route.request().postData() ?? '';
|
||||||
|
if (options.failLogout) {
|
||||||
|
return errorResponse(operation, '로그아웃 서버가 응답하지 않습니다.');
|
||||||
|
}
|
||||||
|
loggedOut = true;
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
logoutBody: () => logoutBody,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test('logs out through the server before clearing all browser session state', async ({ page }) => {
|
||||||
|
const fixture = await installLobbyFixture(page);
|
||||||
|
await page.goto('lobby');
|
||||||
|
|
||||||
|
const logout = page.locator('#btn_logout');
|
||||||
|
await expect(logout).toBeVisible();
|
||||||
|
const before = await logout.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
border: style.border,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(before).toMatchObject({
|
||||||
|
width: 200,
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: 'rgb(48, 48, 48)',
|
||||||
|
border: '0px none rgb(255, 255, 255)',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: '24px',
|
||||||
|
});
|
||||||
|
|
||||||
|
await logout.hover();
|
||||||
|
await logout.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/gateway\/$/);
|
||||||
|
expect(fixture.logoutBody()).toContain('playwright-session');
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
session: window.localStorage.getItem('sammo-session-token'),
|
||||||
|
game: window.localStorage.getItem('sammo-game-token'),
|
||||||
|
profile: window.localStorage.getItem('sammo-game-profile'),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.toEqual({ session: null, game: null, profile: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the lobby and every token when server logout fails', async ({ page }) => {
|
||||||
|
await installLobbyFixture(page, { failLogout: true });
|
||||||
|
await page.goto('lobby');
|
||||||
|
|
||||||
|
await page.locator('#btn_logout').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||||
|
await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.');
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
session: window.localStorage.getItem('sammo-session-token'),
|
||||||
|
game: window.localStorage.getItem('sammo-game-token'),
|
||||||
|
profile: window.localStorage.getItem('sammo-game-profile'),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.toEqual({
|
||||||
|
session: 'playwright-session',
|
||||||
|
game: 'playwright-game-session',
|
||||||
|
profile: 'che:default',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts'],
|
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts', 'logout.spec.ts'],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ const profiles = ref<LobbyProfile[]>([]);
|
|||||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||||
const entryLoading = ref<Record<string, boolean>>({});
|
const entryLoading = ref<Record<string, boolean>>({});
|
||||||
|
const logoutLoading = ref(false);
|
||||||
|
const logoutError = ref('');
|
||||||
const canAccessAdmin = computed(
|
const canAccessAdmin = computed(
|
||||||
() =>
|
() =>
|
||||||
me.value?.roles.some(
|
me.value?.roles.some(
|
||||||
(role) =>
|
(role) =>
|
||||||
role === 'superuser' ||
|
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
||||||
role === 'admin' ||
|
|
||||||
role === 'admin.superuser' ||
|
|
||||||
role.startsWith('admin.')
|
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
||||||
@@ -86,12 +85,28 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
if (logoutLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logoutError.value = '';
|
||||||
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
||||||
if (sessionToken) {
|
if (!sessionToken) {
|
||||||
|
await router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logoutLoading.value = true;
|
||||||
|
try {
|
||||||
await trpc.auth.logout.mutate({ sessionToken });
|
await trpc.auth.logout.mutate({ sessionToken });
|
||||||
window.localStorage.removeItem('sammo-session-token');
|
window.localStorage.removeItem('sammo-session-token');
|
||||||
|
window.localStorage.removeItem('sammo-game-token');
|
||||||
|
window.localStorage.removeItem('sammo-game-profile');
|
||||||
|
me.value = null;
|
||||||
|
await router.replace('/');
|
||||||
|
} catch (error) {
|
||||||
|
logoutError.value = error instanceof Error ? error.message : '로그아웃에 실패했습니다.';
|
||||||
|
} finally {
|
||||||
|
logoutLoading.value = false;
|
||||||
}
|
}
|
||||||
await router.push('/');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKakaoVerification = async (): Promise<void> => {
|
const handleKakaoVerification = async (): Promise<void> => {
|
||||||
@@ -404,9 +419,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">
|
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
||||||
지도를 불러오는 중...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</div>
|
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,10 +441,12 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
비밀번호 & 전콘 & 탈퇴
|
비밀번호 & 전콘 & 탈퇴
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button
|
<button
|
||||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
id="btn_logout"
|
||||||
|
class="legacy-logout-button"
|
||||||
|
:disabled="logoutLoading"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
로 그 아 웃
|
{{ logoutLoading ? '로그아웃 중…' : '로 그 아 웃' }}
|
||||||
</button>
|
</button>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="canAccessAdmin"
|
v-if="canAccessAdmin"
|
||||||
@@ -441,7 +456,45 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
관리자 페이지
|
관리자 페이지
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="logoutError" class="px-6 pb-4 text-center text-sm text-red-400" role="alert">
|
||||||
|
{{ logoutError }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.legacy-logout-button {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 200px;
|
||||||
|
height: 48px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #303030;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: Pretendard, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 24px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-logout-button:hover,
|
||||||
|
.legacy-logout-button:focus,
|
||||||
|
.legacy-logout-button:active {
|
||||||
|
background: #303030;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-logout-button:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-logout-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface DatabaseClient {
|
|||||||
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
||||||
boardPost: GamePrisma.BoardPostDelegate;
|
boardPost: GamePrisma.BoardPostDelegate;
|
||||||
boardComment: GamePrisma.BoardCommentDelegate;
|
boardComment: GamePrisma.BoardCommentDelegate;
|
||||||
|
votePoll: GamePrisma.VotePollDelegate;
|
||||||
|
vote: GamePrisma.VoteDelegate;
|
||||||
inputEvent: GamePrisma.InputEventDelegate;
|
inputEvent: GamePrisma.InputEventDelegate;
|
||||||
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
|
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ import { z } from 'zod';
|
|||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destNationId: z.preprocess(
|
destNationId: z.number().int().positive(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value) : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
export type RaidArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type RaidArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,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 DeceptionArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type DeceptionArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -59,7 +56,7 @@ const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: numb
|
|||||||
|
|
||||||
const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => {
|
const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => {
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return destCityId;
|
throw new RangeError('Cannot choose a deception destination from an empty city collection.');
|
||||||
}
|
}
|
||||||
let idx = legacyChoiceIndex(rng, candidates.length);
|
let idx = legacyChoiceIndex(rng, candidates.length);
|
||||||
let cityId = candidates[idx]?.id ?? destCityId;
|
let cityId = candidates[idx]?.id ?? destCityId;
|
||||||
@@ -258,7 +255,7 @@ export const actionContextBuilder: ActionContextBuilder<DeceptionArgs> = (base,
|
|||||||
const friendlyGenerals = generals.filter((general) => general.nationId === base.general.nationId);
|
const friendlyGenerals = generals.filter((general) => general.nationId === base.general.nationId);
|
||||||
const destNationSupplyCities = worldRef
|
const destNationSupplyCities = worldRef
|
||||||
.listCities()
|
.listCities()
|
||||||
.filter((city) => city.nationId === destCity.nationId && city.supplyState > 0);
|
.filter((city) => city.nationId === destCity.nationId && city.supplyState === 1);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destCity,
|
destCity,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn
|
|||||||
import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js';
|
import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js';
|
||||||
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
||||||
import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js';
|
import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js';
|
||||||
|
import { ActionDefinition as DeceptionAction } from '../../../src/actions/turn/nation/che_허보.js';
|
||||||
import { LogCategory, LogScope } from '../../../src/logging/types.js';
|
import { LogCategory, LogScope } from '../../../src/logging/types.js';
|
||||||
import type { MapDefinition } from '../../../src/world/types.js';
|
import type { MapDefinition } from '../../../src/world/types.js';
|
||||||
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
import type { TurnSchedule } from '../../../src/turn/calendar.js';
|
||||||
@@ -248,6 +249,33 @@ describe('Nation Actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('che_허보 (Deception)', () => {
|
||||||
|
it('fails like legacy choice when the target nation has no supplied city', () => {
|
||||||
|
const nation = buildNation(1);
|
||||||
|
const destNation = buildNation(2);
|
||||||
|
const general = buildGeneral(1, 1, 1);
|
||||||
|
const target = buildGeneral(2, 2, 2, 'Target');
|
||||||
|
const definition = new DeceptionAction([]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
definition.resolve(
|
||||||
|
{
|
||||||
|
general,
|
||||||
|
nation,
|
||||||
|
destNation,
|
||||||
|
destCity: buildCity(2, 2),
|
||||||
|
destCityGenerals: [target],
|
||||||
|
friendlyGenerals: [general],
|
||||||
|
destNationSupplyCities: [],
|
||||||
|
addLog: () => {},
|
||||||
|
rng: {} as any,
|
||||||
|
} as any,
|
||||||
|
{ destCityId: 2 }
|
||||||
|
)
|
||||||
|
).toThrow(RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('che_천도 (Move Capital)', () => {
|
describe('che_천도 (Move Capital)', () => {
|
||||||
it('changes nation capital city', () => {
|
it('changes nation capital city', () => {
|
||||||
const nation = buildNation(1);
|
const nation = buildNation(1);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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-front-status.spec.ts'],
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
reporter: [['list']],
|
||||||
|
outputDir: process.env.SAMMO_TEST_OUTPUT_DIR ?? resolve(repositoryRoot, 'test-results/main-front-status-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 build && ' +
|
||||||
|
`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 preview --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,341 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
createGamePostgresConnector,
|
||||||
|
createRedisConnector,
|
||||||
|
GamePrisma,
|
||||||
|
resolvePostgresConfigFromEnv,
|
||||||
|
resolveRedisConfigFromEnv,
|
||||||
|
type GamePrismaClient,
|
||||||
|
type RedisConnector,
|
||||||
|
} from '../../packages/infra/src/index.js';
|
||||||
|
|
||||||
|
const marker = `main-front-status-live-${randomUUID()}`;
|
||||||
|
const accessToken = `ga_${randomUUID()}`;
|
||||||
|
let postgres: ReturnType<typeof createGamePostgresConnector>;
|
||||||
|
let redis: RedisConnector;
|
||||||
|
let prisma: GamePrismaClient;
|
||||||
|
let fixture: { generalId: number; nationId: number; userId: string; voteId: number } | null = null;
|
||||||
|
|
||||||
|
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
|
||||||
|
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 = decodeURIComponent(new URL(route.request().url()).pathname);
|
||||||
|
return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' }));
|
||||||
|
redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
await postgres.connect();
|
||||||
|
await redis.connect();
|
||||||
|
prisma = postgres.prisma;
|
||||||
|
|
||||||
|
const world = await prisma.worldState.findFirst({ orderBy: { id: 'asc' }, select: { id: true } });
|
||||||
|
if (!world) {
|
||||||
|
throw new Error('The live che profile needs a world state.');
|
||||||
|
}
|
||||||
|
const [generalMax, nationMax] = await Promise.all([
|
||||||
|
prisma.general.aggregate({ _max: { id: true } }),
|
||||||
|
prisma.nation.aggregate({ _max: { id: true } }),
|
||||||
|
]);
|
||||||
|
const nationId = (nationMax._max.id ?? 0) + 10_000;
|
||||||
|
const generalId = (generalMax._max.id ?? 0) + 10_000;
|
||||||
|
const userId = `main-front-status-${randomUUID()}`;
|
||||||
|
await prisma.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
INSERT INTO nation (id, name, color, meta)
|
||||||
|
VALUES (
|
||||||
|
${nationId},
|
||||||
|
${'검증국'},
|
||||||
|
${'#336699'},
|
||||||
|
${JSON.stringify({ notice: `<p>${marker} 국가 방침</p>` })}::jsonb
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
await prisma.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time)
|
||||||
|
VALUES (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()})
|
||||||
|
`
|
||||||
|
);
|
||||||
|
await prisma.generalAccessLog.create({
|
||||||
|
data: {
|
||||||
|
generalId,
|
||||||
|
userId,
|
||||||
|
lastRefresh: new Date(),
|
||||||
|
refresh: 1,
|
||||||
|
refreshTotal: 1,
|
||||||
|
refreshScore: 1,
|
||||||
|
refreshScoreTotal: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const poll = await prisma.votePoll.create({
|
||||||
|
data: {
|
||||||
|
title: '검증 설문',
|
||||||
|
body: '',
|
||||||
|
options: ['찬성', '반대'],
|
||||||
|
multipleOptions: 1,
|
||||||
|
revealMode: 'after_vote',
|
||||||
|
openerGeneralId: generalId,
|
||||||
|
openerName: '현황검증장수',
|
||||||
|
startAt: new Date(Date.now() - 60_000),
|
||||||
|
endAt: new Date(Date.now() + 3_600_000),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
fixture = { generalId, nationId, userId, voteId: poll.id };
|
||||||
|
|
||||||
|
const issuedAt = new Date();
|
||||||
|
await redis.client.set(
|
||||||
|
accessKey(accessToken),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: issuedAt.toISOString(),
|
||||||
|
expiresAt: new Date(issuedAt.getTime() + 30 * 60 * 1000).toISOString(),
|
||||||
|
sessionId: `main-front-status-${randomUUID()}`,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
username: 'main-front-status-live',
|
||||||
|
displayName: '현황검증장수',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
}),
|
||||||
|
{ EX: 1800 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (fixture) {
|
||||||
|
await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } });
|
||||||
|
await prisma.generalAccessLog.deleteMany({ where: { generalId: fixture.generalId } });
|
||||||
|
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM general WHERE id = ${fixture.generalId}`);
|
||||||
|
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`);
|
||||||
|
const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([
|
||||||
|
prisma.general.count({ where: { id: fixture.generalId } }),
|
||||||
|
prisma.nation.count({ where: { id: fixture.nationId } }),
|
||||||
|
prisma.votePoll.count({ where: { id: fixture.voteId } }),
|
||||||
|
prisma.generalAccessLog.count({ where: { generalId: fixture.generalId } }),
|
||||||
|
]);
|
||||||
|
expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
await redis.client.del(accessKey(accessToken));
|
||||||
|
await redis.disconnect();
|
||||||
|
await postgres.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const installMainFixture = async (
|
||||||
|
page: Page,
|
||||||
|
liveStatus: {
|
||||||
|
onlineUserCount: number;
|
||||||
|
onlineNations: string;
|
||||||
|
onlineGenerals: string;
|
||||||
|
nationNotice: string;
|
||||||
|
lastExecuted: string | null;
|
||||||
|
latestVote: { id: number; title: string; hasVoted: boolean } | null;
|
||||||
|
},
|
||||||
|
failStatus: () => boolean
|
||||||
|
) => {
|
||||||
|
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 response({
|
||||||
|
general: {
|
||||||
|
id: fixture?.generalId ?? 1,
|
||||||
|
name: '현황검증장수',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: fixture?.nationId ?? 1,
|
||||||
|
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.getFrontStatus') {
|
||||||
|
return failStatus()
|
||||||
|
? errorResponse(operation, '상단 현황을 불러오지 못했습니다.')
|
||||||
|
: response(liveStatus);
|
||||||
|
}
|
||||||
|
if (operation === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
userCnt: 1,
|
||||||
|
npcCnt: 0,
|
||||||
|
nationCnt: 1,
|
||||||
|
maxUserCnt: 50,
|
||||||
|
turnTerm: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
otherTextInfo: '',
|
||||||
|
starttime: '0190-01-01T00:00:00.000Z',
|
||||||
|
myGeneral: { name: '현황검증장수', picture: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'world.getMapLayout') {
|
||||||
|
return response({
|
||||||
|
mapName: 'che',
|
||||||
|
cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }],
|
||||||
|
regionMap: { 1: '사예' },
|
||||||
|
levelMap: { 7: '수도' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'world.getMap') {
|
||||||
|
return response({
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
startYear: 190,
|
||||||
|
cityList: [[1, 7, 0, 0, 1, 1]],
|
||||||
|
nationList: [],
|
||||||
|
spyList: {},
|
||||||
|
shownByGeneralList: [],
|
||||||
|
myCity: 1,
|
||||||
|
myNation: fixture?.nationId ?? 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
|
return response([]);
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getRecent') {
|
||||||
|
return response({
|
||||||
|
private: [],
|
||||||
|
public: [],
|
||||||
|
national: [],
|
||||||
|
diplomacy: [],
|
||||||
|
permission: 0,
|
||||||
|
latestRead: { private: 0, diplomacy: 0 },
|
||||||
|
canRespondDiplomacy: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getContacts') return response([]);
|
||||||
|
if (operation === 'board.getAccess') {
|
||||||
|
return response({ canMeeting: false, canSecret: false, permission: 0 });
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
|
if (operation === 'public.recordAccess') return response({ recorded: true });
|
||||||
|
throw new Error(`Unhandled main front status operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('renders actual online, nation policy, and survey data with ref geometry and preserves it on error', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const liveResponse = await page.request.get(
|
||||||
|
'http://127.0.0.1:15113/che/api/trpc/general.getFrontStatus?input=%7B%22json%22%3Anull%7D',
|
||||||
|
{ headers: { authorization: `Bearer ${accessToken}` } }
|
||||||
|
);
|
||||||
|
expect(liveResponse.ok()).toBe(true);
|
||||||
|
const liveStatus = (
|
||||||
|
(await liveResponse.json()) as {
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
onlineUserCount: number;
|
||||||
|
onlineNations: string;
|
||||||
|
onlineGenerals: string;
|
||||||
|
nationNotice: string;
|
||||||
|
lastExecuted: string | null;
|
||||||
|
latestVote: { id: number; title: string; hasVoted: boolean } | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).result.data;
|
||||||
|
expect(liveStatus.onlineGenerals).toContain('현황검증장수');
|
||||||
|
expect(liveStatus.nationNotice).toContain(marker);
|
||||||
|
expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false });
|
||||||
|
|
||||||
|
let failStatus = false;
|
||||||
|
await installMainFixture(page, liveStatus, () => failStatus);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('./', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
const status = page.locator('.front-status');
|
||||||
|
await expect(status).toContainText(marker);
|
||||||
|
await expect(page.locator('.online-users')).toContainText('현황검증장수');
|
||||||
|
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
||||||
|
const desktop = await status.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
|
||||||
|
const voteRow = element.querySelector<HTMLElement>('.vote-status');
|
||||||
|
if (!onlineRow || !voteRow) throw new Error('status row missing');
|
||||||
|
return {
|
||||||
|
rect: element.getBoundingClientRect().toJSON(),
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
onlineRow: {
|
||||||
|
rect: onlineRow.getBoundingClientRect().toJSON(),
|
||||||
|
borderTop: getComputedStyle(onlineRow).borderTop,
|
||||||
|
padding: getComputedStyle(onlineRow).padding,
|
||||||
|
},
|
||||||
|
voteRow: voteRow.getBoundingClientRect().toJSON(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(desktop.rect).toMatchObject({ x: 0, width: 1000 });
|
||||||
|
expect(desktop).toMatchObject({
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '21px',
|
||||||
|
onlineRow: {
|
||||||
|
borderTop: '1px solid rgb(128, 128, 128)',
|
||||||
|
padding: '7px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
|
||||||
|
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
|
||||||
|
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
|
||||||
|
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
|
||||||
|
expect(desktop.backgroundImage).toContain('/image/game/back_walnut.jpg');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await expect(status).toHaveCSS('width', '500px');
|
||||||
|
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
|
||||||
|
|
||||||
|
failStatus = true;
|
||||||
|
await page.getByRole('button', { name: '새로고침', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible();
|
||||||
|
await expect(status).toContainText(marker);
|
||||||
|
|
||||||
|
failStatus = false;
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('.front-status')).toContainText(marker);
|
||||||
|
await expect(page.locator('.survey-notice')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -188,6 +188,16 @@ const installMainFixture = async (page: Page, failRecords = false) => {
|
|||||||
? errorResponse(operation, '동향 정보를 불러오지 못했습니다.')
|
? errorResponse(operation, '동향 정보를 불러오지 못했습니다.')
|
||||||
: response(frontRecords);
|
: response(frontRecords);
|
||||||
}
|
}
|
||||||
|
if (operation === 'general.getFrontStatus') {
|
||||||
|
return response({
|
||||||
|
onlineUserCount: 1,
|
||||||
|
onlineNations: '【재야】',
|
||||||
|
onlineGenerals: '지도 장수',
|
||||||
|
nationNotice: '',
|
||||||
|
lastExecuted: '2026-07-26T00:00:00.000Z',
|
||||||
|
latestVote: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
if (operation === 'public.recordAccess') return response({ recorded: true });
|
if (operation === 'public.recordAccess') return response({ recorded: true });
|
||||||
return errorResponse(operation, `Unhandled main operation: ${operation}`);
|
return errorResponse(operation, `Unhandled main operation: ${operation}`);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_GATEWAY_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',
|
||||||
|
viewport: { width: 1000, height: 900 },
|
||||||
|
});
|
||||||
|
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 login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
if (!login.ok()) {
|
||||||
|
throw new Error(`reference login failed: HTTP ${login.status()}`);
|
||||||
|
}
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const logout = page.locator('#btn_logout');
|
||||||
|
await logout.waitFor({ state: 'visible' });
|
||||||
|
const inspect = async () =>
|
||||||
|
logout.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: rect.toJSON(),
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
border: style.border,
|
||||||
|
borderRadius: style.borderRadius,
|
||||||
|
color: style.color,
|
||||||
|
cursor: style.cursor,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
padding: style.padding,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const normal = await inspect();
|
||||||
|
await logout.hover();
|
||||||
|
const hover = await inspect();
|
||||||
|
await logout.focus();
|
||||||
|
const focus = await inspect();
|
||||||
|
console.log(JSON.stringify({ viewport: page.viewportSize(), normal, hover, focus }));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
if (!login.ok()) {
|
||||||
|
throw new Error(`reference login failed: HTTP ${login.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('.onlineNations').waitFor({ state: 'visible' });
|
||||||
|
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: rect.toJSON(),
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderTop: style.borderTop,
|
||||||
|
color: style.color,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
padding: style.padding,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
container: inspect('#container'),
|
||||||
|
onlineNations: inspect('.onlineNations'),
|
||||||
|
onlineUsers: inspect('.onlineUsers'),
|
||||||
|
nationNotice: inspect('.nationNotice'),
|
||||||
|
voteStatus: inspect('.subVoteState'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ viewport, measurement }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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-front-status.spec.ts',
|
||||||
|
'--config',
|
||||||
|
'tools/frontend-legacy-parity/main-front-status.playwright.config.mjs',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...liveEnvironment,
|
||||||
|
SAMMO_TEST_OUTPUT_DIR: '/dev/shm/sammo-main-front-status-live',
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
process.exitCode = result.status ?? 1;
|
||||||
@@ -69,6 +69,7 @@ export interface TurnCommandFixtureRequest {
|
|||||||
messageAfterId?: number;
|
messageAfterId?: number;
|
||||||
generalCooldowns?: GeneralCooldownSelector[];
|
generalCooldowns?: GeneralCooldownSelector[];
|
||||||
nationCooldowns?: NationCooldownSelector[];
|
nationCooldowns?: NationCooldownSelector[];
|
||||||
|
diplomacyPairs?: Array<{ fromNationId: number; toNationId: number }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,19 @@ const buildRequest = (
|
|||||||
dead: 0,
|
dead: 0,
|
||||||
...fixturePatches.diplomacy?.['2:1'],
|
...fixturePatches.diplomacy?.['2:1'],
|
||||||
},
|
},
|
||||||
|
...Object.entries(fixturePatches.diplomacy ?? {})
|
||||||
|
.filter(([key]) => !['1:2', '2:1'].includes(key))
|
||||||
|
.map(([key, patch]) => {
|
||||||
|
const [fromNationId, toNationId] = key.split(':').map(Number);
|
||||||
|
return {
|
||||||
|
fromNationId,
|
||||||
|
toNationId,
|
||||||
|
state: 3,
|
||||||
|
term: 0,
|
||||||
|
dead: 0,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
observe: {
|
observe: {
|
||||||
@@ -331,12 +344,35 @@ 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_백성동원' || action === 'che_이호경식'
|
...(fixturePatches.diplomacy
|
||||||
|
? {
|
||||||
|
diplomacyPairs: Object.keys(fixturePatches.diplomacy)
|
||||||
|
.filter((key) => !['1:2', '2:1'].includes(key))
|
||||||
|
.map((key) => {
|
||||||
|
const [fromNationId, toNationId] = key.split(':').map(Number);
|
||||||
|
return { fromNationId, toNationId };
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(action === 'che_백성동원' ||
|
||||||
|
action === 'che_이호경식' ||
|
||||||
|
action === 'che_급습' ||
|
||||||
|
action === 'che_필사즉생' ||
|
||||||
|
action === 'che_허보'
|
||||||
? {
|
? {
|
||||||
nationCooldowns: [
|
nationCooldowns: [
|
||||||
{
|
{
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
actionName: action === 'che_백성동원' ? '백성동원' : '이호경식',
|
actionName:
|
||||||
|
action === 'che_백성동원'
|
||||||
|
? '백성동원'
|
||||||
|
: action === 'che_이호경식'
|
||||||
|
? '이호경식'
|
||||||
|
: action === 'che_급습'
|
||||||
|
? '급습'
|
||||||
|
: action === 'che_필사즉생'
|
||||||
|
? '필사즉생'
|
||||||
|
: '허보',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -3265,3 +3301,933 @@ integration('nation degrade-relations target, diplomacy, front, and cooldown bou
|
|||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const surpriseAttackBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
destNationId: unknown;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
completed?: boolean;
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedForwardState?: number;
|
||||||
|
expectedReverseState?: number;
|
||||||
|
expectedForwardTerm?: number;
|
||||||
|
expectedReverseTerm?: number;
|
||||||
|
expectedSourceFront?: number;
|
||||||
|
expectedDestFront?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
expectedDestLogGeneralId?: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a missing destination nation',
|
||||||
|
destNationId: 99,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a numeric string destination nation ID',
|
||||||
|
destNationId: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a fractional destination nation ID',
|
||||||
|
destNationId: 2.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a source city occupied by another nation',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { nationId: 2 } },
|
||||||
|
diplomacy: { '1:2': { state: 1, term: 12 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 1, term: 12 } },
|
||||||
|
},
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { strategicCommandLimit: 1 } },
|
||||||
|
diplomacy: { '1:2': { state: 1, term: 12 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a forward diplomacy state other than declaration',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: { '1:2': { state: 0, term: 12 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects declaration term eleven at the lower boundary',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: { '1:2': { state: 1, term: 11 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied source city and preserves both fronts',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
3: { supplyState: 0, frontState: 2 },
|
||||||
|
70: { frontState: 3 },
|
||||||
|
},
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 1, term: 12 },
|
||||||
|
'2:1': { state: 1, term: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedForwardState: 1,
|
||||||
|
expectedReverseState: 1,
|
||||||
|
expectedForwardTerm: 9,
|
||||||
|
expectedReverseTerm: 9,
|
||||||
|
expectedSourceFront: 2,
|
||||||
|
expectedDestFront: 3,
|
||||||
|
expectedPostReqTurn: 126,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'subtracts three from a disallowed reverse state into a negative term',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 1, term: 15 },
|
||||||
|
'2:1': { state: 2, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedForwardState: 1,
|
||||||
|
expectedReverseState: 2,
|
||||||
|
expectedForwardTerm: 12,
|
||||||
|
expectedReverseTerm: -1,
|
||||||
|
expectedSourceFront: 0,
|
||||||
|
expectedDestFront: 1,
|
||||||
|
expectedPostReqTurn: 126,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows the source nation when a self-diplomacy row is present',
|
||||||
|
destNationId: 1,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: {
|
||||||
|
'1:1': { state: 1, term: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedForwardState: 1,
|
||||||
|
expectedReverseState: 1,
|
||||||
|
expectedForwardTerm: 9,
|
||||||
|
expectedReverseTerm: 9,
|
||||||
|
expectedSourceFront: 0,
|
||||||
|
expectedDestFront: 0,
|
||||||
|
expectedPostReqTurn: 126,
|
||||||
|
expectedDestLogGeneralId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist command and global-delay modifiers',
|
||||||
|
destNationId: 2,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { typeCode: 'che_종횡가' } },
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 1, term: 12 },
|
||||||
|
'2:1': { state: 1, term: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedForwardState: 1,
|
||||||
|
expectedReverseState: 1,
|
||||||
|
expectedForwardTerm: 9,
|
||||||
|
expectedReverseTerm: 9,
|
||||||
|
expectedSourceFront: 0,
|
||||||
|
expectedDestFront: 1,
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
expectedPostReqTurn: 95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||||
|
destNationId: 2,
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 1, term: 12 },
|
||||||
|
'2:1': { state: 1, term: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completed: true,
|
||||||
|
expectedForwardState: 1,
|
||||||
|
expectedReverseState: 1,
|
||||||
|
expectedForwardTerm: 9,
|
||||||
|
expectedReverseTerm: 9,
|
||||||
|
expectedSourceFront: 0,
|
||||||
|
expectedDestFront: 1,
|
||||||
|
expectedPostReqTurn: 133,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation surprise-attack target, diplomacy-term, front, and cooldown boundaries', () => {
|
||||||
|
it.each(surpriseAttackBoundaryCases)(
|
||||||
|
'$name matches legacy fallback, diplomacy, fronts, cooldown, logs, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
destNationId,
|
||||||
|
fixturePatches,
|
||||||
|
completed = false,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedForwardState,
|
||||||
|
expectedReverseState,
|
||||||
|
expectedForwardTerm,
|
||||||
|
expectedReverseTerm,
|
||||||
|
expectedSourceFront,
|
||||||
|
expectedDestFront,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
expectedDestLogGeneralId = 2,
|
||||||
|
}) => {
|
||||||
|
const request = buildRequest('che_급습', { destNationID: destNationId }, fixturePatches);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
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 actualDestNationId = destNationId === 1 ? 1 : 2;
|
||||||
|
const actualDestCityId = actualDestNationId === 1 ? 3 : 70;
|
||||||
|
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 sourceCityBefore = snapshot.before.cities.find((entry) => entry.id === 3);
|
||||||
|
const sourceCityAfter = snapshot.after.cities.find((entry) => entry.id === 3);
|
||||||
|
const destCityBefore = snapshot.before.cities.find((entry) => entry.id === actualDestCityId);
|
||||||
|
const destCityAfter = snapshot.after.cities.find((entry) => entry.id === actualDestCityId);
|
||||||
|
const forwardBefore = snapshot.before.diplomacy.find(
|
||||||
|
(entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId
|
||||||
|
);
|
||||||
|
const forwardAfter = snapshot.after.diplomacy.find(
|
||||||
|
(entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId
|
||||||
|
);
|
||||||
|
const reverseBefore = snapshot.before.diplomacy.find(
|
||||||
|
(entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1
|
||||||
|
);
|
||||||
|
const reverseAfter = snapshot.after.diplomacy.find(
|
||||||
|
(entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(readNumericField(forwardAfter, 'state')).toBe(expectedForwardState);
|
||||||
|
expect(readNumericField(reverseAfter, 'state')).toBe(expectedReverseState);
|
||||||
|
expect(readNumericField(forwardAfter, 'term')).toBe(expectedForwardTerm);
|
||||||
|
expect(readNumericField(reverseAfter, 'term')).toBe(expectedReverseTerm);
|
||||||
|
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront);
|
||||||
|
expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront);
|
||||||
|
|
||||||
|
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||||
|
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('급습 발동')])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('급습'))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
addedLogs.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.generalId === expectedDestLogGeneralId &&
|
||||||
|
String(entry.text).includes('아국에 <M>급습</>이 발동되었습니다.')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
expect(forwardAfter).toEqual(forwardBefore);
|
||||||
|
expect(reverseAfter).toEqual(reverseBefore);
|
||||||
|
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(
|
||||||
|
readNumericField(sourceCityBefore, 'frontState')
|
||||||
|
);
|
||||||
|
expect(readNumericField(destCityAfter, 'frontState')).toBe(
|
||||||
|
readNumericField(destCityBefore, 'frontState')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const desperateSurvivalBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
outcome: 'fallback' | 'intermediate' | 'completed';
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedLastTerm?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a nation without an outgoing war',
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a reverse-only war',
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 3 },
|
||||||
|
'2:1': { state: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a source city occupied by another nation',
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { nationId: 2 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
fixturePatches: {
|
||||||
|
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { strategicCommandLimit: 1 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied city at the first intermediate turn',
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { supplyState: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
expectedLastTerm: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'advances the second intermediate turn without side effects',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
expectedLastTerm: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'completes with an outgoing war even when the reverse relation is trade',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 0, term: 0 },
|
||||||
|
'2:1': { state: 3, term: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 87,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preserves training and morale values already above one hundred',
|
||||||
|
fixturePatches: {
|
||||||
|
generals: {
|
||||||
|
1: { train: 120, atmos: 110 },
|
||||||
|
3: { train: 105, atmos: 130 },
|
||||||
|
},
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 87,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts an explicit self-war as the outgoing war relation',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: {
|
||||||
|
'1:1': { state: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 87,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restarts at term one after a different command interrupted the stack',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '급습', arg: { destNationID: 2 }, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
expectedLastTerm: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist command and global-delay modifiers',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
typeCode: 'che_종횡가',
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
expectedPostReqTurn: 65,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
generalCount: 11,
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 92,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation desperate-survival multistep, diplomacy, effects, and cooldown boundaries', () => {
|
||||||
|
it.each(desperateSurvivalBoundaryCases)(
|
||||||
|
'$name matches legacy progress, effects, cooldown, logs, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
fixturePatches,
|
||||||
|
outcome,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedLastTerm,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
}) => {
|
||||||
|
const request = buildRequest('che_필사즉생', undefined, fixturePatches);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
const completed = outcome === 'completed';
|
||||||
|
const fallback = outcome === 'fallback';
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
if (coreResolution) {
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_필사즉생',
|
||||||
|
actionKey: fallback ? '휴식' : 'che_필사즉생',
|
||||||
|
usedFallback: fallback,
|
||||||
|
...(fallback ? {} : { 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 actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||||
|
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||||
|
const teammateBefore = snapshot.before.generals.find((entry) => entry.id === 3);
|
||||||
|
const teammateAfter = snapshot.after.generals.find((entry) => entry.id === 3);
|
||||||
|
const foreignBefore = snapshot.before.generals.find((entry) => entry.id === 2);
|
||||||
|
const foreignAfter = snapshot.after.generals.find((entry) => entry.id === 2);
|
||||||
|
|
||||||
|
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||||
|
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||||
|
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
|
||||||
|
completed ? 15 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
|
||||||
|
completed ? 15 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [before, after] of [
|
||||||
|
[actorBefore, actorAfter],
|
||||||
|
[teammateBefore, teammateAfter],
|
||||||
|
] as const) {
|
||||||
|
expect(readNumericField(after, 'train')).toBe(
|
||||||
|
completed ? Math.max(100, readNumericField(before, 'train')) : readNumericField(before, 'train')
|
||||||
|
);
|
||||||
|
expect(readNumericField(after, 'atmos')).toBe(
|
||||||
|
completed ? Math.max(100, readNumericField(before, 'atmos')) : readNumericField(before, 'atmos')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(readNumericField(foreignAfter, 'train')).toBe(readNumericField(foreignBefore, 'train'));
|
||||||
|
expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos'));
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||||
|
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('필사즉생 발동')])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('필사즉생'))
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome === 'intermediate') {
|
||||||
|
expect(reference.execution.outcome).toMatchObject({
|
||||||
|
lastTurn: {
|
||||||
|
command: '필사즉생',
|
||||||
|
term: expectedLastTerm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||||
|
command: '필사즉생',
|
||||||
|
term: expectedLastTerm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (completed) {
|
||||||
|
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deceptionBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
destCityId: unknown;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
outcome: 'fallback' | 'intermediate' | 'completed';
|
||||||
|
coreResolution?: boolean;
|
||||||
|
expectedPostReqTurn?: number;
|
||||||
|
expectedStrategicCommandLimit?: number;
|
||||||
|
expectedTargetCityId?: number;
|
||||||
|
expectedRngCalls?: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'rejects a missing destination city',
|
||||||
|
destCityId: 99,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts a numeric string destination city ID',
|
||||||
|
destCityId: '70',
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'truncates a fractional destination city ID',
|
||||||
|
destCityId: 70.9,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a neutral destination city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 0 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a destination city occupied by the source nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a trade relation to the destination nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 3 } } },
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a reverse-only war relation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
diplomacy: {
|
||||||
|
'1:2': { state: 3 },
|
||||||
|
'2:1': { state: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a source city occupied by another nation',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { nationId: 2 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an actor below chief rank',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
coreResolution: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a remaining strategic-command delay',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { strategicCommandLimit: 1 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows an unsupplied source city on the intermediate turn',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { supplyState: 0 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'completes against a nation under declaration',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: { diplomacy: { '1:2': { state: 1 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excludes supply state two and moves the target to the only supply-one city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: {
|
||||||
|
70: { supplyState: 2 },
|
||||||
|
23: { nationId: 2, supplyState: 1 },
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 23,
|
||||||
|
expectedRngCalls: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'completes without RNG when no general is in the destination city',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 23: { nationId: 2, supplyState: 1 } },
|
||||||
|
generals: { 2: { cityId: 23 } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 62,
|
||||||
|
expectedTargetCityId: 23,
|
||||||
|
expectedRngCalls: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restarts at term one after another command interrupted the stack',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: {
|
||||||
|
1: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'applies the strategist command and global-delay modifiers',
|
||||||
|
destCityId: 70,
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { typeCode: 'che_종횡가' } },
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 46,
|
||||||
|
expectedStrategicCommandLimit: 5,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
diplomacy: { '1:2': { state: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'completed',
|
||||||
|
expectedPostReqTurn: 65,
|
||||||
|
expectedTargetCityId: 70,
|
||||||
|
expectedRngCalls: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation deception target, multistep, movement, RNG, and cooldown boundaries', () => {
|
||||||
|
it.each(deceptionBoundaryCases)(
|
||||||
|
'$name matches legacy progress, movement, cooldown, logs, RNG, and semantic delta',
|
||||||
|
async ({
|
||||||
|
destCityId,
|
||||||
|
fixturePatches,
|
||||||
|
outcome,
|
||||||
|
coreResolution = true,
|
||||||
|
expectedPostReqTurn,
|
||||||
|
expectedStrategicCommandLimit = 9,
|
||||||
|
expectedTargetCityId,
|
||||||
|
expectedRngCalls,
|
||||||
|
}) => {
|
||||||
|
const normalizedDestCityId =
|
||||||
|
typeof destCityId === 'string' ? Math.trunc(Number(destCityId)) : Math.trunc(Number(destCityId));
|
||||||
|
const completed = outcome === 'completed';
|
||||||
|
const fallback = outcome === 'fallback';
|
||||||
|
const completionNationPatch = completed
|
||||||
|
? {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '허보', arg: { destCityID: destCityId }, term: 1 },
|
||||||
|
},
|
||||||
|
coreTurnLastByOfficerLevel: {
|
||||||
|
12: { command: '허보', arg: { destCityId: normalizedDestCityId }, term: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_허보',
|
||||||
|
{ destCityID: destCityId },
|
||||||
|
{
|
||||||
|
...fixturePatches,
|
||||||
|
nations: {
|
||||||
|
...fixturePatches?.nations,
|
||||||
|
1: {
|
||||||
|
...fixturePatches?.nations?.[1],
|
||||||
|
...completionNationPatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
if (coreResolution) {
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_허보',
|
||||||
|
actionKey: fallback ? '휴식' : 'che_허보',
|
||||||
|
usedFallback: fallback,
|
||||||
|
...(fallback ? {} : { 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 actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||||
|
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||||
|
const targetBefore = snapshot.before.generals.find((entry) => entry.id === 2);
|
||||||
|
const targetAfter = snapshot.after.generals.find((entry) => entry.id === 2);
|
||||||
|
|
||||||
|
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||||
|
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||||
|
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
|
||||||
|
completed ? 10 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
|
||||||
|
completed ? 10 : 0
|
||||||
|
);
|
||||||
|
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||||
|
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||||
|
);
|
||||||
|
expect(readNumericField(targetAfter, 'cityId')).toBe(
|
||||||
|
completed && expectedTargetCityId !== undefined
|
||||||
|
? expectedTargetCityId
|
||||||
|
: readNumericField(targetBefore, 'cityId')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||||
|
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('허보 발동')])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('허보'))
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome === 'intermediate') {
|
||||||
|
expect(reference.execution.outcome).toMatchObject({
|
||||||
|
lastTurn: {
|
||||||
|
command: '허보',
|
||||||
|
term: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||||
|
command: '허보',
|
||||||
|
term: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (completed) {
|
||||||
|
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||||
|
expect(reference.after.world.nationCooldowns).toEqual([
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
actionName: '허보',
|
||||||
|
nextAvailableTurn: expectedNextAvailableTurn,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||||
|
}
|
||||||
|
if (expectedRngCalls !== undefined) {
|
||||||
|
expect(reference.rng).toHaveLength(expectedRngCalls);
|
||||||
|
}
|
||||||
|
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