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
@@ -33,6 +33,15 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
비교한다.
2026-07-26부터 canonical snapshot은 관찰 장수의 `rank_data`도 비교한다.
ref의 `RankColumn` 37종만 의미 행으로 정규화하며, ref에서 자연 `general`
column인 경험·공헌·숙련을 위해 core가 보유한 7개 mirror row는 비교에서
제외한다. 화계 fixture는 같은 초기 `firenum`에서 성공 명령 뒤 양쪽이
동일하게 1 증가하는지 확인한다. 은퇴 fixture는 37종 전부를 서로 다른
비영 값으로 채운 뒤 양쪽이 전부 0으로 만드는지 확인한다. 이 검증으로
일반 명령 snapshot에서 누락됐던 명장일람 누적치와 은퇴 후 메모리→DB
재저장 경로를 관찰한다.
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
@@ -219,6 +219,11 @@ minimum/maximum clamps for gift, donation, and rice trade; donation against
available and minimum resources; and gift reserve and self-target rejection.
They compare normalized last-turn arguments, RNG, fallback, and semantic state
deltas.
Eleven nation resource cases cover 100-unit rounding and minimum/maximum
clamps for award and seizure; award against available gold, the base-rice
reserve, and self-target rejection; and seizure against target holdings and
self-target rejection. They compare exact target resource deltas, RNG,
fallback, and semantic state deltas.
Requests that omit the required argument object remain unverified because the
reference runner did not terminate within the bounded comparison run.
This is not yet a claim that every command-specific
+65
View File
@@ -47,6 +47,71 @@ export const RANK_DATA_TYPES = [
export type RankDataType = (typeof RANK_DATA_TYPES)[number];
/**
* Legacy `sammo\Enums\RankColumn` values stored in `rank_data`.
*
* `experience`, `dedication`, and `dex1` through `dex5` are natural general
* columns in the reference implementation. core2026 currently keeps mirrored
* rank rows for those values as a compatibility cache, but differential
* snapshots must compare this legacy set rather than treating the mirrors as
* source-of-truth rows.
*/
export const LEGACY_RANK_DATA_TYPES = [
'firenum',
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
'ttw',
'ttd',
'ttl',
'ttg',
'ttp',
'tlw',
'tld',
'tll',
'tlg',
'tlp',
'tsw',
'tsd',
'tsl',
'tsg',
'tsp',
'tiw',
'tid',
'til',
'tig',
'tip',
'betwin',
'betgold',
'betwingold',
'killcrew_person',
'deathcrew_person',
'occupied',
'inherit_earned',
'inherit_spent',
'inherit_earned_dyn',
'inherit_earned_act',
'inherit_spent_dyn',
] as const satisfies readonly RankDataType[];
export type LegacyRankDataType = (typeof LEGACY_RANK_DATA_TYPES)[number];
const PREFIXED_RANK_DATA_TYPES = new Set<RankDataType>([
'warnum',
'killnum',
'deathnum',
'occupied',
'killcrew',
'deathcrew',
'killcrew_person',
'deathcrew_person',
]);
export const rankDataMetaKey = (type: RankDataType): string =>
PREFIXED_RANK_DATA_TYPES.has(type) ? `rank_${type}` : type;
export const HALL_OF_FAME_TYPES = [
'experience',
'dedication',
@@ -53,6 +53,7 @@ export interface TurnCommandEnv {
baseRice: number;
generalMinimumGold?: number;
generalMinimumRice?: number;
npcSeizureMessageProb?: number;
maxResourceActionAmount: number;
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
generalActionModules?: Array<GeneralActionModule>;
@@ -16,7 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
export interface TradeEnvironment {
exchangeFee?: number;
@@ -14,7 +14,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { JosaUtil } from '@sammo-ts/common';
import { JosaUtil, LEGACY_RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common';
export interface RetireArgs {}
@@ -22,7 +22,6 @@ const ACTION_NAME = '은퇴';
const ACTION_KEY = 'che_은퇴';
const REQ_AGE = 60;
const reqGeneralValue = (): Constraint => ({
name: 'reqGeneralValue',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -51,46 +50,8 @@ export class ActionResolver<
}
nextMeta.specAge = 0;
nextMeta.specAge2 = 0;
nextMeta.firenum = 0;
for (const key of [
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
'ttw',
'ttd',
'ttl',
'ttg',
'ttp',
'tlw',
'tld',
'tll',
'tlg',
'tlp',
'tsw',
'tsd',
'tsl',
'tsg',
'tsp',
'tiw',
'tid',
'til',
'tig',
'tip',
'betwin',
'betgold',
'betwingold',
'killcrew_person',
'deathcrew_person',
'occupied',
'inherit_earned',
'inherit_spent',
'inherit_earned_dyn',
'inherit_earned_act',
'inherit_spent_dyn',
]) {
nextMeta[`rank_${key}`] = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
nextMeta[rankDataMetaKey(type)] = 0;
}
const josaYi = JosaUtil.pick(general.name, '이');
@@ -20,7 +20,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
const ACTION_NAME = '증여';
const ACTION_KEY = 'che_증여';
@@ -21,7 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from './resourceAmount.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
const ACTION_NAME = '헌납';
const ACTION_KEY = 'che_헌납';
@@ -15,7 +15,12 @@ import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} from '@sammo-ts/logic/actions/engine.js';
import { createLogEffect, createNationPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
import {
createGeneralPatchEffect,
createLogEffect,
createMessageEffect,
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
@@ -24,13 +29,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo
import { clamp } from 'es-toolkit';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
const ARGS_SCHEMA = z.object({
isGold: z.boolean(),
amount: z.preprocess(
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
z.number().int().positive()
),
amount: z.number(),
destGeneralID: z.number(),
});
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -39,9 +42,40 @@ export interface SeizureResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destGeneral: General<TriggerState>;
messageTime: Date;
}
const ACTION_NAME = '몰수';
const NPC_SEIZURE_MESSAGE_PROB = 0.01;
const NPC_SEIZURE_MESSAGES = [
'몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...',
'사유재산까지 몰수해가면서 이 나라가 잘 될거라 믿습니까? 정말 이해할 수가 없군요...',
'내 돈 내놔라! 내 돈! 몰수가 웬 말이냐!',
'몰수해간 내 자금... 언젠가 몰래 다시 빼내올 것이다...',
'몰수로 인한 사기 저하는 몰수로 얻은 물자보다 더 손해란걸 모른단 말인가!',
] as const;
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string => {
const inclusive = rng as InclusiveRandomGenerator;
const index = inclusive.nextIntInclusive
? inclusive.nextIntInclusive(NPC_SEIZURE_MESSAGES.length - 1)
: rng.nextInt(0, NPC_SEIZURE_MESSAGES.length);
return NPC_SEIZURE_MESSAGES[index]!;
};
const resolveGeneralIcon = (general: General): string => {
const runtimePicture = (general as General & { picture?: unknown }).picture;
const rawPicture = runtimePicture ?? general.meta.picture;
const picture =
(typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number'
? String(rawPicture)
: 'default.jpg';
return `/image/icons/${picture}`;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -56,9 +90,13 @@ export class ActionDefinition<
if (!data) {
return null;
}
const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount);
if (amount === null) {
return null;
}
return {
...data,
amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000),
amount,
};
}
@@ -147,6 +185,30 @@ export class ActionDefinition<
}),
];
if (
destGeneral.npcState >= 2 &&
context.rng.nextBool(this.env.npcSeizureMessageProb ?? NPC_SEIZURE_MESSAGE_PROB)
) {
const target = {
generalId: destGeneral.id,
generalName: destGeneral.name,
nationId: nation.id,
nationName: nation.name,
color: nation.color,
icon: resolveGeneralIcon(destGeneral),
};
effects.push(
createMessageEffect({
msgType: 'public',
src: target,
dest: target,
text: pickLegacyNpcMessage(context.rng),
time: context.messageTime,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
})
);
}
return { effects };
}
}
@@ -164,6 +226,7 @@ export const actionContextBuilder: ActionContextBuilder<SeizureArgs> = (base, op
return {
...base,
destGeneral,
messageTime: base.general.turnTime,
};
};
@@ -144,10 +144,10 @@ export class ActionResolver<
createLogEffect(
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
}
)
);
@@ -199,7 +199,7 @@ export class ActionDefinition<
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
}
if (ctx.destGeneralId === ctx.actorId) {
if (args.destGeneralId === ctx.actorId) {
return [denyWithReason('본인입니다')];
}
+5 -2
View File
@@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => {
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
const lhs = target as any;
const rhs = source as any;
// The cast is type-only: JavaScript still applies its native relational
// coercion rules to the original runtime values, matching the legacy
// constraint evaluator without opting the whole comparison into `any`.
const lhs = target as number;
const rhs = source as number;
switch (op) {
case '<':
return lhs < rhs;
+2 -6
View File
@@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
};
};
const buildTraceUnitSnapshot = (
unit: WarUnit,
defenderCity: City
): WarBattleTraceUnitSnapshot => {
const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => {
const common = {
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
@@ -339,7 +336,6 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
);
const iter = defenderUnits.values();
let defender: WarUnit<TriggerState> | null = null;
const getNextDefender = (
_prevDefender: WarUnit<TriggerState> | null,
@@ -359,7 +355,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
return candidate;
};
defender = getNextDefender(null, true);
let defender = getNextDefender(null, true);
let conquerCity = false;
let logWritten = false;
let traceSeq = 0;
@@ -225,7 +225,7 @@ describe('migrated general commands', () => {
expect(updatedLord.experience).toBe(700);
});
it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => {
it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => {
const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
@@ -246,8 +246,8 @@ describe('migrated general commands', () => {
},
]);
expect(world.getGeneral(actor.id)!.gold).toBe(1000);
expect(world.getGeneral(dest.id)!.gold).toBe(500);
expect(world.getGeneral(actor.id)!.gold).toBe(800);
expect(world.getGeneral(dest.id)!.gold).toBe(700);
});
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
@@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => {
// 6. Retire (Needs age >= 60)
// Manually set age
// Manually set age
const gToRetire = { ...g1_after_resign, age: 65 };
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
const retireDef = retireSpec.createDefinition(systemEnv);
@@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => {
const g1_after_retire = world.getGeneral(1)!;
expect(g1_after_retire.age).toBe(20);
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
expect(g1_after_retire.experience).toBe(142);
expect(g1_after_retire.experience).toBe(Math.round(gToRetire.experience * 0.5));
});
it('should execute employ and sabotage commands', async () => {
@@ -0,0 +1,257 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/';
const secretRoot = process.env.REF_SECRET_ROOT;
const username = process.env.REF_USER_ID ?? 'refuser1';
const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password';
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
const outputRoot = resolve(
process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference')
);
if (!secretRoot) {
throw new Error('REF_SECRET_ROOT is required.');
}
const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim();
const viewports = [
{ name: 'desktop', width: 1000, height: 800 },
{ name: 'mobile', width: 500, height: 800 },
];
const login = async (context) => {
const page = await context.newPage();
await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
const clientPasswordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), {
data: { username, password: clientPasswordHash },
timeout: 60_000,
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
if (allowGeneralCreate) {
const joinUrl = new URL('hwe/v_join.php', targetRoot).toString();
await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 });
if (page.url().includes('v_join.php')) {
const createButton = page.getByRole('button', { name: '장수 생성', exact: true });
try {
await createButton.waitFor({ state: 'visible', timeout: 30_000 });
} catch {
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`);
}
page.on('dialog', async (dialog) => dialog.accept());
await createButton.click();
await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 });
}
}
await page.close();
};
const roundedRect = (rect) => ({
x: Math.round(rect.x * 100) / 100,
y: Math.round(rect.y * 100) / 100,
width: Math.round(rect.width * 100) / 100,
height: Math.round(rect.height * 100) / 100,
});
const measurePage = async (page, type) => {
const diagnostics = [];
const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`);
const onConsole = (message) => {
if (message.type() === 'error') {
diagnostics.push(`console: ${message.text()}`);
}
};
const onResponse = (response) => {
if (response.status() >= 400) {
diagnostics.push(`http ${response.status()}: ${response.url()}`);
}
};
page.on('pageerror', onPageError);
page.on('console', onConsole);
page.on('response', onResponse);
const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php';
await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 });
try {
await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 });
} catch {
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
const scripts = await page.locator('script').evaluateAll((elements) =>
elements.map((element) => ({
src: element.getAttribute('src'),
type: element.getAttribute('type'),
length: element.textContent?.length ?? 0,
}))
);
throw new Error(
`Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}`
);
} finally {
page.off('pageerror', onPageError);
page.off('console', onConsole);
page.off('response', onResponse);
}
await page.locator('button').first().waitFor({ state: 'visible' });
await page.evaluate(() => document.fonts.ready);
const measurement = await page.evaluate(() => {
const rect = (element) => {
if (!(element instanceof HTMLElement)) {
return null;
}
const value = element.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width, height: value.height };
};
const style = (element) => {
if (!(element instanceof HTMLElement)) {
return null;
}
const value = getComputedStyle(element);
return {
color: value.color,
backgroundColor: value.backgroundColor,
backgroundImage: value.backgroundImage,
borderColor: value.borderColor,
borderWidth: value.borderWidth,
borderRadius: value.borderRadius,
fontFamily: value.fontFamily,
fontSize: value.fontSize,
fontWeight: value.fontWeight,
lineHeight: value.lineHeight,
padding: value.padding,
cursor: value.cursor,
};
};
const container = document.querySelector('#container');
const topBar = container?.firstElementChild ?? null;
const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({
text: element.textContent?.trim() ?? '',
rect: rect(element),
style: style(element),
}));
const firstButton = document.querySelector('button');
const firstInput = [...document.querySelectorAll('input')].find(
(element) => element.getBoundingClientRect().width > 0
);
const firstAuctionRow = document.querySelector('.auctionItem');
const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({
className: element.className,
rect: rect(element),
style: style(element),
}));
const firstSection = [...document.querySelectorAll('#container > div')].find((element) =>
['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '')
);
const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({
tag: element.tagName,
className: element.className,
text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '',
rect: rect(element),
}));
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
document: {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
},
body: { rect: rect(document.body), style: style(document.body) },
container: { rect: rect(container), style: style(container) },
topBar: { rect: rect(topBar), style: style(topBar) },
topBarButtons,
firstButton: { rect: rect(firstButton), style: style(firstButton) },
firstInput: { rect: rect(firstInput), style: style(firstInput) },
firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) },
firstAuctionRowChildren,
firstSection: { rect: rect(firstSection), style: style(firstSection) },
directChildren,
};
});
return {
...measurement,
body: {
...measurement.body,
rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null,
},
container: {
...measurement.container,
rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null,
},
topBar: {
...measurement.topBar,
rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null,
},
topBarButtons: measurement.topBarButtons.map((button) => ({
...button,
rect: button.rect ? roundedRect(button.rect) : null,
})),
firstButton: {
...measurement.firstButton,
rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null,
},
firstInput: {
...measurement.firstInput,
rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null,
},
firstAuctionRow: {
...measurement.firstAuctionRow,
rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null,
},
firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({
...child,
rect: child.rect ? roundedRect(child.rect) : null,
})),
firstSection: {
...measurement.firstSection,
rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null,
},
directChildren: measurement.directChildren.map((child) => ({
...child,
rect: child.rect ? roundedRect(child.rect) : null,
})),
};
};
await mkdir(outputRoot, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const results = {};
try {
for (const viewport of viewports) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
colorScheme: 'dark',
});
try {
await login(context);
const page = await context.newPage();
for (const type of ['resource', 'unique']) {
results[`${viewport.name}-${type}`] = await measurePage(page, type);
await page.screenshot({
path: resolve(outputRoot, `${viewport.name}-${type}.png`),
fullPage: true,
});
}
} finally {
await context.close();
}
}
} finally {
await browser.close();
}
const outputPath = resolve(outputRoot, 'computed-dom.json');
await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify({ outputPath, views: Object.keys(results) }));
@@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot {
engine: CanonicalEngine;
world: Record<string, unknown>;
generals: Array<Record<string, unknown>>;
rankData: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>;
@@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
meta: unknown;
};
generals: Array<Record<string, unknown>>;
rankData: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>;
@@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
logs: Array<Record<string, unknown>>;
}): CanonicalTurnSnapshot => {
const worldMeta = asRecord(rows.world.meta);
const legacyRankTypes = new Set<string>(LEGACY_RANK_DATA_TYPES);
const generals = rows.generals.map((row) => {
const meta = asRecord(row.meta);
return {
@@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: {
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
},
generals,
rankData: rows.rankData
.filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type))
.map((row) => ({
generalId: row.generalId,
nationId: row.nationId,
type: row.type,
value: row.value,
})),
cities,
nations,
diplomacy,
@@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: {
},
};
};
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
@@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions {
type FlatSnapshot = Map<string, unknown>;
const entityKey = (value: Record<string, unknown>, index: number): string => {
if (
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
typeof value.type === 'string'
) {
return `${String(value.generalId)}:${value.type}`;
}
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
const candidate = value[key];
if (typeof candidate === 'number' || typeof candidate === 'string') {
@@ -18,6 +18,10 @@ import type {
TurnWorldSnapshot,
TurnWorldState,
} from '@sammo-ts/game-engine/turn/types.js';
import {
applyPersistedRankRowsToMeta,
buildLegacyComparableRankRows,
} from '@sammo-ts/game-engine/turn/rankData.js';
import {
canonicalizeTurnCommandArgs,
@@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest {
};
isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>;
rankData?: Array<{ generalId: number; type: string; value: number }>;
nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>;
troops?: Array<Record<string, unknown>>;
@@ -295,6 +300,17 @@ const buildWorldInput = (
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
for (const general of generals) {
applyPersistedRankRowsToMeta(
general.meta,
referenceBefore.rankData
.filter((row) => readNumber(row, 'generalId') === general.id)
.map((row) => ({
type: readString(row, 'type', ''),
value: readNumber(row, 'value'),
}))
);
}
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
? referenceBefore.world.generalCooldowns
: [];
@@ -350,6 +366,7 @@ const buildWorldInput = (
baseRice: 2_000,
generalMinimumGold: 0,
generalMinimumRice: 500,
npcSeizureMessageProb: 0.01,
maxResourceActionAmount: 10_000,
maxTechLevel: 12,
maxLevel: 255,
@@ -523,6 +540,11 @@ const projectWorld = (
}),
},
generals,
rankData: world
.listGenerals()
.filter((general) => selector.generalIds.has(general.id))
.flatMap(buildLegacyComparableRankRows)
.map((row) => ({ ...row })),
cities: world
.listCities()
.filter((city) => selector.cityIds.has(city.id))
@@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async (
try {
const db = connector.prisma;
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
db.general.findMany({
where: { id: { in: selector.generalIds } },
orderBy: { id: 'asc' },
}),
db.rankData.findMany({
where: { generalId: { in: selector.generalIds } },
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
}),
db.city.findMany({
where: { id: { in: selector.cityIds } },
orderBy: { id: 'asc' },
@@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async (
return projectCoreDatabaseSnapshot({
world,
generals,
rankData,
cities,
nations,
diplomacy,
@@ -451,6 +451,10 @@ describe('auction integration flow', () => {
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
'자신이 연 경매에 입찰할 수 없습니다.'
);
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: bidder1.accessToken,
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
@@ -72,6 +73,7 @@ interface FixturePatches {
troops?: Array<Record<string, unknown>>;
diplomacy?: Record<string, Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[];
rankData?: Array<{ generalId: number; type: string; value: number }>;
}
const buildRequest = (
@@ -166,6 +168,7 @@ const buildRequest = (
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
],
...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}),
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
...(fixturePatches.randomFoundingCandidateCityIds
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
@@ -382,6 +385,67 @@ integration('general command success matrix', () => {
);
});
integration('명장일람 rank_data command parity', () => {
it('화계 increments firenum from the same seeded value as legacy', async () => {
const request = buildRequest(
'che_화계',
{ destCityID: 70 },
{ intelligence: 100 },
{
generals: { 2: { intelligence: 10 } },
rankData: [{ generalId: 1, type: 'firenum', value: 17 }],
}
);
request.setup!.world!.hiddenSeed = 'general-injury-4';
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.after.rankData).toContainEqual(
expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 })
);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => {
const request = buildRequest(
'che_은퇴',
undefined,
{ age: 65, lastTurn: { command: '은퇴', term: 1 } },
{
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
generalId: 1,
type,
value: index + 1,
})),
}
);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength(
LEGACY_RANK_DATA_TYPES.length
);
expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe(
true
);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
type GeneralFailureCase = {
action: string;
args?: Record<string, unknown>;
@@ -11,6 +11,9 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...';
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
@@ -469,3 +472,204 @@ integration('nation command success matrix', () => {
120_000
);
});
const nationResourceAmountCases: Array<{
name: string;
action: 'che_포상' | 'che_몰수';
args: Record<string, unknown>;
expectedAmount: number;
}> = [
{
name: 'award rounds a half unit up',
action: 'che_포상',
args: { isGold: true, amount: 150, destGeneralID: 3 },
expectedAmount: 200,
},
{
name: 'award clamps below the minimum',
action: 'che_포상',
args: { isGold: true, amount: 1, destGeneralID: 3 },
expectedAmount: 100,
},
{
name: 'award clamps above the maximum',
action: 'che_포상',
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
expectedAmount: 10_000,
},
{
name: 'seizure rounds a half unit up',
action: 'che_몰수',
args: { isGold: true, amount: 150, destGeneralID: 3 },
expectedAmount: 200,
},
{
name: 'seizure clamps below the minimum',
action: 'che_몰수',
args: { isGold: true, amount: 1, destGeneralID: 3 },
expectedAmount: 100,
},
{
name: 'seizure clamps above the maximum',
action: 'che_몰수',
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
expectedAmount: 10_000,
},
];
integration('nation command resource amount normalization matrix', () => {
it.each(nationResourceAmountCases)(
'$name matches legacy rounding and clamp semantics',
async ({ action, args, expectedAmount }) => {
const request = buildRequest(action, args);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3);
const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3);
const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3);
const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3);
const referenceAmount =
action === 'che_포상'
? readGold(referenceTargetAfter) - readGold(referenceTargetBefore)
: readGold(referenceTargetBefore) - readGold(referenceTargetAfter);
const coreAmount =
action === 'che_포상'
? readGold(coreTargetAfter) - readGold(coreTargetBefore)
: readGold(coreTargetBefore) - readGold(coreTargetAfter);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(referenceAmount).toBe(expectedAmount);
expect(coreAmount).toBe(expectedAmount);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
const nationResourceBoundaryCases: Array<{
name: string;
action: 'che_포상' | 'che_몰수';
args: Record<string, unknown>;
fixturePatches?: FixturePatches;
completed: boolean;
}> = [
{
name: 'award is limited to the available nation gold',
action: 'che_포상',
args: { isGold: true, amount: 10_000, destGeneralID: 3 },
fixturePatches: { nations: { 1: { gold: 5_000 } } },
completed: true,
},
{
name: 'award keeps the legacy base rice reserve',
action: 'che_포상',
args: { isGold: false, amount: 10_000, destGeneralID: 3 },
fixturePatches: { nations: { 1: { rice: 2_100 } } },
completed: true,
},
{
name: 'award rejects the actor as its target',
action: 'che_포상',
args: { isGold: true, amount: 100, destGeneralID: 1 },
completed: false,
},
{
name: 'seizure is limited to the target general gold',
action: 'che_몰수',
args: { isGold: true, amount: 1_000, destGeneralID: 3 },
fixturePatches: { generals: { 3: { gold: 50 } } },
completed: true,
},
{
name: 'seizure rejects the actor as its target',
action: 'che_몰수',
args: { isGold: true, amount: 100, destGeneralID: 1 },
completed: false,
},
];
integration('nation command resource balance and target boundaries', () => {
it.each(nationResourceBoundaryCases)(
'$name matches legacy completion, RNG, and state delta',
async ({ action, args, fixturePatches, completed }) => {
const request = buildRequest(action, args, fixturePatches);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: completed ? action : '휴식',
usedFallback: !completed,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
integration('nation seizure NPC public message parity', () => {
it('matches the legacy fixed-seed RNG and public message side effect', async () => {
const request = buildRequest(
'che_몰수',
{ isGold: true, amount: 100, destGeneralID: 3 },
{
world: { hiddenSeed: 'seizure-message-37' },
generals: { 3: { name: '몰수NPC', npcState: 2 } },
}
);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(reference.rng).toHaveLength(2);
expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']);
expect(core.rng).toEqual(reference.rng);
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
expect(referenceMessages).toHaveLength(1);
expect(core.after.messages).toHaveLength(1);
expect(referenceMessages[0]).toMatchObject({
mailbox: 9999,
type: 'public',
sourceId: 3,
destinationId: 9999,
payload: {
src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
text: NPC_SEIZURE_MESSAGE_TEXT,
},
});
expect(core.after.messages[0]).toMatchObject({
payload: {
msgType: 'public',
src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
text: NPC_SEIZURE_MESSAGE_TEXT,
},
});
}, 120_000);
});
@@ -15,6 +15,7 @@ const snapshot = (
engine,
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
rankData: [],
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
nations: [{ id: 1, gold: 0, rice: 0 }],
diplomacy: [],
@@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => {
expect(compareTurnSnapshots(reference, core)).toEqual([]);
});
it('compares rank rows by general and type instead of array position', () => {
const reference = snapshot('ref', {
rankData: [
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
],
});
const core = snapshot('core2026', {
rankData: [
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
],
});
expect(compareTurnSnapshots(reference, core)).toEqual([]);
});
it('normalizes legacy ID argument spelling at the trace boundary', () => {
expect(
canonicalizeTurnCommandArgs({