merge: improve dynasty archives and gateway states
This commit is contained in:
@@ -45,6 +45,7 @@ export const zWorldStateMeta = z.object({
|
|||||||
turntime: z.string().optional(),
|
turntime: z.string().optional(),
|
||||||
otherTextInfo: z.string().optional(),
|
otherTextInfo: z.string().optional(),
|
||||||
isUnited: z.number().optional(),
|
isUnited: z.number().optional(),
|
||||||
|
isunited: z.number().optional(),
|
||||||
autorun_user: z
|
autorun_user: z
|
||||||
.object({
|
.object({
|
||||||
limit_minutes: z.number().optional(),
|
limit_minutes: z.number().optional(),
|
||||||
|
|||||||
@@ -25,6 +25,50 @@ const parseDisplayArray = (value: unknown): Array<string | number> =>
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const parseArchiveRecord = (value: unknown): Record<string, unknown> => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return asRecord(JSON.parse(value));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return asRecord(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstFiniteNumber = (...values: unknown[]): number | null => {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||||
|
if (typeof parsed === 'number' && Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstDisplayArray = (...values: unknown[]): Array<string | number> => {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = parseDisplayArray(value);
|
||||||
|
if (parsed.length > 0 || Array.isArray(value)) return parsed;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeOldNationData = (value: unknown) => {
|
||||||
|
const data = asRecord(value);
|
||||||
|
const aux = parseArchiveRecord(data.aux);
|
||||||
|
const meta = asRecord(data.meta);
|
||||||
|
const legacyMaxPower = asRecord(meta.max_power);
|
||||||
|
const typeCode = typeof data.type === 'string' ? data.type : typeof data.typeCode === 'string' ? data.typeCode : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
typeCode,
|
||||||
|
tech: firstFiniteNumber(data.tech, meta.tech),
|
||||||
|
maxPower: firstFiniteNumber(data.maxPower, aux.maxPower, legacyMaxPower.maxPower, data.power),
|
||||||
|
maxCrew: firstFiniteNumber(data.maxCrew, aux.maxCrew, legacyMaxPower.maxCrew),
|
||||||
|
maxCities: firstDisplayArray(data.maxCities, aux.maxCities, legacyMaxPower.maxCities),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const formatNationType = (typeCode: string): string => {
|
const formatNationType = (typeCode: string): string => {
|
||||||
const separator = typeCode.indexOf('_');
|
const separator = typeCode.indexOf('_');
|
||||||
return separator < 0 ? typeCode : typeCode.slice(separator + 1);
|
return separator < 0 ? typeCode : typeCode.slice(separator + 1);
|
||||||
@@ -94,31 +138,27 @@ export const dynastyRouter = router({
|
|||||||
const serverId = emperor.serverId ?? '';
|
const serverId = emperor.serverId ?? '';
|
||||||
const oldNationRows = await ctx.db.oldNation.findMany({
|
const oldNationRows = await ctx.db.oldNation.findMany({
|
||||||
where: { serverId },
|
where: { serverId },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const nationEntries = oldNationRows
|
const nationEntries = oldNationRows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const data = asRecord(row.data);
|
const normalized = normalizeOldNationData(row.data);
|
||||||
|
const { data } = normalized;
|
||||||
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
|
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
|
||||||
const typeCode = typeof data.type === 'string' ? data.type : '';
|
|
||||||
return {
|
return {
|
||||||
|
archiveId: row.id,
|
||||||
nation: nationId,
|
nation: nationId,
|
||||||
isWinner: winnerNationId !== null && nationId === winnerNationId,
|
isWinner: winnerNationId !== null && nationId === winnerNationId,
|
||||||
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
|
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
|
||||||
color: typeof data.color === 'string' ? data.color : '#000000',
|
color: typeof data.color === 'string' ? data.color : '#000000',
|
||||||
type: typeCode,
|
type: normalized.typeCode,
|
||||||
typeName: formatNationType(typeCode),
|
typeName: formatNationType(normalized.typeCode),
|
||||||
level: typeof data.level === 'number' ? data.level : null,
|
level: typeof data.level === 'number' ? data.level : null,
|
||||||
tech: typeof data.tech === 'number' ? data.tech : null,
|
tech: normalized.tech,
|
||||||
maxPower:
|
maxPower: normalized.maxPower,
|
||||||
typeof data.maxPower === 'number'
|
maxCrew: normalized.maxCrew,
|
||||||
? data.maxPower
|
maxCities: normalized.maxCities,
|
||||||
: typeof data.power === 'number'
|
|
||||||
? data.power
|
|
||||||
: null,
|
|
||||||
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
|
|
||||||
maxCities: parseDisplayArray(data.maxCities),
|
|
||||||
generals: parseNumberArray(data.generals),
|
generals: parseNumberArray(data.generals),
|
||||||
history: parseTextArray(data.history),
|
history: parseTextArray(data.history),
|
||||||
date: row.date.toISOString(),
|
date: row.date.toISOString(),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const lobbyRouter = router({
|
|||||||
opentime: worldState.meta.opentime ?? '',
|
opentime: worldState.meta.opentime ?? '',
|
||||||
turntime: worldState.meta.turntime ?? '',
|
turntime: worldState.meta.turntime ?? '',
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
isUnited: worldState.meta.isUnited ?? 0,
|
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||||
myGeneral,
|
myGeneral,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const loadWorldTrendSnapshot = async (ctx: GameApiContext): Promise<WorldTrendSn
|
|||||||
opentime: meta.opentime ?? '',
|
opentime: meta.opentime ?? '',
|
||||||
turntime: meta.turntime ?? '',
|
turntime: meta.turntime ?? '',
|
||||||
otherTextInfo: meta.otherTextInfo ?? '',
|
otherTextInfo: meta.otherTextInfo ?? '',
|
||||||
isUnited: meta.isUnited ?? 0,
|
isUnited: meta.isunited ?? meta.isUnited ?? 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -178,11 +178,13 @@ const globalGenerals = [
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const createContext = (options: {
|
const createContext = (
|
||||||
auth?: GameSessionTokenPayload | null;
|
options: {
|
||||||
me?: GeneralRow | null;
|
auth?: GameSessionTokenPayload | null;
|
||||||
isUnited?: number;
|
me?: GeneralRow | null;
|
||||||
} = {}): { context: GameApiContext; requestCommand: ReturnType<typeof vi.fn> } => {
|
isUnited?: number;
|
||||||
|
} = {}
|
||||||
|
): { context: GameApiContext; requestCommand: ReturnType<typeof vi.fn> } => {
|
||||||
const token = options.auth === undefined ? auth() : options.auth;
|
const token = options.auth === undefined ? auth() : options.auth;
|
||||||
const me = options.me === undefined ? actor({ userId: token?.user.id ?? 'user-1' }) : options.me;
|
const me = options.me === undefined ? actor({ userId: token?.user.id ?? 'user-1' }) : options.me;
|
||||||
const requestCommand = vi.fn();
|
const requestCommand = vi.fn();
|
||||||
@@ -280,12 +282,7 @@ describe('legacy global nation/general directories', () => {
|
|||||||
ambassadorNames: ['군주', '외교관'],
|
ambassadorNames: ['군주', '외교관'],
|
||||||
auditorCount: 1,
|
auditorCount: 1,
|
||||||
});
|
});
|
||||||
expect(result[1]?.generals.map((general) => general.name)).toEqual([
|
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
|
||||||
'군주',
|
|
||||||
'외교관',
|
|
||||||
'제재외교관',
|
|
||||||
'조언자',
|
|
||||||
]);
|
|
||||||
expect(result[1]?.officers[0]).toMatchObject({
|
expect(result[1]?.officers[0]).toMatchObject({
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
general: { id: 10, name: '군주' },
|
general: { id: 10, name: '군주' },
|
||||||
@@ -311,9 +308,9 @@ describe('legacy global nation/general directories', () => {
|
|||||||
expect(defaultResult.generals.find((general) => general.id === 10)?.ownerName).toBeNull();
|
expect(defaultResult.generals.find((general) => general.id === 10)?.ownerName).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reveals only the legacy owner display name after unification', async () => {
|
it.each([1, 2, 3])('reveals only the legacy owner display name in united state %i', async (isUnited) => {
|
||||||
const result = await appRouter
|
const result = await appRouter
|
||||||
.createCaller(createContext({ isUnited: 1 }).context)
|
.createCaller(createContext({ isUnited }).context)
|
||||||
.world.getGeneralDirectory({ sort: 1 });
|
.world.getGeneralDirectory({ sort: 1 });
|
||||||
expect(result.generals.find((general) => general.id === 10)?.ownerName).toBe('통일유저');
|
expect(result.generals.find((general) => general.id === 10)?.ownerName).toBe('통일유저');
|
||||||
expect(JSON.stringify(result)).not.toContain('user-1');
|
expect(JSON.stringify(result)).not.toContain('user-1');
|
||||||
|
|||||||
@@ -69,12 +69,11 @@ const oldNation = {
|
|||||||
nation: 1,
|
nation: 1,
|
||||||
name: '촉',
|
name: '촉',
|
||||||
color: '#FF0000',
|
color: '#FF0000',
|
||||||
type: 'che_병가',
|
typeCode: 'che_병가',
|
||||||
level: 7,
|
level: 7,
|
||||||
tech: 4000,
|
tech: 4000,
|
||||||
power: 34434,
|
power: 12_345,
|
||||||
maxCrew: 120000,
|
aux: { maxPower: 34_434, maxCrew: 120_000, maxCities: ['성도', '한중'] },
|
||||||
maxCities: ['성도', '한중'],
|
|
||||||
generals: [11, 12],
|
generals: [11, 12],
|
||||||
history: ['<Y>유비</>가 황제로 즉위'],
|
history: ['<Y>유비</>가 황제로 즉위'],
|
||||||
owner: 'not-returned',
|
owner: 'not-returned',
|
||||||
@@ -82,6 +81,28 @@ const oldNation = {
|
|||||||
date: new Date('2026-07-25T12:00:00.000Z'),
|
date: new Date('2026-07-25T12:00:00.000Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deletedOldNation = {
|
||||||
|
id: 4,
|
||||||
|
serverId: emperor.serverId,
|
||||||
|
nation: 2,
|
||||||
|
data: {
|
||||||
|
nation: 2,
|
||||||
|
name: '위',
|
||||||
|
color: '#0000FF',
|
||||||
|
type: 'che_법가',
|
||||||
|
level: 5,
|
||||||
|
power: 8_000,
|
||||||
|
generals: [13],
|
||||||
|
history: [],
|
||||||
|
meta: {
|
||||||
|
tech: 3_000,
|
||||||
|
max_power: { maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
|
||||||
|
privateNote: 'not-returned',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
date: new Date('2026-07-24T12:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: profile.name,
|
profile: profile.name,
|
||||||
@@ -97,7 +118,10 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload
|
|||||||
sanctions: {},
|
sanctions: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
|
const buildContext = (
|
||||||
|
auth: GameSessionTokenPayload | null,
|
||||||
|
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
|
||||||
|
): GameApiContext => {
|
||||||
const db = {
|
const db = {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||||
@@ -108,12 +132,13 @@ const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
|
|||||||
},
|
},
|
||||||
oldNation: {
|
oldNation: {
|
||||||
findMany: async ({ where }: { where: { serverId: string } }) =>
|
findMany: async ({ where }: { where: { serverId: string } }) =>
|
||||||
where.serverId === emperor.serverId ? [oldNation] : [],
|
where.serverId === emperor.serverId ? oldNations : [],
|
||||||
},
|
},
|
||||||
oldGeneral: {
|
oldGeneral: {
|
||||||
findMany: async () => [
|
findMany: async () => [
|
||||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||||
|
{ generalNo: 13, name: '조조', lastYearMonth: 21003 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -204,16 +229,30 @@ describe('dynasty public read model', () => {
|
|||||||
);
|
);
|
||||||
expect(result.nations).toEqual([
|
expect(result.nations).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
archiveId: 3,
|
||||||
name: '촉',
|
name: '촉',
|
||||||
type: 'che_병가',
|
type: 'che_병가',
|
||||||
typeName: '병가',
|
typeName: '병가',
|
||||||
levelName: '황제',
|
levelName: '황제',
|
||||||
maxPower: 34434,
|
maxPower: 34434,
|
||||||
|
maxCrew: 120000,
|
||||||
|
maxCities: ['성도', '한중'],
|
||||||
generalsFull: [
|
generalsFull: [
|
||||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
archiveId: 4,
|
||||||
|
name: '위',
|
||||||
|
type: 'che_법가',
|
||||||
|
typeName: '법가',
|
||||||
|
tech: 3000,
|
||||||
|
maxPower: 20000,
|
||||||
|
maxCrew: 80000,
|
||||||
|
maxCities: ['허창'],
|
||||||
|
generalsFull: [{ generalNo: 13, name: '조조', lastYearMonth: 21003 }],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
expect(JSON.stringify(result)).not.toContain('privateNote');
|
expect(JSON.stringify(result)).not.toContain('privateNote');
|
||||||
expect(JSON.stringify(result)).not.toContain('not-returned');
|
expect(JSON.stringify(result)).not.toContain('not-returned');
|
||||||
@@ -221,6 +260,29 @@ describe('dynasty public read model', () => {
|
|||||||
expect(JSON.stringify(result)).not.toContain('"data"');
|
expect(JSON.stringify(result)).not.toContain('"data"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads legacy JSON-string aux values without exposing malformed archive internals', async () => {
|
||||||
|
const stringAuxNation = {
|
||||||
|
...oldNation,
|
||||||
|
id: 5,
|
||||||
|
data: {
|
||||||
|
...oldNation.data,
|
||||||
|
aux: JSON.stringify({ maxPower: 45_000, maxCrew: 130_000, maxCities: ['낙양'] }),
|
||||||
|
owner: 'not-returned',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(buildContext(null, [stringAuxNation]))
|
||||||
|
.dynasty.getDetail({ emperorId: emperor.id });
|
||||||
|
|
||||||
|
expect(result.nations[0]).toMatchObject({
|
||||||
|
archiveId: 5,
|
||||||
|
maxPower: 45_000,
|
||||||
|
maxCrew: 130_000,
|
||||||
|
maxCities: ['낙양'],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result)).not.toContain('not-returned');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid and missing record identifiers without querying another scope', async () => {
|
it('rejects invalid and missing record identifiers without querying another scope', async () => {
|
||||||
const caller = appRouter.createCaller(buildContext(null));
|
const caller = appRouter.createCaller(buildContext(null));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
||||||
|
({
|
||||||
|
auth: null,
|
||||||
|
db: {
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 3_600,
|
||||||
|
config: {},
|
||||||
|
meta,
|
||||||
|
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
count: vi.fn(async () => 0),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
count: vi.fn(async () => 0),
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseClient,
|
||||||
|
}) as GameApiContext;
|
||||||
|
|
||||||
|
describe('lobby season state', () => {
|
||||||
|
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||||
|
.lobby.info();
|
||||||
|
|
||||||
|
expect(result.isUnited).toBe(isunited);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ import { calculateNationBettingRewards } from '../betting/nationBettingSettlemen
|
|||||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||||
import { buildPersistedRankRows } from './rankData.js';
|
import { buildPersistedRankRows } from './rankData.js';
|
||||||
import { persistUnificationFinalization } from './unificationPersistence.js';
|
import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||||
|
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||||
|
|
||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
@@ -696,40 +697,22 @@ export const createDatabaseTurnHooks = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
data: {
|
data: buildOldNationArchiveData({
|
||||||
nation: snapshot.nation.id,
|
nation: snapshot.nation,
|
||||||
name: snapshot.nation.name,
|
generalIds: snapshot.generalIds,
|
||||||
color: snapshot.nation.color,
|
|
||||||
type: snapshot.nation.typeCode,
|
|
||||||
level: snapshot.nation.level,
|
|
||||||
gold: snapshot.nation.gold,
|
|
||||||
rice: snapshot.nation.rice,
|
|
||||||
power: snapshot.nation.power,
|
|
||||||
capitalCityId: snapshot.nation.capitalCityId,
|
|
||||||
generals: snapshot.generalIds,
|
|
||||||
history: historyMap.get(snapshot.nation.id) ?? [],
|
history: historyMap.get(snapshot.nation.id) ?? [],
|
||||||
meta: snapshot.nation.meta ?? {},
|
}) as InputJsonValue,
|
||||||
},
|
|
||||||
date: snapshot.removedAt,
|
date: snapshot.removedAt,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
serverId,
|
serverId,
|
||||||
nation: snapshot.nation.id,
|
nation: snapshot.nation.id,
|
||||||
sourceId: 0,
|
sourceId: 0,
|
||||||
data: {
|
data: buildOldNationArchiveData({
|
||||||
nation: snapshot.nation.id,
|
nation: snapshot.nation,
|
||||||
name: snapshot.nation.name,
|
generalIds: snapshot.generalIds,
|
||||||
color: snapshot.nation.color,
|
|
||||||
type: snapshot.nation.typeCode,
|
|
||||||
level: snapshot.nation.level,
|
|
||||||
gold: snapshot.nation.gold,
|
|
||||||
rice: snapshot.nation.rice,
|
|
||||||
power: snapshot.nation.power,
|
|
||||||
capitalCityId: snapshot.nation.capitalCityId,
|
|
||||||
generals: snapshot.generalIds,
|
|
||||||
history: historyMap.get(snapshot.nation.id) ?? [],
|
history: historyMap.get(snapshot.nation.id) ?? [],
|
||||||
meta: snapshot.nation.meta ?? {},
|
}) as InputJsonValue,
|
||||||
},
|
|
||||||
date: snapshot.removedAt,
|
date: snapshot.removedAt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const createRaiseInvaderHandler = (options: {
|
|||||||
}
|
}
|
||||||
npcEachCount = Math.max(10, toInteger(npcEachCount));
|
npcEachCount = Math.max(10, toInteger(npcEachCount));
|
||||||
|
|
||||||
world.updateWorldMeta({ isunited: 1 });
|
world.updateWorldMeta({ isunited: 1, isUnited: 1 });
|
||||||
const totalGeneralCount = npcEachCount * invaderCities.length + world.listGenerals().length;
|
const totalGeneralCount = npcEachCount * invaderCities.length + world.listGenerals().length;
|
||||||
const maxGeneralsPerMinute =
|
const maxGeneralsPerMinute =
|
||||||
options.maxGeneralsPerMinute ?? readNumber(world.getState().meta.maxGeneralsPerMinute, 1_000);
|
options.maxGeneralsPerMinute ?? readNumber(world.getState().meta.maxGeneralsPerMinute, 1_000);
|
||||||
@@ -531,6 +531,7 @@ export const createInvaderEndingHandler = (options: {
|
|||||||
}
|
}
|
||||||
world.updateWorldMeta({
|
world.updateWorldMeta({
|
||||||
isunited: 3,
|
isunited: 3,
|
||||||
|
isUnited: 3,
|
||||||
refreshLimit: readNumber(meta.refreshLimit) * 100,
|
refreshLimit: readNumber(meta.refreshLimit) * 100,
|
||||||
});
|
});
|
||||||
world.removeEvent(environment.currentEventID);
|
world.removeEvent(environment.currentEventID);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import type { Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
const readNumber = (value: unknown): number => {
|
||||||
|
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||||
|
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTextArray = (value: unknown): string[] =>
|
||||||
|
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||||
|
|
||||||
|
export const buildOldNationArchiveData = (options: {
|
||||||
|
nation: Nation;
|
||||||
|
generalIds: readonly number[];
|
||||||
|
history: readonly string[];
|
||||||
|
}): Record<string, unknown> => {
|
||||||
|
const { nation } = options;
|
||||||
|
const meta = asRecord(nation.meta);
|
||||||
|
const maxPower = asRecord(meta.max_power);
|
||||||
|
const aux = {
|
||||||
|
...asRecord(meta.aux),
|
||||||
|
...maxPower,
|
||||||
|
};
|
||||||
|
const maxCities = readTextArray(maxPower.maxCities);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...nation,
|
||||||
|
nation: nation.id,
|
||||||
|
type: nation.typeCode,
|
||||||
|
tech: readNumber(meta.tech),
|
||||||
|
maxPower: readNumber(maxPower.maxPower),
|
||||||
|
maxCrew: readNumber(maxPower.maxCrew),
|
||||||
|
maxCities,
|
||||||
|
aux,
|
||||||
|
generals: [...options.generalIds],
|
||||||
|
history: [...options.history],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageReco
|
|||||||
|
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
|
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
|
||||||
|
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||||
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
|
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
|
||||||
|
|
||||||
const UNIFIER_POINT = 2000;
|
const UNIFIER_POINT = 2000;
|
||||||
@@ -490,16 +491,13 @@ export const persistUnificationFinalization = async (
|
|||||||
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
|
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
|
||||||
const winnerMeta = asRecord(winner.meta);
|
const winnerMeta = asRecord(winner.meta);
|
||||||
const winnerData = {
|
const winnerData = {
|
||||||
...winner,
|
...buildOldNationArchiveData({
|
||||||
tech: readInteger(winnerMeta.tech),
|
nation: winner,
|
||||||
aux: {
|
generalIds: winnerGenerals.map((general) => general.id),
|
||||||
...asRecord(winnerMeta.aux),
|
history: nationHistory,
|
||||||
...asRecord(winnerMeta.max_power),
|
}),
|
||||||
},
|
|
||||||
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
|
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
|
||||||
scout_msg: String(winnerMeta.scout_msg ?? ''),
|
scout_msg: String(winnerMeta.scout_msg ?? ''),
|
||||||
generals: winnerGenerals.map((general) => general.id),
|
|
||||||
history: nationHistory,
|
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
};
|
};
|
||||||
await transaction.oldNation.upsert({
|
await transaction.oldNation.upsert({
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ describe('invader monthly actions', () => {
|
|||||||
]);
|
]);
|
||||||
expect(harness.world.getState().meta).toMatchObject({
|
expect(harness.world.getState().meta).toMatchObject({
|
||||||
isunited: 1,
|
isunited: 1,
|
||||||
|
isUnited: 1,
|
||||||
block_change_scout: false,
|
block_change_scout: false,
|
||||||
lastNationId: 10,
|
lastNationId: 10,
|
||||||
});
|
});
|
||||||
@@ -363,7 +364,7 @@ describe('invader monthly actions', () => {
|
|||||||
|
|
||||||
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
|
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
|
||||||
|
|
||||||
expect(world.getState().meta).toMatchObject({ isunited: 3, refreshLimit: 300 });
|
expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 });
|
||||||
expect(world.listEvents()).toHaveLength(0);
|
expect(world.listEvents()).toHaveLength(0);
|
||||||
expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([
|
expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([
|
||||||
'<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
|
'<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
})
|
})
|
||||||
).toBe(3);
|
).toBe(3);
|
||||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({
|
expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({
|
||||||
meta: expect.objectContaining({ isunited: 1, block_change_scout: false }),
|
meta: expect.objectContaining({ isunited: 1, isUnited: 1, block_change_scout: false }),
|
||||||
});
|
});
|
||||||
if (referenceTrace) {
|
if (referenceTrace) {
|
||||||
expect(referenceTrace.phases.afterRaise).toMatchObject({
|
expect(referenceTrace.phases.afterRaise).toMatchObject({
|
||||||
@@ -451,6 +451,7 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
diplomacyCountsPerNation: [2],
|
diplomacyCountsPerNation: [2],
|
||||||
diplomacyStates: ['1:24'],
|
diplomacyStates: ['1:24'],
|
||||||
isunited: 1,
|
isunited: 1,
|
||||||
|
isUnited: 1,
|
||||||
blockChangeScout: false,
|
blockChangeScout: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -539,7 +540,7 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
|
|
||||||
expect(await db.event.findUnique({ where: { id: createdEventIds[1]! } })).toBeNull();
|
expect(await db.event.findUnique({ where: { id: createdEventIds[1]! } })).toBeNull();
|
||||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({
|
expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({
|
||||||
meta: expect.objectContaining({ isunited: 3, refreshLimit: 300 }),
|
meta: expect.objectContaining({ isunited: 3, isUnited: 3, refreshLimit: 300 }),
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await db.logEntry.findMany({
|
await db.logEntry.findMany({
|
||||||
@@ -556,6 +557,7 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
result: 'Deleted',
|
result: 'Deleted',
|
||||||
endingEventPresent: false,
|
endingEventPresent: false,
|
||||||
isunited: 3,
|
isunited: 3,
|
||||||
|
isUnited: 3,
|
||||||
refreshLimit: 300,
|
refreshLimit: 300,
|
||||||
logs: [
|
logs: [
|
||||||
'<C>●</>200년 4월:<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
|
'<C>●</>200년 4월:<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { buildOldNationArchiveData } from '../src/turn/oldNationArchive.js';
|
||||||
|
|
||||||
|
describe('old nation archive data', () => {
|
||||||
|
it('writes one canonical public shape for winner and deleted-nation readers', () => {
|
||||||
|
const nation: Nation = {
|
||||||
|
id: 2,
|
||||||
|
name: '위',
|
||||||
|
color: '#0000ff',
|
||||||
|
capitalCityId: 3,
|
||||||
|
chiefGeneralId: 7,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 2_000,
|
||||||
|
power: 8_000,
|
||||||
|
level: 5,
|
||||||
|
typeCode: 'che_법가',
|
||||||
|
meta: {
|
||||||
|
tech: 3_000,
|
||||||
|
aux: { legacy: 'preserved' },
|
||||||
|
max_power: { maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildOldNationArchiveData({ nation, generalIds: [7, 8], history: ['위가 멸망'] })).toMatchObject({
|
||||||
|
nation: 2,
|
||||||
|
name: '위',
|
||||||
|
type: 'che_법가',
|
||||||
|
typeCode: 'che_법가',
|
||||||
|
tech: 3_000,
|
||||||
|
power: 8_000,
|
||||||
|
maxPower: 20_000,
|
||||||
|
maxCrew: 80_000,
|
||||||
|
maxCities: ['허창'],
|
||||||
|
aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
|
||||||
|
generals: [7, 8],
|
||||||
|
history: ['위가 멸망'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -330,6 +330,26 @@ integration('unification finalization transaction', () => {
|
|||||||
expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1);
|
expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1);
|
||||||
expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1);
|
expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1);
|
||||||
expect(await db.oldNation.count({ where: { serverId } })).toBe(2);
|
expect(await db.oldNation.count({ where: { serverId } })).toBe(2);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await db.oldNation.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
serverId_nation_sourceId: { serverId, nation: fixtureId, sourceId: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
).toMatchObject({
|
||||||
|
nation: fixtureId,
|
||||||
|
name: '원자통일국',
|
||||||
|
type: 'che_중립',
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
tech: 123,
|
||||||
|
maxPower: 3_500,
|
||||||
|
maxCrew: 400,
|
||||||
|
maxCities: ['원자도시'],
|
||||||
|
aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
|
||||||
|
generals: [fixtureId],
|
||||||
|
});
|
||||||
expect(await db.emperor.count({ where: { serverId } })).toBe(1);
|
expect(await db.emperor.count({ where: { serverId } })).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
(await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } }))
|
(await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } }))
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ onMounted(loadDetail);
|
|||||||
|
|
||||||
<table
|
<table
|
||||||
v-for="nation in data.nations"
|
v-for="nation in data.nations"
|
||||||
:key="nation.nation"
|
:key="nation.archiveId"
|
||||||
class="legacy-table legacy-bg2 old-nation-table"
|
class="legacy-table legacy-bg2 old-nation-table"
|
||||||
>
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const artifactRoot = process.env.GATEWAY_STATUS_ARTIFACT_DIR;
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
mkdirSync(artifactRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
const operationNames = (route: Route): string[] => {
|
const operationNames = (route: Route): string[] => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
@@ -19,6 +27,7 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LobbyFixtureOptions = {
|
type LobbyFixtureOptions = {
|
||||||
|
authenticated?: boolean;
|
||||||
canCreateGeneral?: boolean;
|
canCreateGeneral?: boolean;
|
||||||
myGeneral?: {
|
myGeneral?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,10 +38,16 @@ type LobbyFixtureOptions = {
|
|||||||
npcPossessionEnabled?: boolean;
|
npcPossessionEnabled?: boolean;
|
||||||
userCnt?: number;
|
userCnt?: number;
|
||||||
maxUserCnt?: number;
|
maxUserCnt?: number;
|
||||||
|
nationCnt?: number;
|
||||||
|
isUnited?: number;
|
||||||
|
starttime?: string;
|
||||||
|
opentime?: string;
|
||||||
|
turntime?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
||||||
const {
|
const {
|
||||||
|
authenticated = true,
|
||||||
canCreateGeneral = true,
|
canCreateGeneral = true,
|
||||||
myGeneral = {
|
myGeneral = {
|
||||||
name: '선택장수',
|
name: '선택장수',
|
||||||
@@ -43,22 +58,33 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
npcPossessionEnabled = false,
|
npcPossessionEnabled = false,
|
||||||
userCnt = 1,
|
userCnt = 1,
|
||||||
maxUserCnt = 500,
|
maxUserCnt = 500,
|
||||||
|
nationCnt = 0,
|
||||||
|
isUnited = 0,
|
||||||
|
starttime = '2026-07-30 00:00:00',
|
||||||
|
opentime = '2026-07-30 00:00:00',
|
||||||
|
turntime = '2026-07-30 00:05:00',
|
||||||
} = options;
|
} = options;
|
||||||
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
||||||
await page.addInitScript(() => {
|
if (authenticated) {
|
||||||
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
|
await page.addInitScript(() => {
|
||||||
});
|
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
|
||||||
|
});
|
||||||
|
}
|
||||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
const results = operationNames(route).map((operation) => {
|
const results = operationNames(route).map((operation) => {
|
||||||
if (operation === 'me') {
|
if (operation === 'me') {
|
||||||
return response({
|
return response(
|
||||||
id: 'lobby-user',
|
authenticated
|
||||||
username: 'lobby-user',
|
? {
|
||||||
displayName: '로비사용자',
|
id: 'lobby-user',
|
||||||
roles: ['user'],
|
username: 'lobby-user',
|
||||||
kakaoVerified: true,
|
displayName: '로비사용자',
|
||||||
createdAt: '2026-07-30T00:00:00.000Z',
|
roles: ['user'],
|
||||||
});
|
kakaoVerified: true,
|
||||||
|
createdAt: '2026-07-30T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (operation === 'lobby.notice') {
|
if (operation === 'lobby.notice') {
|
||||||
return response('');
|
return response('');
|
||||||
@@ -119,14 +145,14 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
userCnt,
|
userCnt,
|
||||||
maxUserCnt,
|
maxUserCnt,
|
||||||
npcCnt: 0,
|
npcCnt: 0,
|
||||||
nationCnt: 0,
|
nationCnt,
|
||||||
turnTerm: 5,
|
turnTerm: 5,
|
||||||
fictionMode: '가상',
|
fictionMode: '가상',
|
||||||
starttime: '2026-07-30 00:00:00',
|
starttime,
|
||||||
opentime: '2026-07-30 00:00:00',
|
opentime,
|
||||||
turntime: '2026-07-30 00:05:00',
|
turntime,
|
||||||
otherTextInfo: '',
|
otherTextInfo: '',
|
||||||
isUnited: 0,
|
isUnited,
|
||||||
selectionPoolEnabled,
|
selectionPoolEnabled,
|
||||||
npcPossessionEnabled,
|
npcPossessionEnabled,
|
||||||
myGeneral,
|
myGeneral,
|
||||||
@@ -231,3 +257,99 @@ test('shows registration closed instead of acquisition actions at the Ref capaci
|
|||||||
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
||||||
await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0);
|
await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const season of [
|
||||||
|
{
|
||||||
|
name: 'competition',
|
||||||
|
isUnited: 0,
|
||||||
|
opentime: '2000-01-01 00:00:00',
|
||||||
|
label: '<4국 경쟁중>',
|
||||||
|
period: '2026-07-30 00:00:00 ~',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preopen',
|
||||||
|
isUnited: 0,
|
||||||
|
opentime: '2099-01-01 00:00:00',
|
||||||
|
label: '-가오픈 중-',
|
||||||
|
period: '2026-07-30 00:00:00 ~',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event running',
|
||||||
|
isUnited: 1,
|
||||||
|
opentime: '2099-01-01 00:00:00',
|
||||||
|
label: '§이벤트 진행중§',
|
||||||
|
period: '2026-07-30 00:00:00 ~',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'united',
|
||||||
|
isUnited: 2,
|
||||||
|
opentime: '2099-01-01 00:00:00',
|
||||||
|
label: '§천하통일§',
|
||||||
|
period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event finished',
|
||||||
|
isUnited: 3,
|
||||||
|
opentime: '2099-01-01 00:00:00',
|
||||||
|
label: '§이벤트 종료§',
|
||||||
|
period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00',
|
||||||
|
},
|
||||||
|
] as const) {
|
||||||
|
test(`renders the Ref ${season.name} season status without changing entry actions`, async ({ page }) => {
|
||||||
|
await installFixture(page, {
|
||||||
|
isUnited: season.isUnited,
|
||||||
|
opentime: season.opentime,
|
||||||
|
nationCnt: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
|
const status = row.getByText(season.label, { exact: true });
|
||||||
|
await expect(status).toBeVisible();
|
||||||
|
await expect(row.locator('td').first().locator('[title]')).toHaveAttribute('title', season.period);
|
||||||
|
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
const [viewport, rowGeometry, statusGeometry] = await Promise.all([
|
||||||
|
page.evaluate(() => ({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
devicePixelRatio: window.devicePixelRatio,
|
||||||
|
})),
|
||||||
|
row.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
}),
|
||||||
|
status.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
color: style.color,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const geometry = { viewport, row: rowGeometry, status: statusGeometry };
|
||||||
|
const slug = season.name.replaceAll(' ', '-');
|
||||||
|
writeFileSync(resolve(artifactRoot, `gateway-${slug}.json`), `${JSON.stringify(geometry, null, 2)}\n`);
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, `gateway-${slug}.png`), fullPage: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders the same Ref season status on the public gateway page', async ({ page }) => {
|
||||||
|
await installFixture(page, { authenticated: false, isUnited: 3, nationCnt: 4 });
|
||||||
|
|
||||||
|
await page.goto('');
|
||||||
|
const status = page.locator('.season-status');
|
||||||
|
await expect(status).toHaveText('§이벤트 종료§');
|
||||||
|
await expect(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00');
|
||||||
|
await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface ServerSeasonStatusInput {
|
||||||
|
isUnited: number;
|
||||||
|
nationCnt: number;
|
||||||
|
opentime: string;
|
||||||
|
starttime: string;
|
||||||
|
turntime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSeasonStatus {
|
||||||
|
code: 'COMPETING' | 'PREOPEN' | 'EVENT_RUNNING' | 'UNITED' | 'EVENT_FINISHED';
|
||||||
|
label: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveServerSeasonStatus = (info: ServerSeasonStatusInput, now = new Date()): ServerSeasonStatus => {
|
||||||
|
const finishedPeriod = `${info.starttime}\n~ ${info.turntime}`;
|
||||||
|
if (info.isUnited === 3) {
|
||||||
|
return { code: 'EVENT_FINISHED', label: '§이벤트 종료§', period: finishedPeriod };
|
||||||
|
}
|
||||||
|
if (info.isUnited === 1) {
|
||||||
|
return { code: 'EVENT_RUNNING', label: '§이벤트 진행중§', period: `${info.starttime} ~` };
|
||||||
|
}
|
||||||
|
if (info.isUnited === 2) {
|
||||||
|
return { code: 'UNITED', label: '§천하통일§', period: finishedPeriod };
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAt = new Date(info.opentime);
|
||||||
|
if (Number.isFinite(openAt.getTime()) && openAt.getTime() > now.getTime()) {
|
||||||
|
return { code: 'PREOPEN', label: '-가오픈 중-', period: `${info.starttime} ~` };
|
||||||
|
}
|
||||||
|
return { code: 'COMPETING', label: `<${info.nationCnt}국 경쟁중>`, period: `${info.starttime} ~` };
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
|
|||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { sealPassword } from '../utils/passwordEnvelope';
|
import { sealPassword } from '../utils/passwordEnvelope';
|
||||||
|
import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus';
|
||||||
|
|
||||||
type GatewayOutput = inferRouterOutputs<AppRouter>;
|
type GatewayOutput = inferRouterOutputs<AppRouter>;
|
||||||
type GameOutput = inferRouterOutputs<GameRouter>;
|
type GameOutput = inferRouterOutputs<GameRouter>;
|
||||||
@@ -37,6 +38,7 @@ const dateText = computed(() => {
|
|||||||
}
|
}
|
||||||
return `西紀 ${info.value.year}年 ${info.value.month}月`;
|
return `西紀 ${info.value.year}年 ${info.value.month}月`;
|
||||||
});
|
});
|
||||||
|
const seasonStatus = computed(() => (info.value ? resolveServerSeasonStatus(info.value) : null));
|
||||||
|
|
||||||
const loadPublicStatus = async (): Promise<void> => {
|
const loadPublicStatus = async (): Promise<void> => {
|
||||||
statusLoading.value = true;
|
statusLoading.value = true;
|
||||||
@@ -153,9 +155,7 @@ const handlePasswordReset = async (): Promise<void> => {
|
|||||||
<button class="login-button" type="submit" :disabled="loginLoading">
|
<button class="login-button" type="submit" :disabled="loginLoading">
|
||||||
{{ loginLoading ? '로그인 중…' : '로그인' }}
|
{{ loginLoading ? '로그인 중…' : '로그인' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="reset-button" type="button" @click="handlePasswordReset">
|
<button class="reset-button" type="button" @click="handlePasswordReset">비밀번호 초기화</button>
|
||||||
비밀번호 초기화
|
|
||||||
</button>
|
|
||||||
<RouterLink class="signup-button" to="/signup">아이디로 회원가입</RouterLink>
|
<RouterLink class="signup-button" to="/signup">아이디로 회원가입</RouterLink>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="loginError" class="login-error" role="alert">{{ loginError }}</p>
|
<p v-if="loginError" class="login-error" role="alert">{{ loginError }}</p>
|
||||||
@@ -174,7 +174,10 @@ const handlePasswordReset = async (): Promise<void> => {
|
|||||||
</div>
|
</div>
|
||||||
<ul v-if="info" class="status-summary">
|
<ul v-if="info" class="status-summary">
|
||||||
<li>서버: {{ profile?.korName }} / 시나리오: {{ profile?.scenario }}</li>
|
<li>서버: {{ profile?.korName }} / 시나리오: {{ profile?.scenario }}</li>
|
||||||
<li>유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · {{ info.nationCnt }}국 경쟁중</li>
|
<li>
|
||||||
|
유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 ·
|
||||||
|
<span class="season-status" :title="seasonStatus?.period">{{ seasonStatus?.label }}</span>
|
||||||
|
</li>
|
||||||
<li>{{ info.turnTerm }}분 턴 서버</li>
|
<li>{{ info.turnTerm }}분 턴 서버</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="mapData?.history?.length" class="status-history">
|
<div v-if="mapData?.history?.length" class="status-history">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import MapPreview from '../components/MapPreview.vue';
|
|||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { createGameTrpc } from '../utils/gameTrpc';
|
import { createGameTrpc } from '../utils/gameTrpc';
|
||||||
import type { GameRouter } from '../utils/gameTrpc';
|
import type { GameRouter } from '../utils/gameTrpc';
|
||||||
|
import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus';
|
||||||
|
|
||||||
type GatewayRouterOutput = inferRouterOutputs<AppRouter>;
|
type GatewayRouterOutput = inferRouterOutputs<AppRouter>;
|
||||||
type GameRouterOutput = inferRouterOutputs<GameRouter>;
|
type GameRouterOutput = inferRouterOutputs<GameRouter>;
|
||||||
@@ -43,6 +44,7 @@ const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gat
|
|||||||
|
|
||||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||||
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||||
const encodeLegacyIconPath = (value: string): string =>
|
const encodeLegacyIconPath = (value: string): string =>
|
||||||
value
|
value
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -273,15 +275,18 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
:style="{ color: profile.color }"
|
:style="{ color: profile.color }"
|
||||||
class="text-lg font-bold cursor-help"
|
class="text-lg font-bold cursor-help"
|
||||||
:title="
|
:title="
|
||||||
profileDetails[profile.profileName]?.starttime
|
profileDetails[profile.profileName]
|
||||||
? `시작일: ${profileDetails[profile.profileName]?.starttime}`
|
? serverSeasonStatus(profileDetails[profile.profileName]!).period
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ profile.korName }}섭
|
{{ profile.korName }}섭
|
||||||
</div>
|
</div>
|
||||||
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-500 mt-1">
|
<div
|
||||||
<{{ profileDetails[profile.profileName]?.nationCnt }}국 경쟁중>
|
v-if="profileDetails[profile.profileName]"
|
||||||
|
class="season-status mt-1 whitespace-nowrap text-xs text-zinc-500"
|
||||||
|
>
|
||||||
|
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const detailPayload = {
|
|||||||
},
|
},
|
||||||
nations: [
|
nations: [
|
||||||
{
|
{
|
||||||
|
archiveId: 3,
|
||||||
nation: 1,
|
nation: 1,
|
||||||
isWinner: true,
|
isWinner: true,
|
||||||
name: '백년01',
|
name: '백년01',
|
||||||
@@ -104,6 +105,25 @@ const detailPayload = {
|
|||||||
{ generalNo: 12, name: '료우기시키', lastYearMonth: 21504 },
|
{ generalNo: 12, name: '료우기시키', lastYearMonth: 21504 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
archiveId: 4,
|
||||||
|
nation: 2,
|
||||||
|
isWinner: false,
|
||||||
|
name: '청해',
|
||||||
|
color: '#0000FF',
|
||||||
|
type: 'che_법가',
|
||||||
|
typeName: '법가',
|
||||||
|
level: 5,
|
||||||
|
levelName: '공',
|
||||||
|
tech: 3000,
|
||||||
|
maxPower: 20000,
|
||||||
|
maxCrew: 80000,
|
||||||
|
maxCities: ['허창'],
|
||||||
|
generals: [13],
|
||||||
|
history: ['<Y>조조</>가 나라를 세웠습니다.'],
|
||||||
|
date: '2026-07-24T12:00:00.000Z',
|
||||||
|
generalsFull: [{ generalNo: 13, name: '조조', lastYearMonth: 21003 }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +261,15 @@ test('dynasty detail preserves the legacy fields, old-nation table and error flo
|
|||||||
await expect(page.getByText('건 안 칠 자')).toBeVisible();
|
await expect(page.getByText('건 안 칠 자')).toBeVisible();
|
||||||
await expect(page.getByText('【 백년01 】')).toBeVisible();
|
await expect(page.getByText('【 백년01 】')).toBeVisible();
|
||||||
await expect(page.getByText('황제', { exact: true })).toBeVisible();
|
await expect(page.getByText('황제', { exact: true })).toBeVisible();
|
||||||
await expect(page.locator('.old-nation-table')).toHaveCount(1);
|
await expect(page.locator('.old-nation-table')).toHaveCount(2);
|
||||||
|
await expect(page.locator('.old-nation-table').nth(0)).toContainText('120000명');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(0)).toContainText('34434');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(0)).toContainText('낙양, 장안, 성도');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(1)).toContainText('법가');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(1)).toContainText('3000');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(1)).toContainText('80000명');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(1)).toContainText('20000');
|
||||||
|
await expect(page.locator('.old-nation-table').nth(1)).toContainText('허창');
|
||||||
|
|
||||||
const geometry = await page.locator('#dynasty-detail-container').evaluate((container) => {
|
const geometry = await page.locator('#dynasty-detail-container').evaluate((container) => {
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
|
|||||||
Reference in New Issue
Block a user