Merge branch 'main' into feature/dynasty-list-parity

This commit is contained in:
2026-07-26 06:09:17 +00:00
73 changed files with 3146 additions and 564 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}):
continue;
}
let job: BattleSimJob | null = null;
let job: BattleSimJob;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
+1 -1
View File
@@ -594,7 +594,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
tryExtendCloseDate: input.tryExtendCloseDate ?? true,
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
if (!result || result.type !== 'auctionBid') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
+9 -7
View File
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
const nation = await ctx.db.nation.findUnique({
where: { id: nationId },
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
return resolveNationPermission(general, nation.meta, true);
};
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
if (state === 'ACTIVATED') return 'ACTIVATED';
if (state === 'CANCELLED') return 'CANCELLED';
if (state === 'REPLACED') return 'REPLACED';
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
select: { id: true },
});
if (newer) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
});
}
if (prevLetter.state === 'PROPOSED') {
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
});
}
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
destNationId =
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
}
const nations = await ctx.db.nation.findMany({
@@ -372,4 +374,4 @@ export const diplomacyRouter = router({
});
return { state: 'ACTIVATED' };
}),
});
});
+8 -2
View File
@@ -1,13 +1,14 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import type { GameApiContext } from '../../context.js';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js';
import { procedure, router } from '../../trpc.js';
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
import { z } from 'zod';
type WorldTrendSnapshot = {
year: number;
@@ -231,6 +232,11 @@ const sortNpcList = <T extends {
});
export const publicRouter = router({
recordAccess: sessionActivityProcedure
.input(z.object({ page: z.enum(accessPages) }))
.mutation(async ({ ctx, input }) => ({
recorded: await recordGeneralAccess(ctx, input.page),
})),
getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario);
}),
+55 -15
View File
@@ -8,8 +8,29 @@ import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { authedProcedure, procedure, router } from '../../trpc.js';
const DEFAULT_BG_COLOR = '#2b2b2b';
const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff';
const NEUTRAL_BG_COLOR = '#000000';
const LEGACY_WHITE_TEXT_COLORS = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const readMetaNumber = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -24,7 +45,17 @@ const readMetaNumber = (value: unknown): number => {
return 0;
};
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
export const formatLegacyRankingNumber = (value: number, fractionDigits = 0): string =>
new Intl.NumberFormat('en-US', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
useGrouping: true,
}).format(value);
export const resolveLegacyTextColor = (backgroundColor: string): string =>
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
const percentText = (value: number): string => `${formatLegacyRankingNumber(value * 100, 2)}%`;
const readOwnerDisplayName = (value: unknown): string | null => {
const meta = asRecord(value);
@@ -72,6 +103,7 @@ export const rankingRouter = router({
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({
where: { npcState: npcFilter },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
@@ -133,11 +165,11 @@ export const rankingRouter = router({
}
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
}],
['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0],
['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0],
['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0],
['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0],
['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0],
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
['전 력 전 승 률', 'percent', (_g, r) => {
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
if (total < 50) {
@@ -186,17 +218,20 @@ export const rankingRouter = router({
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = {
id: general.id,
name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
value,
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'),
printValue:
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
};
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
@@ -206,7 +241,7 @@ export const rankingRouter = router({
ownerName: null,
nationName: '???',
bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
};
@@ -268,12 +303,14 @@ export const rankingRouter = router({
})
.map((general) => {
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
return {
id: general.id,
name: general.name,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
};
@@ -299,7 +336,7 @@ export const rankingRouter = router({
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
},
@@ -398,7 +435,10 @@ export const rankingRouter = router({
picture: typeof aux.picture === 'string' ? aux.picture : null,
imageServer: readMetaNumber(aux.imgsvr),
value: row.value,
printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'),
printValue:
type.type === 'percent'
? percentText(row.value)
: formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''),
+184
View File
@@ -0,0 +1,184 @@
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
export const accessPages = [
'front-info',
'nation-info',
'nation-cities',
'global-info',
'current-city',
'diplomacy',
'nation-generals',
'nation-personnel',
'nation-finance',
'battle-center',
'board',
'best-general',
'hall-of-fame',
'dynasty',
'yearbook',
'nation-betting',
'traffic',
'npc-list',
'my-page',
'npc-control',
'tournament',
'betting',
] as const;
export type AccessPage = (typeof accessPages)[number];
export const accessPageWeights: Record<AccessPage, number> = {
'front-info': 1,
'nation-info': 1,
'nation-cities': 1,
'global-info': 1,
'current-city': 1,
diplomacy: 1,
'nation-generals': 1,
'nation-personnel': 1,
'nation-finance': 1,
'battle-center': 1,
board: 1,
'best-general': 1,
'hall-of-fame': 1,
dynasty: 1,
yearbook: 1,
'nation-betting': 1,
traffic: 1,
'npc-list': 2,
'my-page': 1,
'npc-control': 1,
tournament: 1,
betting: 1,
};
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
const readFiniteNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const readDate = (value: unknown): Date | null => {
if (typeof value !== 'string' && !(value instanceof Date)) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const resolveAccessWindows = (
now: Date,
tickSeconds: number,
worldMeta: unknown
): { dayStartedAt: Date; scoreStartedAt: Date } => {
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const meta = asRecord(worldMeta);
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
const scoreStartedAt =
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
? tickStartedAt
: new Date(now.getTime() - fallbackTickMs);
return { dayStartedAt, scoreStartedAt };
};
export const upsertGeneralAccess = async (
db: Pick<GameApiContext['db'], '$executeRaw'>,
input: {
generalId: number;
userId: string;
weight: number;
now: Date;
dayStartedAt: Date;
scoreStartedAt: Date;
}
): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`
INSERT INTO general_access_log (
general_id,
user_id,
last_refresh,
refresh,
refresh_total,
refresh_score,
refresh_score_total
)
VALUES (
${input.generalId},
${input.userId},
${input.now},
${input.weight},
${input.weight},
${input.weight},
${input.weight}
)
ON CONFLICT (general_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
last_refresh = EXCLUDED.last_refresh,
refresh = CASE
WHEN general_access_log.last_refresh IS NULL
OR general_access_log.last_refresh < ${input.dayStartedAt}
THEN EXCLUDED.refresh
ELSE general_access_log.refresh + EXCLUDED.refresh
END,
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
refresh_score = CASE
WHEN general_access_log.last_refresh IS NULL
OR general_access_log.last_refresh < ${input.scoreStartedAt}
THEN EXCLUDED.refresh_score
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
END,
refresh_score_total =
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
`
);
};
export const recordGeneralAccess = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => {
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return false;
}
const [general, worldState] = await Promise.all([
ctx.db.general.findFirst({
where: { userId: user.id },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
}),
ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { tickSeconds: true, meta: true },
}),
]);
if (!general || !worldState) {
return false;
}
const meta = asRecord(worldState.meta);
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
const openTime = readDate(meta.opentime);
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
return false;
}
const weight = accessPageWeights[page];
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
await upsertGeneralAccess(ctx.db, {
generalId: general.id,
userId: user.id,
weight,
now,
dayStartedAt,
scoreStartedAt,
});
return true;
};
+4
View File
@@ -63,6 +63,10 @@ export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
+1
View File
@@ -205,6 +205,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01),
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
};
};
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GamePrisma, RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 10_000,
rice: 10_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
});
const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
auctions?: Array<Record<string, unknown>>;
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async (command: { type: string }) => {
if (command.type === 'auctionOpen') {
return {
type: 'auctionOpen' as const,
ok: true as const,
auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z',
};
}
return {
type: 'auctionBid' as const,
ok: true as const,
auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z',
};
});
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
const worldState = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
config: {
const: {
auctionName: ['청룡', '백호', '주작', '현무'],
allItems: { weapon: { che_무기_12_칠성검: 1 } },
},
},
meta: { hiddenSeed: 'auction-hidden-seed' },
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
const db = {
$queryRaw: queryRaw,
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) =>
where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' }))
),
},
auction: {
findMany: vi.fn(async () => options.auctions ?? []),
findFirst: vi.fn(async () => null),
},
worldState: {
findFirst: vi.fn(async () => worldState),
},
inheritancePoint: {
findUnique: vi.fn(async () => ({ value: 10_000 })),
},
logEntry: {
findMany: vi.fn(async () => []),
},
};
const redis = {
zAdd: vi.fn(async () => 1),
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: redis as unknown as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, db, queryRaw, redis, requestCommand };
};
describe('auction router actor and permission boundaries', () => {
it('rejects unauthenticated auction reads', async () => {
const fixture = buildContext({ auth: null });
await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('rejects reads and mutations when the authenticated user owns no general', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
});
const caller = appRouter.createCaller(fixture.context);
await expect(caller.auction.getOverview()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
message: 'General not found.',
});
await expect(
caller.auction.openBuyRice({
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
})
).rejects.toMatchObject({
code: 'UNAUTHORIZED',
message: 'General not found.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => {
const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) });
const input = {
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
generalId: 999,
};
await appRouter.createCaller(fixture.context).auction.openBuyRice(input);
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
auctionType: 'BUY_RICE',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
});
});
it('redacts real unique-auction identities while preserving caller markers', async () => {
const openedAt = new Date('2026-07-26T01:00:00Z');
const fixture = buildContext({
auctions: [
{
id: 31,
type: 'UNIQUE_ITEM',
targetCode: 'che_무기_12_칠성검',
hostGeneralId: 7,
hostName: null,
detail: { title: '칠성검 경매', startBidAmount: 5000 },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00Z'),
bids: [
{
id: 41,
generalId: 88,
amount: 5500,
eventAt: openedAt,
},
],
},
],
});
const result = await appRouter.createCaller(fixture.context).auction.getOverview();
const unique = result.uniqueAuctions[0];
expect(unique).toMatchObject({
id: 31,
hostGeneralId: null,
isCallerHost: true,
highestBid: { amount: 5500, isCaller: false },
});
expect(unique?.hostName).not.toBe('유비');
expect(unique?.highestBid?.bidderName).not.toBe('관우');
expect(JSON.stringify(unique)).not.toContain('"generalId"');
expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7');
});
it('keeps the legacy default of no requested close extension for a unique bid', async () => {
const fixture = buildContext({
queryRaw: async (query) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'UNIQUE_ITEM',
targetCode: 'che_무기_12_칠성검',
hostGeneralId: 88,
detail: { startBidAmount: 100, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00Z'),
},
];
}
if (text.includes('FROM auction_bid') && text.includes('general_id =')) {
return [];
}
if (text.includes('SELECT bid.auction_id')) {
return [{ auctionId: 31, generalId: 88, amount: 100 }];
}
if (text.includes('FROM auction_bid')) {
return [{ id: 41, generalId: 88, amount: 100, meta: {} }];
}
if (text.includes('SELECT id, target_code')) {
return [{ id: 31, targetCode: 'che_무기_12_칠성검' }];
}
return [];
},
});
await appRouter.createCaller(fixture.context).auction.bidUnique({
auctionId: 31,
amount: 110,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionBid',
auctionId: 31,
generalId: 7,
amount: 110,
tryExtendCloseDate: false,
});
});
});
@@ -0,0 +1,71 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 9_980_071;
integration('general access tracking persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.generalAccessLog.deleteMany({ where: { generalId } });
});
afterAll(async () => {
await db.generalAccessLog.deleteMany({ where: { generalId } });
await closeDb?.();
});
it('atomically increments concurrent requests and resets only windowed counters', async () => {
const firstWindow = {
generalId,
userId: 'access-user-a',
now: new Date('2026-07-26T03:05:00.000Z'),
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
};
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
upsertGeneralAccess(db, {
...firstWindow,
now: new Date(firstWindow.now.getTime() + index + 1),
weight: 1,
})
)
);
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
userId: 'access-user-a',
refresh: 22,
refreshTotal: 22,
refreshScore: 22,
refreshScoreTotal: 22,
});
await upsertGeneralAccess(db, {
generalId,
userId: 'access-user-b',
now: new Date('2026-07-27T00:05:00.000Z'),
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
weight: 1,
});
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
userId: 'access-user-b',
refresh: 1,
refreshTotal: 23,
refreshScore: 1,
refreshScoreTotal: 23,
});
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient } from '../src/context.js';
import { recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
const auth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'access-session',
user: {
id: 'user-7',
username: 'user7',
displayName: '사용자7',
roles,
},
sanctions: {},
});
const buildDb = (meta: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async (_query: unknown) => 1);
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
const findWorld = vi.fn(async () => ({
tickSeconds: 600,
meta: {
opentime: '2026-07-25T00:00:00.000Z',
lastTurnTime: '2026-07-26T03:00:00.000Z',
...meta,
},
}));
const db = {
$executeRaw: executeRaw,
general: { findFirst: findGeneral },
worldState: { findFirst: findWorld },
} as unknown as DatabaseClient;
return { db, executeRaw, findGeneral, findWorld };
};
describe('general access tracking', () => {
it('resolves the UTC day and latest processed turn windows', () => {
expect(
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
lastTurnTime: '2026-07-26T03:10:00.000Z',
})
).toEqual({
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
});
});
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
const { db, executeRaw, findGeneral } = buildDb();
const now = new Date('2026-07-26T03:05:00.000Z');
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
expect(findGeneral).toHaveBeenCalledWith({
where: { userId: 'user-7' },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
});
expect(executeRaw).toHaveBeenCalledTimes(1);
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
expect(statement.values).toEqual([
7,
'user-7',
now,
2,
2,
2,
2,
new Date('2026-07-26T00:00:00.000Z'),
new Date('2026-07-26T03:00:00.000Z'),
]);
});
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
const anonymous = buildDb();
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
expect(anonymous.findGeneral).not.toHaveBeenCalled();
const admin = buildDb();
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
expect(admin.findGeneral).not.toHaveBeenCalled();
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
await expect(
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
).resolves.toBe(false);
expect(future.executeRaw).not.toHaveBeenCalled();
const united = buildDb({ isUnited: 2 });
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
expect(united.executeRaw).not.toHaveBeenCalled();
});
});
+28 -3
View File
@@ -9,6 +9,7 @@ import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.j
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.js';
const profile: GameProfile = {
id: 'che',
@@ -40,7 +41,7 @@ const generalRows = [
npcState: 0,
picture: '1.jpg',
imageServer: 0,
meta: { ownerName: '공개소유자' },
meta: { ownerName: '공개소유자', dex1: 120 },
experience: 1200,
dedication: 900,
horseCode: 'che_명마_15_적토마',
@@ -56,7 +57,7 @@ const generalRows = [
npcState: 1,
picture: null,
imageServer: 0,
meta: { owner_name: '빙의소유자' },
meta: { owner_name: '빙의소유자', dex1: 80 },
experience: 1100,
dedication: 800,
horseCode: 'None',
@@ -72,7 +73,7 @@ const generalRows = [
npcState: 2,
picture: null,
imageServer: 0,
meta: {},
meta: { dex1: 200 },
experience: 1300,
dedication: 1000,
horseCode: 'None',
@@ -122,6 +123,9 @@ const buildContext = (options?: {
{ generalId: 1, type: 'firenum', value: 10 },
{ generalId: 2, type: 'firenum', value: 20 },
{ generalId: 3, type: 'firenum', value: 30 },
{ generalId: 1, type: 'dex1', value: 999 },
{ generalId: 2, type: 'dex1', value: 999 },
{ generalId: 3, type: 'dex1', value: 999 },
],
},
auction: {
@@ -218,6 +222,27 @@ describe('ranking.getBestGeneral', () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
});
it('uses the general dex columns as the legacy source of truth instead of mirrored rank rows', async () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'user' });
const dex = result.sections.find((section) => section.title === '보 병 숙 련 도');
expect(dex?.entries.map((entry) => [entry.id, entry.value, entry.printValue])).toEqual([
[1, 120, '120'],
[2, 80, '80'],
]);
expect(dex?.entries[0]).toMatchObject({
bgColor: '#006400',
fgColor: '#000000',
});
});
it('matches PHP number_format rounding and the legacy fixed color table', () => {
expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01');
expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60');
expect(resolveLegacyTextColor('#006400')).toBe('#000000');
expect(resolveLegacyTextColor('#330000')).toBe('#ffffff');
});
});
describe('ranking hall of fame', () => {
@@ -711,7 +711,7 @@ export class GeneralAI {
const leadership = this.general.stats.leadership;
const strength = Math.max(this.general.stats.strength, 1);
const intel = Math.max(this.general.stats.intelligence, 1);
let genType = 0;
let genType: number;
if (strength >= intel) {
genType = t무장;
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
const force = ai.nationPolicy.combatForce[leader.id];
let [fromCityId, toCityId] = force;
let targetCityId: number | null = null;
let targetCityId: number | null;
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
targetCityId = pickRandomCityId(ai, ai.frontCities);
} else {
+4 -79
View File
@@ -22,7 +22,7 @@ import {
type LogEntryDraft,
type MessageRecordDraft,
} from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import { asRecord } from '@sammo-ts/common';
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
import { buildPersistedRankRows } from './rankData.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => {
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return toLegacyDatabaseInt(parsed);
}
}
return 0;
};
const LEGACY_INTEGER_GENERAL_META_KEYS = [
'leadership_exp',
'strength_exp',
@@ -312,72 +299,10 @@ const buildPersistedGeneralMeta = (
return asJson(meta);
};
const buildRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
const meta = asRecord(general.meta);
const readMeta = (key: string) => readRankMetaNumber(meta, key);
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
const entries: Array<[RankDataType, number]> = [
['experience', toLegacyDatabaseInt(general.experience)],
['dedication', toLegacyDatabaseInt(general.dedication)],
['firenum', readMeta('firenum')],
['warnum', readRank('warnum')],
['killnum', readRank('killnum')],
['deathnum', readRank('deathnum')],
['occupied', readRank('occupied')],
['killcrew', readRank('killcrew')],
['deathcrew', readRank('deathcrew')],
['killcrew_person', readRank('killcrew_person')],
['deathcrew_person', readRank('deathcrew_person')],
['dex1', readMeta('dex1')],
['dex2', readMeta('dex2')],
['dex3', readMeta('dex3')],
['dex4', readMeta('dex4')],
['dex5', readMeta('dex5')],
['ttw', readMeta('ttw')],
['ttd', readMeta('ttd')],
['ttl', readMeta('ttl')],
['ttg', readMeta('ttg')],
['ttp', readMeta('ttp')],
['tlw', readMeta('tlw')],
['tld', readMeta('tld')],
['tll', readMeta('tll')],
['tlg', readMeta('tlg')],
['tlp', readMeta('tlp')],
['tsw', readMeta('tsw')],
['tsd', readMeta('tsd')],
['tsl', readMeta('tsl')],
['tsg', readMeta('tsg')],
['tsp', readMeta('tsp')],
['tiw', readMeta('tiw')],
['tid', readMeta('tid')],
['til', readMeta('til')],
['tig', readMeta('tig')],
['tip', readMeta('tip')],
['betgold', readMeta('betgold')],
['betwin', readMeta('betwin')],
['betwingold', readMeta('betwingold')],
['inherit_earned', readMeta('inherit_earned')],
['inherit_spent', readMeta('inherit_spent')],
['inherit_earned_dyn', readMeta('inherit_earned_dyn')],
['inherit_earned_act', readMeta('inherit_earned_act')],
['inherit_spent_dyn', readMeta('inherit_spent_dyn')],
];
return entries.map(([type, value]) => ({
generalId: general.id,
nationId: general.nationId,
type,
value,
}));
};
const buildInitialRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
const buildGeneralUpdate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
@@ -953,7 +878,7 @@ export const createDatabaseTurnHooks = async (
if (createdGenerals.length > 0 || rankTargets.length > 0) {
const rankRows = [
...createdGenerals.flatMap(buildInitialRankRows),
...rankTargets.flatMap(buildRankRows),
...rankTargets.flatMap(buildPersistedRankRows),
];
await Promise.all(
rankRows.map((row) =>
+6 -9
View File
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
originOutcome: number
): { next: number; ratio: number; realOutcome: number } => {
let next = current + income;
let realOutcome = 0;
let realOutcome: number;
if (next < baseResource) {
realOutcome = 0;
next = baseResource;
@@ -139,14 +139,11 @@ const processIncomeForNation = (
const trait = traitMap.get(nation.typeCode) ?? null;
const incomeContext = buildNationIncomeContext(nation, trait);
let income = 0;
if (type === 'gold') {
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
} else {
income =
getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
}
const income =
type === 'gold'
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
const incomeValue = roundResource(income);
const originOutcome = getOutcome(100, nationGenerals);
+87
View File
@@ -0,0 +1,87 @@
import {
LEGACY_RANK_DATA_TYPES,
RANK_DATA_TYPES,
rankDataMetaKey,
type LegacyRankDataType,
type RankDataType,
} from '@sammo-ts/common';
export interface RankedGeneralState {
id: number;
nationId: number;
experience: number;
dedication: number;
meta: unknown;
}
export interface PersistedRankRow {
generalId: number;
nationId: number;
type: RankDataType;
value: number;
}
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
const toLegacyDatabaseInt = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? toLegacyDatabaseInt(parsed) : 0;
}
return 0;
};
export const rankMetaKey = rankDataMetaKey;
export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRankRow[] => {
const meta = asRecord(general.meta);
return RANK_DATA_TYPES.map((type) => {
const value =
type === 'experience'
? toLegacyDatabaseInt(general.experience)
: type === 'dedication'
? toLegacyDatabaseInt(general.dedication)
: readMetaNumber(meta, rankMetaKey(type));
return {
generalId: general.id,
nationId: general.nationId,
type,
value,
};
});
};
export const buildLegacyComparableRankRows = (
general: RankedGeneralState
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
return buildPersistedRankRows(general).filter(
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
);
};
export const applyPersistedRankRowsToMeta = (
rawMeta: Record<string, unknown>,
rows: ReadonlyArray<{ type: string; value: number }>
): void => {
const supportedTypes = new Set<string>(RANK_DATA_TYPES);
for (const row of rows) {
if (row.type === 'experience' || row.type === 'dedication' || !supportedTypes.has(row.type)) {
continue;
}
rawMeta[rankMetaKey(row.type as RankDataType)] = row.value;
}
};
@@ -37,6 +37,7 @@ const DEFAULT_BASE_GOLD = 0;
const DEFAULT_BASE_RICE = 2000;
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
const DEFAULT_NPC_SEIZURE_MESSAGE_PROB = 0.01;
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
const normalizeCode = (value: string | null | undefined): string | null => {
@@ -136,6 +137,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], DEFAULT_NPC_SEIZURE_MESSAGE_PROB),
maxResourceActionAmount: resolveNumber(
constValues,
['maxResourceActionAmount'],
@@ -37,7 +37,7 @@ import {
} from '@sammo-ts/logic';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
@@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js';
import { rankMetaKey } from './rankData.js';
const DEFAULT_ACTION = '휴식';
@@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
const meta = { ...general.meta };
for (const key of [
'firenum',
'rank_warnum',
'rank_killnum',
'rank_deathnum',
'rank_occupied',
'rank_killcrew',
'rank_deathcrew',
'rank_killcrew_person',
'rank_deathcrew_person',
'rank_ttw',
'rank_ttd',
'rank_ttl',
'rank_ttg',
'rank_ttp',
'rank_tlw',
'rank_tld',
'rank_tll',
'rank_tlg',
'rank_tlp',
'rank_tsw',
'rank_tsd',
'rank_tsl',
'rank_tsg',
'rank_tsp',
'rank_tiw',
'rank_tid',
'rank_til',
'rank_tig',
'rank_tip',
'rank_betgold',
'rank_betwin',
'rank_betwingold',
'specage',
'specage2',
]) {
meta[key] = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
meta[rankMetaKey(type)] = 0;
}
meta.specage = 0;
meta.specage2 = 0;
for (let dex = 1; dex <= 5; dex += 1) {
const key = `dex${dex}`;
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
+36 -39
View File
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
sabotage,
dex,
unifier,
unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
unifierAward:
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
},
},
});
@@ -194,15 +195,11 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim()
? meta.serverId.trim()
: options.profileName;
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
const scenarioName =
typeof asRecord(meta.scenarioMeta).title === 'string'
? String(asRecord(meta.scenarioMeta).title)
: '';
typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = new Date().toISOString();
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
};
for (const [typeName, valueType] of hallTypes) {
let value = 0;
if (valueType === 'natural') {
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0;
} else if (valueType === 'rank') {
value = ranks[typeName] ?? 0;
} else {
value = calcValues[typeName] ?? 0;
}
const value =
valueType === 'natural'
? typeName === 'experience'
? general.experience
: typeName === 'dedication'
? general.dedication
: (ranks[typeName] ?? 0)
: valueType === 'rank'
? (ranks[typeName] ?? 0)
: (calcValues[typeName] ?? 0);
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
continue;
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim()
? meta.serverId.trim()
: options.profileName;
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim()
? meta.serverName.trim()
@@ -653,30 +650,30 @@ export const createUnificationHandler = (options: {
await Promise.all(
oldGeneralTargets.map((general) =>
((snapshot) =>
prisma.oldGeneral.upsert({
where: {
by_no: {
prisma.oldGeneral.upsert({
where: {
by_no: {
serverId,
generalNo: general.id,
},
},
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
},
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
}))( {
}))({
...general,
turnTime: general.turnTime.toISOString(),
})
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
const attackerG = getRankNumber(attacker, rankKey('g'));
const defenderG = getRankNumber(defender, rankKey('g'));
let attackerGDelta = 0;
let defenderGDelta = 0;
let attackerGDelta: number;
let defenderGDelta: number;
let attackerW = 0;
let attackerD = 0;
let attackerL = 0;
+2 -17
View File
@@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
import { readDiplomacyMeta } from '@sammo-ts/logic';
import { applyPersistedRankRowsToMeta } from './rankData.js';
interface TurnWorldLoaderOptions {
databaseUrl: string;
@@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
return parsed.data;
};
const GENERAL_RANK_META_PREFIX_TYPES = new Set([
'warnum',
'killnum',
'deathnum',
'occupied',
'killcrew',
'deathcrew',
'killcrew_person',
'deathcrew_person',
]);
const mapGeneralRow = (
row: TurnEngineGeneralRow,
rankRows: readonly TurnEngineRankDataRow[],
@@ -181,12 +171,7 @@ const mapGeneralRow = (
item: normalizeCode(row.itemCode),
};
const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) };
for (const rank of rankRows) {
if (rank.type === 'experience' || rank.type === 'dedication') {
continue;
}
rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value;
}
applyPersistedRankRowsToMeta(rawMeta, rankRows);
const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value]));
const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots);
return {
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import type { TurnSchedule } from '@sammo-ts/logic';
import { rankMetaKey } from '../src/turn/rankData.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
@@ -294,6 +296,9 @@ describe('legacy general turn lifecycle', () => {
});
it('retires a player general and resets inherited stats and rank state', async () => {
const legacyRankMeta = Object.fromEntries(
LEGACY_RANK_DATA_TYPES.map((type, index) => [rankMetaKey(type), index + 1])
);
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
@@ -302,11 +307,11 @@ describe('legacy general turn lifecycle', () => {
experience: 101,
dedication: 81,
meta: {
...legacyRankMeta,
killturn: 24,
dex1: 101,
inherit_lived_month: 10,
inherit_active_action: 4,
rank_warnum: 12,
},
}),
]),
@@ -325,7 +330,9 @@ describe('legacy general turn lifecycle', () => {
expect(updated.meta.dex1).toBe(51);
expect(updated.meta.inherit_lived_month).toBe(0);
expect(updated.meta.inherit_active_action).toBe(0);
expect(updated.meta.rank_warnum).toBe(0);
for (const type of LEGACY_RANK_DATA_TYPES) {
expect(updated.meta[rankMetaKey(type)], type).toBe(0);
}
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
});
});
@@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
);
});
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => {
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => {
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
const nations = [
{
@@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
}) as never,
});
handler.beforeMonthChanged?.({} as never);
await handler.beforeMonthChanged?.({} as never);
expect(updates).toEqual([
{
@@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
// Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke.
}, 60000);
}, 300_000);
});
@@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
}
throw error;
}
}, 180000);
}, 360_000);
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
import {
applyPersistedRankRowsToMeta,
buildLegacyComparableRankRows,
buildPersistedRankRows,
rankMetaKey,
} from '../src/turn/rankData.js';
describe('rank data projection', () => {
it('projects every core row while preserving legacy key and integer semantics', () => {
const rows = buildPersistedRankRows({
id: 7,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {
rank_warnum: '3.5',
ttw: 4.5,
inherit_earned: '9',
dex1: 12,
},
});
expect(rows).toHaveLength(RANK_DATA_TYPES.length);
expect(rows).toEqual(
expect.arrayContaining([
{ generalId: 7, nationId: 2, type: 'experience', value: 11 },
{ generalId: 7, nationId: 2, type: 'dedication', value: 20 },
{ generalId: 7, nationId: 2, type: 'warnum', value: 4 },
{ generalId: 7, nationId: 2, type: 'ttw', value: 5 },
{ generalId: 7, nationId: 2, type: 'inherit_earned', value: 9 },
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
])
);
expect(buildLegacyComparableRankRows({
id: 7,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {},
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
});
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
const meta: Record<string, unknown> = {};
applyPersistedRankRowsToMeta(meta, [
{ type: 'warnum', value: 3 },
{ type: 'ttw', value: 4 },
{ type: 'inherit_spent', value: 5 },
{ type: 'dex1', value: 6 },
{ type: 'unknown', value: 99 },
]);
expect(meta).toEqual({
rank_warnum: 3,
ttw: 4,
inherit_spent: 5,
dex1: 6,
});
expect(rankMetaKey('warnum')).toBe('rank_warnum');
expect(rankMetaKey('betgold')).toBe('betgold');
});
});
+2
View File
@@ -13,5 +13,7 @@ export default defineConfig({
environment: 'node',
globals: true,
include: ['test/**/*.test.ts'],
maxWorkers: 4,
testTimeout: 10_000,
},
});
+371
View File
@@ -0,0 +1,371 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
type AuctionFixture = {
failResourceBid?: boolean;
resourceBidCount: number;
uniqueBidCount: number;
};
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32000,
data: { code: 'BAD_REQUEST', httpStatus: 400, 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 readReferenceImage = async (filename: string): Promise<Buffer> => {
for (const imageRoot of imageRoots) {
try {
return await readFile(resolve(imageRoot, filename));
} catch {
// The main checkout and nested feature worktrees have different parents.
}
}
throw new Error(`Reference image not found: ${filename}`);
};
const overview = {
resourceAuctions: [
{
id: 1,
type: 'BUY_RICE',
targetCode: '1000',
status: 'OPEN',
hostGeneralId: 11,
hostName: '조조',
isCallerHost: false,
closeAt: '2026-07-27T02:30:00.000Z',
detail: {
title: '쌀 1000 경매',
amount: 1000,
isReverse: false,
startBidAmount: 500,
finishBidAmount: 1800,
},
highestBid: {
amount: 750,
bidderName: '관우',
isCaller: false,
eventAt: '2026-07-26T01:00:00.000Z',
},
},
{
id: 2,
type: 'SELL_RICE',
targetCode: '900',
status: 'OPEN',
hostGeneralId: 7,
hostName: '유비',
isCallerHost: true,
closeAt: '2026-07-27T03:00:00.000Z',
detail: {
title: '금 900 경매',
amount: 900,
isReverse: false,
startBidAmount: 600,
finishBidAmount: 1700,
},
highestBid: null,
},
],
uniqueAuctions: [
{
id: 10,
type: 'UNIQUE_ITEM',
targetCode: 'che_무기_12_칠성검',
status: 'OPEN',
hostGeneralId: null,
hostName: '청룡',
isCallerHost: false,
closeAt: '2026-07-27T04:00:00.000Z',
detail: {
title: '칠성검 경매',
startBidAmount: 5000,
remainCloseDateExtensionCnt: 1,
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
},
highestBid: {
amount: 5500,
bidderName: '백호',
isCaller: false,
eventAt: '2026-07-26T02:00:00.000Z',
},
},
{
id: 9,
type: 'UNIQUE_ITEM',
targetCode: 'che_서적_15_손자병법',
status: 'FINISHED',
hostGeneralId: null,
hostName: '현무',
isCallerHost: true,
closeAt: '2026-07-25T04:00:00.000Z',
detail: {
title: '손자병법 경매',
startBidAmount: 5000,
remainCloseDateExtensionCnt: 0,
availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z',
},
highestBid: {
amount: 6000,
bidderName: '현무',
isCaller: true,
eventAt: '2026-07-25T03:00:00.000Z',
},
},
],
callerAlias: '현무',
remainPoint: 9000,
recentLogs: [
{
id: 1,
text: '<C>●</>경매 1번 거래가 성사되었습니다.',
createdAt: '2026-07-25T00:00:00.000Z',
},
],
};
const uniqueDetail = {
auction: {
id: 10,
targetCode: 'che_무기_12_칠성검',
status: 'OPEN',
hostName: '청룡',
isCallerHost: false,
closeAt: '2026-07-27T04:00:00.000Z',
detail: {
title: '칠성검 경매',
startBidAmount: 5000,
remainCloseDateExtensionCnt: 1,
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
},
},
bids: [
{
id: 101,
amount: 5500,
bidderName: '백호',
isCaller: false,
eventAt: '2026-07-26T02:00:00.000Z',
},
{
id: 100,
amount: 5000,
bidderName: '현무',
isCaller: true,
eventAt: '2026-07-26T01:00:00.000Z',
},
],
callerAlias: '현무',
remainPoint: 9000,
};
const installFixture = async (page: Page, state: AuctionFixture) => {
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
await page.route(`**/image/game/${filename}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await readReferenceImage(filename),
});
});
}
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') {
return response({ myGeneral: { id: 7, name: '유비' } });
}
if (operation === 'join.getConfig') {
return response({});
}
if (operation === 'auction.getOverview') {
return response(overview);
}
if (operation === 'auction.getUniqueDetail') {
return response(uniqueDetail);
}
if (operation === 'auction.bidBuyRice') {
if (state.failResourceBid) {
state.failResourceBid = false;
return errorResponse(operation, '금이 부족합니다.');
}
state.resourceBidCount += 1;
return response({ ok: true });
}
if (operation === 'auction.bidUnique') {
state.uniqueBidCount += 1;
return response({ ok: true });
}
if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') {
return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
const gotoAuction = async (page: Page, suffix = 'auction') => {
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
await page.goto(suffix);
await lobbyResponse;
await expect(page.locator('#container')).toBeVisible();
};
test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => {
const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 };
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 800 });
await gotoAuction(page);
await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible();
await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible();
await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible();
await expect(page.getByText('단가', { exact: true }).first()).toBeVisible();
const geometry = await page.locator('#container').evaluate((container) => {
const box = (selector: string) => {
const rect = container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
const containerRect = container.getBoundingClientRect();
const row = container.querySelector<HTMLElement>('.resource-row')!;
const rowRect = row.getBoundingClientRect();
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
const button = container.querySelector<HTMLElement>('.tab-button')!;
const buttonStyle = getComputedStyle(button);
return {
container: { x: containerRect.x, width: containerRect.width },
topBar: box('.top-back-bar'),
row: { width: rowRect.width, height: rowRect.height },
cells,
button: {
height: button.getBoundingClientRect().height,
borderRadius: buttonStyle.borderRadius,
cursor: buttonStyle.cursor,
fontSize: buttonStyle.fontSize,
},
};
});
expect(geometry.container).toEqual({ x: 0, width: 1000 });
expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 });
expect(geometry.row).toEqual({ width: 1000, height: 22 });
expect(geometry.cells[0]).toBeCloseTo(66.66, 1);
expect(geometry.cells[1]).toBeCloseTo(133.34, 1);
expect(geometry.cells[6]).toBeCloseTo(200, 1);
expect(geometry.button).toEqual({
height: 35.5,
borderRadius: '5.25px',
cursor: 'pointer',
fontSize: '14px',
});
await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true });
const firstRow = page.locator('.resource-row.clickable-row').first();
await firstRow.click();
const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' });
await bidInput.fill('800');
await page.getByRole('button', { name: '입찰', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('금이 부족합니다.');
await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true });
expect(state.resourceBidCount).toBe(0);
await page.getByRole('button', { name: '입찰', exact: true }).click();
await expect(page.getByRole('status')).toContainText('입찰했습니다.');
expect(state.resourceBidCount).toBe(1);
await firstRow.hover();
expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer');
await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true });
});
test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => {
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
await page.setViewportSize({ width: 500, height: 800 });
await gotoAuction(page);
const geometry = await page
.locator('.resource-row')
.first()
.evaluate((row) => {
const origin = row.getBoundingClientRect();
const relative = (selector: string) => {
const rect = row.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
};
return {
row: { width: origin.width, height: origin.height },
idx: relative('.idx'),
host: relative('.host'),
amount: relative('.amount'),
close: relative('.close-date'),
};
});
expect(geometry.row).toEqual({ width: 500, height: 43 });
expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 });
expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 });
expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 });
expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 });
await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true });
});
test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => {
const state = { resourceBidCount: 0, uniqueBidCount: 0 };
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 800 });
await gotoAuction(page, 'auction?type=unique');
await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible();
await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무');
await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible();
await expect(page.getByText('최대지연', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible();
await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible();
await expect(page.getByText('남음', { exact: true })).toBeVisible();
await expect(page.getByText('소진', { exact: true })).toBeVisible();
const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => {
const style = getComputedStyle(element);
return { color: style.color, fontWeight: style.fontWeight };
});
expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' });
const input = page.getByRole('spinbutton', { name: '유산포인트' });
await input.fill('5600');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?');
await dialog.accept();
});
await page.getByRole('button', { name: '입찰', exact: true }).click();
await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.');
expect(state.uniqueBidCount).toBe(1);
await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true });
});
test('resource host cannot bid on the auction opened by its own general', async ({ page }) => {
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
await gotoAuction(page);
await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click();
await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled();
});
+29 -8
View File
@@ -23,6 +23,12 @@ type FixtureState = {
permission: 'head' | 'member';
myset: number;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
};
type TrpcRequestPayload = {
json?: Record<string, unknown>;
input?: { json?: Record<string, unknown> };
};
const myGeneral = (state: FixtureState) => ({
@@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({
const install = async (page: Page, state: FixtureState) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'menu-token');
localStorage.setItem('sammo-game-token', 'ga_menu-token');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', async (route) => {
@@ -130,7 +136,16 @@ const install = async (page: Page, state: FixtureState) => {
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const requestBody =
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
const results = operations.map((operation, operationIndex) => {
const rawPayload =
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
const payload =
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
const jsonInput =
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(myGeneral(state));
@@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => {
if (operation === 'general.getMyLog')
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
if (operation === 'general.setMySetting') {
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } };
state.settingMutations.push(raw.input?.json ?? {});
state.settingMutations.push(jsonInput);
state.myset = Math.max(0, state.myset - 1);
return response({ ok: true });
}
if (operation === 'public.recordAccess') {
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
if (pageName) state.accessPages.push(pageName);
return response({ recorded: true });
}
if (operation === 'nation.getBattleCenter') {
if (state.permission === 'member') {
return {
@@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => {
};
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] };
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('traffic');
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
await expect.poll(() => state.accessPages).toContain('traffic');
const geometry = await page.locator('#traffic-container').evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -244,12 +264,13 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
});
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect.poll(() => state.accessPages).toContain('my-page');
const desktop = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -322,7 +343,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
});
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, head);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('battle-center');
@@ -368,7 +389,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
await page.unrouteAll({ behavior: 'wait' });
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] };
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] };
await install(page, member);
await page.reload();
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
@@ -16,6 +16,7 @@ export default defineConfig({
'nationOffices.spec.ts',
'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts',
'auction.spec.ts',
'battleSimulator.spec.ts',
'battleSimulatorRef.spec.ts',
],
+1
View File
@@ -11,6 +11,7 @@
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
interface Props {
general: GeneralDraft;
options: BattleSimOptions;
mode: 'attacker' | 'defender';
title: string;
@@ -11,6 +10,7 @@ interface Props {
}
const props = defineProps<Props>();
const general = defineModel<GeneralDraft>('general', { required: true });
const emit = defineEmits<{
(event: 'import'): void;
+38
View File
@@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
const accessPageByRouteName = {
home: 'front-info',
'nation-info': 'nation-info',
'nation-cities': 'nation-cities',
'global-info': 'global-info',
'current-city': 'current-city',
diplomacy: 'diplomacy',
'nation-generals': 'nation-generals',
'nation-personnel': 'nation-personnel',
'nation-finance': 'nation-finance',
'battle-center': 'battle-center',
board: 'board',
'board-secret': 'board',
'best-general': 'best-general',
'hall-of-fame': 'hall-of-fame',
'dynasty-list': 'dynasty',
'dynasty-detail': 'dynasty',
yearbook: 'yearbook',
'nation-betting': 'nation-betting',
traffic: 'traffic',
'npc-list': 'npc-list',
'my-page': 'my-page',
'npc-control': 'npc-control',
tournament: 'tournament',
betting: 'betting',
} as const;
const routes = [
{
@@ -371,4 +399,14 @@ router.beforeEach(async (to) => {
return true;
});
router.afterEach((to) => {
const session = useSessionStore();
const routeName = typeof to.name === 'string' ? to.name : '';
const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName];
if (!page || !session.hasGeneral) {
return;
}
void trpc.public.recordAccess.mutate({ page }).catch(() => undefined);
});
export default router;
+2 -5
View File
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
return '';
}
let match: RegExpExecArray | null = null;
let lastIndex = 0;
const result: string[] = [];
while ((match = logRegex.exec(text)) !== null) {
for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
const partAll = match[0];
const subPart = match[1];
const index = match.index;
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
if (subPart === '/') {
result.push('</span>');
} else if (subPart.length === 2) {
result.push(
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
);
result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
} else {
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
}
+688 -201
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
@@ -11,7 +10,8 @@ type ResourceAuction = AuctionOverview['resourceAuctions'][number];
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
const activeTab = ref<'resource' | 'unique'>('resource');
const route = useRoute();
const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'unique' : 'resource');
const loading = ref(false);
const actionBusy = ref(false);
const error = ref<string | null>(null);
@@ -38,22 +38,57 @@ const resolveErrorMessage = (value: unknown): string => {
};
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const formatDate = (value: string): string =>
new Intl.DateTimeFormat('ko-KR', {
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value));
...(showSecond ? { second: '2-digit' } : {}),
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((entry) => entry.type === type)?.value ?? '';
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
};
const resourceTitle = (auction: ResourceAuction): string =>
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
const buyRice = computed(() =>
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'BUY_RICE')
);
const sellRice = computed(() =>
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'SELL_RICE')
);
const ongoingUnique = computed(() =>
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status === 'OPEN')
);
const finishedUnique = computed(() =>
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status !== 'OPEN')
);
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
const selectResource = (auction: ResourceAuction): void => {
selectedResource.value = auction;
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
};
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
selectedUnique.value = auction;
uniqueDetail.value = null;
error.value = null;
try {
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const loadOverview = async (): Promise<void> => {
loading.value = true;
@@ -65,8 +100,14 @@ const loadOverview = async (): Promise<void> => {
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
}
if (selectedUnique.value) {
selectedUnique.value =
const updated =
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
selectedUnique.value = updated;
if (updated) {
await selectUnique(updated);
}
} else if (activeTab.value === 'unique' && ongoingUnique.value[0]) {
await selectUnique(ongoingUnique.value[0]);
}
} catch (err) {
error.value = resolveErrorMessage(err);
@@ -75,23 +116,6 @@ const loadOverview = async (): Promise<void> => {
}
};
const selectResource = (auction: ResourceAuction): void => {
selectedResource.value = auction;
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
};
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
selectedUnique.value = auction;
error.value = null;
try {
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const runAction = async (action: () => Promise<void>): Promise<void> => {
if (actionBusy.value) {
return;
@@ -138,7 +162,7 @@ const bidResourceAuction = (): Promise<void> =>
} else {
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
}
message.value = `${auction.id}번 경매에 입찰했습니다.`;
message.value = '입찰했습니다.';
});
const bidUniqueAuction = (): Promise<void> =>
@@ -147,206 +171,669 @@ const bidUniqueAuction = (): Promise<void> =>
if (!auction) {
return;
}
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
if (
!window.confirm(
`${auction.detail.title ?? auction.targetCode ?? '유니크'}${bidAmount.value}유산포인트를 입찰하시겠습니까?`
)
) {
return;
}
await trpc.auction.bidUnique.mutate({
auctionId: auction.id,
amount: bidAmount.value,
tryExtendCloseDate: true,
tryExtendCloseDate: false,
});
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
await selectUnique(auction);
message.value = '입찰이 완료되었습니다.';
});
const closeWindow = (): void => window.close();
watch(activeTab, (tab) => {
error.value = null;
message.value = null;
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
void selectUnique(ongoingUnique.value[0]);
}
});
onMounted(() => {
void loadOverview();
});
</script>
<template>
<main class="auction-page">
<header class="page-header">
<div>
<h1>거래장</h1>
<p>· 거래와 유니크 아이템 경매를 확인합니다.</p>
</div>
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
<main id="container" class="legacy-auction-page bg0">
<header class="top-back-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview">
갱신
</button>
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
<button
class="legacy-button tab-button"
:aria-pressed="activeTab === 'resource'"
@click="activeTab = 'resource'"
>
/
</button>
<button
class="legacy-button tab-button"
:aria-pressed="activeTab === 'unique'"
@click="activeTab = 'unique'"
>
유니크
</button>
</header>
<nav class="tabs" aria-label="경매 종류">
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">· 경매</button>
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
</nav>
<p v-if="error" class="auction-notice error" role="alert">{{ error }}</p>
<p v-if="message" class="auction-notice success" role="status">{{ message }}</p>
<div v-if="loading && !overview" class="loading-state">불러오는 중...</div>
<p v-if="error" class="notice error">{{ error }}</p>
<p v-if="message" class="notice success">{{ message }}</p>
<SkeletonLines v-if="loading && !overview" :lines="8" />
<section v-else-if="activeTab === 'resource'" class="resource-auction bg0">
<h2 class="section-title bg2">거래장</h2>
<template v-else-if="activeTab === 'resource'">
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
<div class="auction-table resource-table">
<div class="table-head">
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰</span>
<span>현재</span><span>마감가</span><span>종료</span>
</div>
<button
v-for="auction in resourceAuctions"
:key="auction.id"
class="table-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span>{{ auction.id }}</span>
<span>{{ resourceTitle(auction) }}</span>
<span>{{ auction.hostName }}</span>
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span>{{ formatDate(auction.closeAt) }}</span>
</button>
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
<section class="resource-section" aria-labelledby="buy-rice-heading">
<h3 id="buy-rice-heading" class="resource-kind buy-rice"> 구매</h3>
<div class="resource-row resource-header">
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰</span>
<span class="bid-ratio"></span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
<button
v-for="auction in buyRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
<span class="amount tnum"> {{ formatNumber(auction.detail.amount) }}</span>
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
</span>
<span class="bid-ratio tnum">
{{
auction.highestBid && auction.detail.amount
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
: '-'
}}
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
<p v-if="buyRice.length === 0" class="empty-row">진행 중인 구매 경매가 없습니다.</p>
</section>
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
<strong>{{ selectedResource.id }} {{ resourceTitle(selectedResource) }}</strong>
<label>
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
</label>
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
</form>
</PanelCard>
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
<form class="open-form" @submit.prevent="openResourceAuction">
<label>
<span>매물</span>
<select v-model="openForm.type">
<option value="BUY_RICE"></option>
<option value="SELL_RICE"></option>
</select>
</label>
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
<label><span>기간()</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
<button :disabled="actionBusy">등록</button>
</form>
</PanelCard>
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
<ol class="log-list">
<!-- eslint-disable vue/no-v-html -->
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
</ol>
</PanelCard>
</template>
<template v-else>
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
<div class="auction-table unique-table">
<div class="table-head">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in uniqueAuctions"
:key="auction.id"
class="table-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span>
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
<span>{{ formatDate(auction.closeAt) }}</span>
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
</button>
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
<section class="resource-section" aria-labelledby="sell-rice-heading">
<h3 id="sell-rice-heading" class="resource-kind sell-rice"> 판매</h3>
<div class="resource-row resource-header">
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
</PanelCard>
<button
v-for="auction in sellRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
<span class="amount tnum"> {{ formatNumber(auction.detail.amount) }}</span>
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
</span>
<span class="bid-ratio tnum">
{{
auction.highestBid && auction.detail.amount
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
: '-'
}}
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
<p v-if="sellRice.length === 0" class="empty-row">진행 중인 판매 경매가 없습니다.</p>
</section>
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
<form v-if="selectedResource" class="resource-bid-form" @submit.prevent="bidResourceAuction">
<span class="bid-description">
{{ selectedResource.id }} {{ selectedResource.type === 'BUY_RICE' ? '쌀' : '금' }}
{{ formatNumber(selectedResource.detail.amount) }} 경매에
{{ selectedResource.type === 'BUY_RICE' ? '금' : '쌀' }}
</span>
<input
v-model.number="bidAmount"
:aria-label="`${selectedResource.id} 경매 입찰가`"
type="number"
:min="selectedResource.detail.startBidAmount ?? 1"
:max="selectedResource.detail.finishBidAmount ?? undefined"
step="10"
required
/>
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
</form>
<h3 class="subsection-title">경매 등록</h3>
<form class="open-form" @submit.prevent="openResourceAuction">
<fieldset>
<legend>매물</legend>
<div class="item-toggle">
<button
class="legacy-button"
type="button"
:aria-pressed="openForm.type === 'BUY_RICE'"
@click="openForm.type = 'BUY_RICE'"
>
</button>
<button
class="legacy-button"
type="button"
:aria-pressed="openForm.type === 'SELL_RICE'"
@click="openForm.type = 'SELL_RICE'"
>
</button>
</div>
</fieldset>
<label>
<span>수량 ({{ openForm.type === 'BUY_RICE' ? '쌀' : '금' }})</span>
<input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" />
</label>
<label
><span>기간()</span><input v-model.number="openForm.closeTurnCnt" type="number" min="3" max="24"
/></label>
<label>
<span>시작가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.startBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<label>
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<button class="legacy-button register-button" :disabled="actionBusy">등록</button>
</form>
<h3 class="subsection-title">이전 경매(최근 20)</h3>
<div class="recent-logs">
<!-- eslint-disable vue/no-v-html -->
<div v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty-row">경매 기록이 없습니다.</div>
</div>
</section>
<section v-else class="unique-auction bg0">
<div class="caller-alias">
가명: <strong>{{ overview?.callerAlias ?? '-' }}</strong>
</div>
<section v-if="uniqueDetail" class="unique-detail">
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }} 상세</h2>
<dl class="detail-grid">
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
<dt class="bg1">경매명</dt>
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dt class="bg1">주최자(익명)</dt>
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt class="bg1">종료일시</dt>
<dd class="tnum">{{ cutDateTime(uniqueDetail.auction.closeAt, true) }}</dd>
<dt class="bg1">최대지연</dt>
<dd class="tnum">
{{ cutDateTime(uniqueDetail.auction.detail.availableLatestBidCloseDate, true) }}
</dd>
</dl>
<div class="bid-history">
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
<strong>{{ formatNumber(bid.amount) }}</strong>
<time>{{ formatDate(bid.eventAt) }}</time>
</div>
<h3 class="subsection-title bg1">입찰자 목록</h3>
<div class="bid-row bid-header"><span>입찰자</span><span>입찰포인트</span><span>시각</span></div>
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
<span class="tnum">{{ formatNumber(bid.amount) }}</span>
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time>
</div>
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
<button :disabled="actionBusy">입찰</button>
</form>
</PanelCard>
</template>
<template v-if="uniqueDetail.auction.status === 'OPEN'">
<h3 class="subsection-title bg1">입찰하기</h3>
<form class="unique-bid-form" @submit.prevent="bidUniqueAuction">
<label for="unique-bid">
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
</label>
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
<button class="legacy-button" :disabled="actionBusy">입찰</button>
</form>
</template>
</section>
<section class="unique-list-section">
<h2 class="subsection-title bg1">진행중인 경매 목록</h2>
<div class="unique-row unique-header">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in ongoingUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
auction.highestBid?.bidderName ?? '-'
}}</span>
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
<p v-if="ongoingUnique.length === 0" class="empty-row">진행중인 유니크 경매가 없습니다.</p>
</section>
<section class="unique-list-section">
<h2 class="subsection-title bg1">종료된 경매 목록</h2>
<div class="unique-row unique-header">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in finishedUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
auction.highestBid?.bidderName ?? '-'
}}</span>
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
<p v-if="finishedUnique.length === 0" class="empty-row">종료된 유니크 경매가 없습니다.</p>
</section>
</section>
<footer class="bottom-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
</footer>
</main>
</template>
<style scoped>
.auction-page {
min-height: 100%;
padding: 18px;
color: #e8ddc4;
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
.legacy-auction-page {
width: 100%;
max-width: 1000px;
box-sizing: border-box;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
display: flex;
.bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.bg1 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.bg2 {
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.top-back-bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.top-back-bar h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
line-height: 32px;
text-align: center;
}
.legacy-button {
box-sizing: border-box;
border: solid #3d3d3d;
border-width: 0 1px 4px;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #fff;
background: #444;
font: inherit;
font-weight: 700;
line-height: 21px;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus {
border-color: #353535;
background: #393939;
}
.legacy-button:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.legacy-button:active,
.legacy-button[aria-pressed='true'] {
border-color: #303030;
background: #333;
}
.legacy-button:disabled {
cursor: default;
opacity: 0.65;
}
.close-button,
.reload-button {
margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.close-button:hover,
.close-button:focus,
.reload-button:hover,
.reload-button:focus {
border-color: #004523;
background: #004a25;
}
.top-back-bar .close-button,
.top-back-bar .reload-button {
height: 32px;
}
.tab-button {
border-color: #3d3d3d;
background: #444;
}
.tab-button[aria-pressed='true'] {
border-color: #3d3d3d;
background: #444;
}
.tab-button:hover,
.tab-button:focus {
border-color: #353535;
background: #393939;
}
.section-title,
.subsection-title,
.resource-kind {
margin: 0;
min-height: 18px;
font: inherit;
font-weight: 400;
}
.resource-kind.buy-rice {
color: #000;
background: orange;
}
.resource-kind.sell-rice {
color: #000;
background: skyblue;
}
.resource-row {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
align-items: center;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid gray;
padding: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: center;
}
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.page-header h1 { margin: 0; font-size: 1.45rem; }
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
.tabs { gap: 6px; margin-bottom: 12px; }
button, input, select {
border: 1px solid rgba(201, 164, 90, 0.55);
background: rgba(20, 17, 12, 0.95);
color: #e8ddc4;
padding: 8px 10px;
.clickable-row {
cursor: pointer;
}
button { cursor: pointer; }
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
button:disabled { cursor: not-allowed; opacity: 0.45; }
.ghost { background: transparent; }
.notice { padding: 9px 12px; border: 1px solid; }
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
.auction-table { overflow-x: auto; }
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
.table-row > span { padding: 8px 5px; }
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
.bid-form label, .open-form label { display: grid; gap: 5px; }
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
.open-form label { min-width: 110px; flex: 1; }
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
.log-list { margin: 0; padding-left: 24px; }
.log-list li { padding: 4px 0; }
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
.bid-history { margin-top: 12px; }
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
.me { color: aquamarine; font-weight: 700; }
@media (max-width: 720px) {
.auction-page { padding: 10px; }
.page-header { align-items: flex-start; }
.detail-grid { grid-template-columns: 110px 1fr; }
.clickable-row:hover,
.clickable-row:focus-visible,
.clickable-row.selected {
background-color: rgb(255 255 255 / 12%);
outline: 0;
}
.no-bid {
color: #ccc;
}
.tnum {
font-variant-numeric: tabular-nums;
}
.empty-row {
min-height: 24px;
margin: 0;
padding: 4px 8px;
text-align: center;
}
.resource-bid-form {
min-height: 42px;
display: grid;
grid-template-columns: 2fr 2fr 1fr;
align-items: center;
gap: 4px;
padding-right: 33.3333%;
padding-left: 25%;
}
.bid-description {
text-align: right;
}
input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 1px solid #000;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #303030;
background: #ddd;
font: inherit;
}
input:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.open-form {
min-height: 76px;
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 2fr 1fr;
align-items: end;
gap: 4px;
box-sizing: border-box;
padding-right: 8.3333%;
padding-left: 16.6667%;
}
.open-form fieldset,
.open-form label {
min-width: 0;
margin: 0;
border: 0;
padding: 0;
}
.open-form legend {
padding: 0;
}
.open-form label {
display: grid;
gap: 2px;
}
.item-toggle {
display: flex;
}
.item-toggle .legacy-button {
flex: 1;
}
.register-button {
height: 100%;
align-self: stretch;
}
.recent-logs {
min-height: 24px;
}
.caller-alias {
min-height: 20px;
}
.caller-alias strong,
.is-me {
color: aqua;
font-weight: 700;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr;
margin: 0;
text-align: center;
}
.detail-grid dt,
.detail-grid dd {
min-width: 0;
min-height: 18px;
display: grid;
align-content: center;
margin: 0;
}
.bid-row {
min-height: 22px;
display: grid;
grid-template-columns: 3fr 2fr 3fr;
align-items: center;
padding: 0 20%;
text-align: center;
}
.bid-header {
border-bottom: 1px solid #fff;
}
.bid-row > :nth-child(2) {
padding-right: 20px;
text-align: right;
}
.unique-bid-form {
min-height: 40px;
display: grid;
grid-template-columns: 3fr 2fr 1fr;
align-items: center;
padding: 0 25%;
}
.unique-bid-form label {
text-align: center;
}
.unique-row {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 1fr 4fr 1fr 2fr 1fr 1fr 2fr;
align-items: center;
box-sizing: border-box;
border: 0;
padding: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: center;
}
.unique-header {
border-bottom: 1px solid #fff;
}
.unique-row > :last-child {
padding-right: 8px;
text-align: right;
}
.auction-notice {
min-height: 28px;
box-sizing: border-box;
margin: 0;
padding: 5px 8px;
}
.auction-notice.error {
background: #842029;
}
.auction-notice.success {
background: #0f5132;
}
.loading-state {
min-height: 120px;
display: grid;
place-items: center;
}
.bottom-bar {
padding-top: 20px;
}
@media (max-width: 991px) {
.legacy-auction-page {
max-width: none;
}
}
@media (max-width: 500px) {
.resource-row {
min-height: 43px;
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
grid-template-rows: 1fr 1fr;
}
.resource-row .idx {
grid-column: 1;
grid-row: 1 / 3;
}
.resource-row .host {
grid-column: 2;
grid-row: 1;
}
.resource-row .amount {
grid-column: 2;
grid-row: 2;
}
.resource-row .highest-bidder {
grid-column: 3;
grid-row: 1;
}
.resource-row .highest-bid {
grid-column: 3;
grid-row: 2;
}
.resource-row .bid-ratio {
grid-column: 4;
grid-row: 1 / 3;
}
.resource-row .finish-bid {
grid-column: 5;
grid-row: 1 / 3;
}
.resource-row .close-date {
grid-column: 6;
grid-row: 1 / 3;
}
.resource-bid-form {
grid-template-columns: 4fr 3fr 2fr;
padding-right: 16.6667%;
padding-left: 8.3333%;
}
.open-form {
grid-template-columns: 2fr 2.3333fr 2fr 2.3333fr 2.3333fr 1fr;
padding: 0;
}
.detail-grid {
grid-template-columns: 2fr 4fr 2fr 4fr;
}
.bid-row {
padding: 0;
grid-template-columns: 4fr 4fr 4fr;
}
.unique-bid-form {
padding: 0;
grid-template-columns: 5fr 4fr 3fr;
}
}
</style>
@@ -1125,7 +1125,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard
v-if="attackerGeneral"
:general="attackerGeneral!"
v-model:general="attackerGeneral"
:options="options!"
mode="attacker"
title="출병자 설정"
@@ -1193,7 +1193,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard
v-for="(defender, index) in defenders"
:key="defender.id"
:general="defender"
v-model:general="defenders[index]"
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
+94 -20
View File
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
}
};
const prevOptions = computed(() =>
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
);
const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
@@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) =>
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
const canDestroy = (letter: DiplomacyLetter) =>
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
editable.value &&
letter.state === 'ACTIVATED' &&
(data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
onMounted(() => {
loadLetters();
void loadLetters();
});
onBeforeUnmount(() => {
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
<div class="editor-group">
<div class="editor-label">내용(국가 공개)</div>
<div class="editor-toolbar">
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button>
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button>
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleBold().run()"
:class="{ active: briefEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleItalic().run()"
:class="{ active: briefEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: briefEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('brief')">링크</button>
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'brief';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div>
<EditorContent v-if="briefEditor" :editor="briefEditor" />
</div>
<div class="editor-group">
<div class="editor-label">내용(외교권자 전용)</div>
<div class="editor-toolbar">
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button>
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button>
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleBold().run()"
:class="{ active: detailEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleItalic().run()"
:class="{ active: detailEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: detailEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('detail')">링크</button>
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'detail';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div>
<EditorContent v-if="detailEditor" :editor="detailEditor" />
</div>
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
</button>
<div v-if="historyOpen[letter.id]" class="history-panel">
<template v-if="getPrevLetter(letter)">
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} {{ getPrevLetter(letter)?.dest.nationName }}</p>
<p>
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }}
{{ getPrevLetter(letter)?.dest.nationName }}
</p>
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
</template>
<p v-else class="hint">이전 문서를 찾을 없습니다.</p>
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
</div>
</div>
<footer class="letter-actions">
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
승인
</button>
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">
거부
</button>
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
<button v-if="canRenew(letter)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button>
<button
v-if="canRenew(letter)"
type="button"
@click="
selectedPrevId = letter.id;
applyPrevLetter();
"
>
추가 문서 작성
</button>
</footer>
</article>
</section>
@@ -565,4 +639,4 @@ onBeforeUnmount(() => {
.loading {
color: #9aa3b8;
}
</style>
</style>
@@ -242,7 +242,7 @@ watch(
);
onMounted(() => {
loadData();
void loadData();
});
onBeforeUnmount(() => {
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
<div class="panel-header">
<h2>국가 방침</h2>
<div class="panel-actions">
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">
수정
</button>
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
저장
</button>
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
취소
</button>
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
</div>
</div>
<div v-if="editingNationMsg" class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="{ active: editor?.isActive('bold') }"
>
굵게
</button>
<button
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
<div class="panel-card">
<h3>자금 예산</h3>
<dl>
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div>
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div>
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd></div>
<div>
<dt>현재</dt>
<dd>{{ data.gold.toLocaleString() }}</dd>
</div>
<div>
<dt>단기 수입</dt>
<dd>{{ data.income.gold.war.toLocaleString() }}</dd>
</div>
<div>
<dt>세금</dt>
<dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd>
</div>
<div>
<dt>수입/지출</dt>
<dd>
+{{ Math.floor(incomeGold).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</dd>
</div>
<div>
<dt>국고 예산</dt>
<dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd>
</div>
</dl>
</div>
<div class="panel-card">
<h3>군량 예산</h3>
<dl>
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div>
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div>
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd></div>
<div>
<dt>현재</dt>
<dd>{{ data.rice.toLocaleString() }}</dd>
</div>
<div>
<dt>둔전 수입</dt>
<dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd>
</div>
<div>
<dt>세금</dt>
<dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd>
</div>
<div>
<dt>수입/지출</dt>
<dd>
+{{ Math.floor(incomeRice).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</dd>
</div>
<div>
<dt>국고 예산</dt>
<dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd>
</div>
</dl>
</div>
<div class="panel-card">
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
<div class="panel-card">
<h3>기밀 권한</h3>
<div class="input-row">
<input v-model.number="policyDraft.secretLimit" type="number" min="1" max="99" :disabled="!editable" />
<input
v-model.number="policyDraft.secretLimit"
type="number"
min="1"
max="99"
:disabled="!editable"
/>
<span></span>
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
</div>
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
/>
전쟁 금지
</label>
<span class="hint">잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span>
<span class="hint"
>잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span
>
</div>
</div>
<div class="panel-card">
@@ -574,4 +616,4 @@ onBeforeUnmount(() => {
.loading {
color: #9aa3b8;
}
</style>
</style>
@@ -134,7 +134,7 @@ watch(
);
onMounted(() => {
loadData();
void loadData();
});
onBeforeUnmount(() => {
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
</div>
<div v-if="editing" class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="{ active: editor?.isActive('bold') }"
>
굵게
</button>
<button
+1
View File
@@ -817,6 +817,7 @@ export const adminRouter = router({
profileName: profile.profileName,
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
auctionRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
@@ -72,7 +73,13 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
row: GatewayProfileRecord,
runtimeMap: Map<
string,
{ apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean }
{
apiRunning: boolean;
daemonRunning: boolean;
auctionRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
}
>
): LobbyProfileStatus {
const meta = row.meta;
@@ -85,6 +92,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
runtime: runtimeMap.get(row.profileName) ?? {
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
export interface ProfileRuntimeState {
apiRunning: boolean;
daemonRunning: boolean;
auctionRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
}
@@ -70,6 +71,7 @@ export const planProfileReconcile = (
shouldStart: !(
runtime.apiRunning &&
runtime.daemonRunning &&
runtime.auctionRunning &&
runtime.battleSimRunning &&
runtime.tournamentRunning
),
@@ -79,7 +81,11 @@ export const planProfileReconcile = (
return {
shouldStart: false,
shouldStop:
runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning,
runtime.apiRunning ||
runtime.daemonRunning ||
runtime.auctionRunning ||
runtime.battleSimRunning ||
runtime.tournamentRunning,
};
};
@@ -280,15 +286,20 @@ const parseInstallOptions = (
};
};
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string =>
const buildProcessName = (
profileName: string,
role: 'api' | 'daemon' | 'auction' | 'battle-sim' | 'tournament'
): string =>
`sammo:${profileName}:${
role === 'api'
? 'game-api'
: role === 'daemon'
? 'turn-daemon'
: role === 'battle-sim'
? 'battle-sim-worker'
: 'tournament-worker'
: role === 'auction'
? 'auction-worker'
: role === 'battle-sim'
? 'battle-sim-worker'
: 'tournament-worker'
}`;
const isMissingProcessError = (error: unknown): boolean =>
@@ -300,12 +311,14 @@ export const buildProcessDefinitions = (
): {
api: { name: string; script: string; cwd: string; env: Record<string, string> };
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
auction: { name: string; script: string; cwd: string; env: Record<string, string> };
battleSim: { name: string; script: string; cwd: string; env: Record<string, string> };
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
} => {
const baseEnv = { ...(config.baseEnv ?? {}) };
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const auctionName = buildProcessName(profile.profileName, 'auction');
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
@@ -344,6 +357,15 @@ export const buildProcessDefinitions = (
cwd: daemonCwd,
env: daemonEnv,
},
auction: {
name: auctionName,
script: apiScript,
cwd: apiCwd,
env: {
...apiEnv,
GAME_API_ROLE: 'auction-worker',
},
},
battleSim: {
name: battleSimName,
script: apiScript,
@@ -402,12 +424,14 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
profileNames.map((profileName) => {
const apiName = buildProcessName(profileName, 'api');
const daemonName = buildProcessName(profileName, 'daemon');
const auctionName = buildProcessName(profileName, 'auction');
const battleSimName = buildProcessName(profileName, 'battle-sim');
const tournamentName = buildProcessName(profileName, 'tournament');
return {
profileName,
apiRunning: processNames.get(apiName) ?? false,
daemonRunning: processNames.get(daemonName) ?? false,
auctionRunning: processNames.get(auctionName) ?? false,
battleSimRunning: processNames.get(battleSimName) ?? false,
tournamentRunning: processNames.get(tournamentName) ?? false,
};
@@ -1009,6 +1033,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
try {
await this.processManager.start(definitions.api);
await this.processManager.start(definitions.daemon);
await this.processManager.start(definitions.auction);
await this.processManager.start(definitions.battleSim);
await this.processManager.start(definitions.tournament);
await this.repository.updateLastError(profile.profileName, null);
@@ -1025,11 +1050,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const auctionName = buildProcessName(profile.profileName, 'auction');
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = [];
for (const name of [apiName, daemonName, battleSimName, tournamentName]) {
for (const name of [apiName, daemonName, auctionName, battleSimName, tournamentName]) {
if (!existingNames.has(name)) {
continue;
}
@@ -88,6 +88,7 @@ const createHarness = (
? [
{ name: 'sammo:che:2:game-api', status: 'online' },
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
{ name: 'sammo:che:2:auction-worker', status: 'online' },
{ name: 'sammo:che:2:battle-sim-worker', status: 'online' },
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
]
@@ -148,6 +149,7 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.started.map((definition) => definition.name)).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
@@ -163,12 +165,14 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.stopped).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
@@ -194,6 +198,7 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
@@ -216,12 +221,14 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.stopped).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(harness.deleted).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: false,
auctionRunning: true,
battleSimRunning: true,
tournamentRunning: true,
})
@@ -40,6 +41,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('PREOPEN', {
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
@@ -51,17 +53,31 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: true,
auctionRunning: true,
battleSimRunning: true,
tournamentRunning: true,
})
).toEqual({ shouldStart: false, shouldStop: false });
});
it('restarts a running profile when only the auction worker is missing', () => {
expect(
planProfileReconcile('RUNNING', {
apiRunning: true,
daemonRunning: true,
auctionRunning: false,
battleSimRunning: true,
tournamentRunning: true,
})
).toEqual({ shouldStart: true, shouldStop: false });
});
it('stops processes for non-running profiles', () => {
expect(
planProfileReconcile('STOPPED', {
apiRunning: false,
daemonRunning: true,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
@@ -73,6 +89,7 @@ describe('planProfileReconcile', () => {
planProfileReconcile('RESERVED', {
apiRunning: false,
daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
})
@@ -100,6 +117,11 @@ describe('buildProcessDefinitions', () => {
});
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
expect(definitions.auction).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-api'),
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
env: { GAME_API_ROLE: 'auction-worker' },
});
expect(definitions.battleSim).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-api'),
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
@@ -117,6 +139,7 @@ describe('buildProcessDefinitions', () => {
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
expect(definitions.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
});
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
profileName: 'che:2',
apiRunning: runtimeRunning,
daemonRunning: runtimeRunning,
auctionRunning: runtimeRunning,
battleSimRunning: runtimeRunning,
tournamentRunning: runtimeRunning,
},
@@ -213,7 +214,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
});
test('starts and stops both runtime roles through the operation controls', async ({ page }) => {
test('starts and stops all runtime roles through the operation controls', async ({ page }) => {
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
+3 -1
View File
@@ -70,6 +70,7 @@ type AdminProfile = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
auctionRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
@@ -1353,7 +1354,8 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION:
{{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
{{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
</div>
@@ -19,6 +19,7 @@ type Profile = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
auctionRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
@@ -382,6 +383,12 @@ onBeforeUnmount(() => {
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Auction worker</div>
<div :class="selectedProfile.runtime.auctionRunning ? 'text-emerald-400' : 'text-zinc-500'">
{{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Battle sim worker</div>
<div