Author SHA1 Message Date
Hide_D 957a599608 외교 제의 만료와 처리 완료를 삭제 표시에서 분리 2026-09-10 06:03:47 +00:00
Hide_D 73e1743a3d 복구 2배속의 게임·실제 시간 표시 설정과 시계 빠른 전환 추가 2026-09-10 04:22:18 +00:00
Hide_D 3a5d4e7833 메인 턴 당기기와 미루기를 1턴 기본 분할 버튼으로 변경 2026-09-10 02:50:19 +00:00
Hide_D 3f4e5095b5 외교 조회 권한자의 문서와 시스템 메시지 도착 알림 복원 2026-09-10 02:38:23 +00:00
Hide_D a00f46dfc8 관리자 서버 진단에 lease 상태와 영속 장애 이력 추가 2026-09-09 23:36:16 +00:00
Hide_D 9c73085353 현재 복구 migration 기준으로 manifest 검증 기대값 갱신 2026-09-09 23:05:39 +00:00
Hide_D 604b951eff 대기 후 복구 migration과 릴리스 manifest 기준 일치 2026-09-09 23:05:21 +00:00
Hide_D 09f4e60df4 lease 상실 데몬을 종료하고 관리자에게 즉시 오류 기록 2026-09-09 23:04:02 +00:00
Hide_D d191960ea1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-09 22:54:38 +00:00
Hide_D c1fd79da19 메인 지도 연월 툴팁에 유니크 보유 한도 표시 2026-09-08 00:49:39 +00:00
Hide_D d8c9a63915 NPC와 알림 메시지의 장수 아이콘 경로를 공통 처리 2026-09-07 15:28:24 +00:00
Hide_D c39527bdd7 중단 후 대기와 2배속 복구 경계 및 전체 공지 적용 2026-09-07 14:37:55 +00:00
Hide_D d57fd3e553 설문 제목과 알림을 닫을 수 있는 새 창으로 열기 2026-09-07 12:42:27 +00:00
Hide_D bdb4e22028 장비 구매 목록을 시나리오 JSON 순서로 표시 2026-09-07 12:30:47 +00:00
Hide_D b2be86a9a2 fix: 신규 오픈 시각과 게임 시계 원점을 정확히 보존 2026-09-07 01:48:23 +00:00
Hide_D 69f461612d fix: 등용장을 발송 국가 멸망 시 함께 만료 2026-09-06 23:55:20 +00:00
Hide_D 9d5447dd3f fix: 토너먼트 관리자 요청의 Redis와 DB 잠금 순서 통일 2026-09-06 23:32:24 +00:00
Hide_D 27049dae1f fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정 2026-09-06 15:39:30 +00:00
Hide_D 09dfe96ff1 fix: 설문조사 창 닫기 버튼이 팝업을 닫도록 수정 2026-09-06 15:11:05 +00:00
Hide_D b14d8d52f6 feat: 정지·종료 기수의 장수 참여를 허용하고 확정 기록 보호 2026-09-06 13:39:30 +00:00
Hide_D 4d64978cb6 fix: 후계자 없는 군주 사망 시 국가 멸망을 함께 처리 2026-09-06 12:09:09 +00:00
118 changed files with 4720 additions and 580 deletions
+30 -2
View File
@@ -1,5 +1,6 @@
import type { GameApiContext, WorldStateRow } from '../context.js'; import type { GameApiContext, WorldStateRow } from '../context.js';
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
export type MapCityCompact = [number, number, number, number, number, number]; export type MapCityCompact = [number, number, number, number, number, number];
@@ -16,6 +17,7 @@ export type BaseMapResult = {
initialLevel: number; initialLevel: number;
increaseYears: number; increaseYears: number;
}; };
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
cityList: MapCityCompact[]; cityList: MapCityCompact[];
nationList: MapNationCompact[]; nationList: MapNationCompact[];
}; };
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
const BASE_MAP_TTL_SECONDS = 30; const BASE_MAP_TTL_SECONDS = 30;
const PUBLIC_MAP_TTL_SECONDS = 600; const PUBLIC_MAP_TTL_SECONDS = 600;
const resolveStartYear = (worldState: WorldStateRow): number => { const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta); const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = scenarioMeta.startYear; const startYear = scenarioMeta.startYear;
@@ -87,6 +89,28 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe
}; };
}; };
// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다.
export const resolveMapUniqueItemLimit = (
worldState: Pick<WorldStateRow, 'config' | 'meta' | 'currentYear'>
): BaseMapResult['uniqueItemLimit'] => {
const config = resolveUniqueConfig(asRecord(asRecord(worldState.config).const));
const startYear = resolveStartYear(worldState);
const relativeYear = worldState.currentYear - startYear;
const slotCount = Object.keys(config.allItems).length;
let count = Math.min(1, slotCount);
for (const [targetYear, targetCount] of config.maxUniqueItemLimit) {
const nextCount = Math.min(targetCount, slotCount);
if (relativeYear < targetYear) {
if (nextCount !== count) {
return { count, until: { year: startYear + targetYear - 1, month: 12 } };
}
} else {
count = nextCount;
}
}
return { count, until: null };
};
const normalizeNumberRecord = (value: unknown): Record<number, number> => { const normalizeNumberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) { if (!isRecord(value)) {
return {}; return {};
@@ -154,7 +178,10 @@ const loadBaseMap = async (
} }
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) as BaseMapResult; const parsed = JSON.parse(cached) as BaseMapResult;
if (parsed.uniqueItemLimit) {
return parsed;
}
} catch { } catch {
// Ignore cache parse errors. // Ignore cache parse errors.
} }
@@ -207,6 +234,7 @@ const loadBaseMap = async (
year: worldState.currentYear, year: worldState.currentYear,
month: worldState.currentMonth, month: worldState.currentMonth,
techLevelLimit: resolveTechLevelLimit(worldState), techLevelLimit: resolveTechLevelLimit(worldState),
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
cityList, cityList,
nationList, nationList,
}; };
+13 -6
View File
@@ -57,10 +57,17 @@ const formatMessageTime = (value: Date): string => {
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => { const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
const payload = parsePayload(row.message); const payload = parsePayload(row.message);
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null; const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
const actionUnavailable = // 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
actionStatus !== null && const actionState =
(actionStatus !== 'PENDING' || actionStatus === null
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick)); ? null
: actionStatus === 'PENDING'
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
? 'expired'
: 'pending'
: actionStatus === 'RESOLVED'
? 'resolved'
: 'unavailable';
return { return {
id: row.id, id: row.id,
msgType: row.type, msgType: row.type,
@@ -68,8 +75,8 @@ const toMessageView = (row: MessageRow, currentGameTick: bigint | null): Message
dest: row.type === 'public' ? null : payload.dest, dest: row.type === 'public' ? null : payload.dest,
text: payload.text, text: payload.text,
option: option:
actionUnavailable && payload.option && typeof payload.option === 'object' actionState !== null && payload.option && typeof payload.option === 'object'
? { ...payload.option, used: true, invalid: true } ? { ...payload.option, actionState, used: actionState !== 'pending' }
: (payload.option ?? null), : (payload.option ?? null),
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)), time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
}; };
+1 -4
View File
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
color: '#000000', color: '#000000',
}; };
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
export const resolveNationInfo = async ( export const resolveNationInfo = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number nationId: number
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => { export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
const nation = await resolveNationInfo(db, general.nationId); const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return { return {
generalId: general.id, generalId: general.id,
generalName: general.name, generalName: general.name,
nationId: general.nationId, nationId: general.nationId,
nationName: nation.name, nationName: nation.name,
color: nation.color, color: nation.color,
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`, icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
}; };
}; };
+9 -7
View File
@@ -4,11 +4,7 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js'; import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import { import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
} from '@sammo-ts/infra';
import { import {
isWarTraitKey, isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS, JOIN_PERSONALITY_TRAIT_KEYS,
@@ -339,6 +335,11 @@ export const joinRouter = router({
}; };
}); });
const serverId = asRecord(worldState.meta).serverId;
const history =
typeof serverId === 'string'
? await ctx.db.gameHistory.findUnique({ where: { serverId }, select: { status: true } })
: null;
const inheritConst = resolveInheritConstants(worldState); const inheritConst = resolveInheritConstants(worldState);
const inheritTotalPoint = ctx.auth?.user.id const inheritTotalPoint = ctx.auth?.user.id
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous') ? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
@@ -376,6 +377,7 @@ export const joinRouter = router({
npcGeneralCount, npcGeneralCount,
}, },
inherit: { inherit: {
enabled: history?.status !== 'COMPLETED',
totalPoint: inheritTotalPoint, totalPoint: inheritTotalPoint,
costs: { costs: {
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint, inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
@@ -595,10 +597,10 @@ export const joinRouter = router({
message: 'World state is not initialized.', message: 'World state is not initialized.',
}); });
} }
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) { if (!['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'COMPLETED'].includes(clockRows[0].clockPhase)) {
throw new TRPCError({ throw new TRPCError({
code: 'PRECONDITION_FAILED', code: 'PRECONDITION_FAILED',
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.', message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
}); });
} }
const worldState = await transaction.worldState.findFirst(); const worldState = await transaction.worldState.findFirst();
+21 -9
View File
@@ -8,7 +8,7 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js'; import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js'; import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime } from '../../services/gameClock.js';
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js'; import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
@@ -38,19 +38,29 @@ const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured); return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
}; };
const adminProcedure = authedProcedure.use(({ ctx, next }) => { const adminProcedure = engineAuthedProcedure
const roles = ctx.auth?.user.roles ?? []; .use(({ ctx, next }) => {
if (!hasAdminRole(roles, ctx.profile.name)) { const roles = ctx.auth?.user.roles ?? [];
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' }); if (!hasAdminRole(roles, ctx.profile.name)) {
} throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
return next(); }
}); return next();
})
.use(async ({ ctx, type, next }) => {
if (type !== 'mutation') return next({ ctx: { tournamentMutationLockHeld: false } });
// 참가·베팅은 Redis lock 안에서 ENGINE의 DB commit을 기다린다.
// 관리자도 Redis를 먼저 잡아 DB clock fence → Redis 역순 대기를 막는다.
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(() => next({ ctx: { tournamentMutationLockHeld: true } }));
})
.concat(procedure);
const withTournamentClockMutation = async <T>( const withTournamentClockMutation = async <T>(
ctx: { ctx: {
db: Parameters<typeof loadCurrentGameTime>[0]; db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureActiveRedisClockFence>[0]; redis: Parameters<typeof ensureActiveRedisClockFence>[0];
profile: { name: string }; profile: { name: string };
tournamentMutationLockHeld?: boolean;
}, },
store: TournamentStore, store: TournamentStore,
operation: () => Promise<T> operation: () => Promise<T>
@@ -69,7 +79,9 @@ const withTournamentClockMutation = async <T>(
deadlineGeneration: fence.generation, deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick, dateToTick: gameTime.dateToTick,
}; };
return store.withClockContext(clockContext, () => store.withMutationLock(operation)); return store.withClockContext(clockContext, () =>
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
);
}; };
const withTournamentBetClockMutation = async <T>( const withTournamentBetClockMutation = async <T>(
+4
View File
@@ -17,6 +17,7 @@ import {
assertReservedTurnActionAvailable, assertReservedTurnActionAvailable,
assertReservedTurnArgsPassLegacyBasicValidation, assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions, buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
parseReservedTurnArgs, parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS, TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions, type TurnCommandInputOptions,
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
traits, traits,
moduleBundle, moduleBundle,
map, map,
itemOrder,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -330,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
loadBattleSimTraitOptions(), loadBattleSimTraitOptions(),
moduleBundlePromise, moduleBundlePromise,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)), loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
loadEquipmentTradeItemOrder(worldState.scenarioCode),
]); ]);
const nationById = new Map(nations.map((entry) => [entry.id, entry])); const nationById = new Map(nations.map((entry) => [entry.id, entry]));
@@ -383,6 +386,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])), troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
}); });
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
itemOrder,
configConst: asRecord(asRecord(worldState.config).const), configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules, itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0, currentSecurity: city?.security ?? 0,
+5 -7
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -102,10 +102,7 @@ export const hasPollEnded = (
time: CurrentGameTime time: CurrentGameTime
): boolean => ): boolean =>
Boolean(poll.closed_at) || Boolean(poll.closed_at) ||
Boolean( Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => { const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null; if (!date) return null;
@@ -290,7 +287,9 @@ export const voteRouter = router({
userCnt, userCnt,
}; };
}), }),
submitVote: authedProcedure // 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
submitVote: engineAuthedProcedure
.input( .input(
z.object({ z.object({
voteId: z.number().int().positive(), voteId: z.number().int().positive(),
@@ -366,7 +365,6 @@ export const voteRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
} }
ctx.changeJournal?.mark('front.general', general.id);
return { ok: true, wonLottery: rewardResult.awardedUnique }; return { ok: true, wonLottery: rewardResult.awardedUnique };
}), }),
addComment: authedProcedure addComment: authedProcedure
+27 -2
View File
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod'; import { z } from 'zod';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -120,8 +121,29 @@ const plainLegacyInfo = (value: string): string =>
.replace(/\s+/gu, ' ') .replace(/\s+/gu, ' ')
.trim(); .trim();
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
return undefined;
}
try {
const scenario = await loadScenarioDefinitionById(Number(normalized));
const configConst = scenario.config.const;
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
} catch (error) {
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
if (isRecord(error) && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
};
export const buildEquipmentTradeItemOptions = (options: { export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>; configConst: Record<string, unknown>;
itemOrder?: readonly string[];
itemModules: readonly EquipmentTradeItemModule[]; itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number; currentSecurity: number;
generalGold: number; generalGold: number;
@@ -148,8 +170,11 @@ export const buildEquipmentTradeItemOptions = (options: {
]; ];
} }
for (const item of options.itemModules) { // 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
if (!item.buyable || !purchasableItemKeys.has(item.key)) { const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
for (const key of orderedKeys) {
const item = catalog.get(key);
if (!item?.buyable || !purchasableItemKeys.has(key)) {
continue; continue;
} }
const cost = item.cost ?? 0; const cost = item.cost ?? 0;
+71 -2
View File
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
import { import {
assertReservedTurnArgsPassLegacyBasicValidation, assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions, buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
buildTurnCommandInputFields, buildTurnCommandInputFields,
parseReservedTurnArgs, parseReservedTurnArgs,
sanitizeReservedTurnArgs, sanitizeReservedTurnArgs,
@@ -222,6 +223,58 @@ describe('turn command argument input', () => {
); );
}); });
it('preserves scenario order while filtering unavailable catalog entries in every slot', () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: { [slot]: { z_last_code: 0, missing: 0, unique: 1, blocked: 0, a_first_code: -1 } },
},
itemModules: [
{ ...buildShopItem('a_first_code', '첫 코드'), slot },
{ ...buildShopItem('z_last_code', '마지막 코드'), slot },
{ ...buildShopItem('unique', '유니크'), slot },
{ ...buildShopItem('blocked', '비매품'), slot, buyable: false },
],
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items[slot].map((item) => item.value)).toEqual(['None', 'z_last_code', 'a_first_code']);
}
});
it('restores original scenario order after DB key reordering without restoring removed purchase permissions', async () => {
const scenario = await loadScenarioDefinitionById(2701);
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
const originalKeys = Object.keys(pool.item!);
const buyableKeys = originalKeys.filter((key) => pool.item![key]! <= 0);
const removedKey = buyableKeys[1]!;
const reorderedPool = Object.fromEntries(Object.entries(pool.item!).reverse());
reorderedPool[removedKey] = 1;
reorderedPool.runtime_added = 0;
const items = buildEquipmentTradeItemOptions({
configConst: { allItems: { item: reorderedPool } },
itemOrder: await loadEquipmentTradeItemOrder('scenario_2701.json'),
itemModules: [...buyableKeys]
.reverse()
.concat('runtime_added')
.map((key) => buildShopItem(key, key)),
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items.item.map((item) => item.value)).toEqual([
'None',
...buyableKeys.filter((key) => key !== removedKey),
'runtime_added',
]);
expect((await loadEquipmentTradeItemOrder('1'))?.filter((key) => key.startsWith('che_치료'))).toEqual([
'che_치료_환약',
]);
expect(await loadEquipmentTradeItemOrder('unknown')).toBeUndefined();
expect(await loadEquipmentTradeItemOrder('999999')).toBeUndefined();
});
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => { it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 }; const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
@@ -245,12 +298,28 @@ describe('turn command argument input', () => {
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null }, ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: {}, configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')], itemModules: [
buildShopItem('event_전투특기_격노', '격노의 비급'),
buildShopItem('che_계략_향낭', '향낭'),
buildShopItem('che_계략_이추', '이추'),
buildShopItem('che_훈련_청주', '청주'),
buildShopItem('che_사기_탁주', '탁주'),
buildShopItem('che_저격_수극', '수극'),
buildShopItem('che_치료_환약', '환약'),
],
currentSecurity: 5000, currentSecurity: 5000,
generalGold: 1000, generalGold: 1000,
}); });
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']); expect(items.item.map((item) => item.value)).toEqual([
'None',
'che_치료_환약',
'che_저격_수극',
'che_사기_탁주',
'che_훈련_청주',
'che_계략_이추',
'che_계략_향낭',
]);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명'); expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
}); });
@@ -229,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => {
create: { userId, key, value }, create: { userId, key, value },
}); });
} }
// 이 fixture는 특기 없는 일반 생성/reload를 검증한다. 1% 천재 추첨은 별도 계약이다.
const seededWorld = await db.worldState.findFirstOrThrow();
await db.worldState.update({
where: { id: seededWorld.id },
data: {
meta: { ...(seededWorld.meta as Record<string, GamePrisma.InputJsonValue>), genius: 0 },
},
});
await startRuntime('create-general-integration-daemon'); await startRuntime('create-general-integration-daemon');
}, 60_000); }, 60_000);
@@ -559,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => {
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })]) expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
); );
}, 10_000); }, 10_000);
it.each([
{ phase: 'SUSPENDED', united: 0, finalized: false },
{ phase: 'SUSPENDED', united: 2, finalized: true },
{ phase: 'COMPLETED', united: 3, finalized: true },
])(
'creates a visitor in $phase/$united without reopening season records',
async ({ phase, united, finalized }) => {
await stopRuntime('prepare frozen participation');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `visitor-${phase}-${united}`;
const auth = buildAuth(visitorId, '방문자', 7000 + united);
await db.inheritancePoint.create({ data: { userId: visitorId, key: 'previous', value: 5000 } });
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: {
...(original.meta as Record<string, GamePrisma.InputJsonValue>),
isUnited: united,
isunited: united,
},
},
});
await db.gameHistory.update({
where: { serverId: profile },
data: { status: finalized ? 'COMPLETED' : 'OPEN' },
});
const readRecords = async () => ({
points: await db.inheritancePoint.findMany({ where: { userId: visitorId }, orderBy: { id: 'asc' } }),
logs: await db.inheritanceLog.count({ where: { userId: visitorId } }),
baseline: await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }),
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
oldGenerals: await db.oldGeneral.count({ where: { serverId: profile } }),
});
const before = await readRecords();
try {
await startRuntime(`visitor-${phase}-${united}`);
const caller = appRouter.createCaller(buildContext(`visitor-${phase}-${united}`, auth));
expect((await caller.join.getConfig()).inherit.enabled).toBe(!finalized);
const input = {
name: `방문${united}`,
pic: false,
leadership: 55,
strength: 55,
intel: 55,
character: 'che_안전' as const,
};
if (finalized) {
await expect(
appRouter
.createCaller(buildContext(`visitor-paid-${united}`, auth))
.join.createGeneral({ ...input, inheritTurntimeZone: 7 })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(await readRecords()).toEqual(before);
}
const result = await caller.join.createGeneral(input);
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
npcState: 0,
});
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
if (finalized) expect(await readRecords()).toEqual(before);
else
expect(
await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } })
).toBe(1);
await stopRuntime('verify visitor persisted');
await startRuntime(`visitor-reload-${phase}-${united}`);
expect(runtime?.world.getGeneralById(result.generalId)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore participation fixture');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('participation-fixture-restored');
}
},
30_000
);
}); });
+41
View File
@@ -53,6 +53,47 @@ describe('current game time projection', () => {
}); });
}); });
it('exposes waiting, 2x and normal speed boundaries from a reloaded recovery', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-09-07T00:00:00Z'),
clockTick: 6000000n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-07T00:35:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 6000000n,
clockRecoveryEndTick: 36000000n,
clockRecoveryStartWallAt: new Date('2026-09-07T00:35:00Z'),
})),
},
$queryRaw: vi.fn(async () => [{ ready: true }]),
} as unknown as DatabaseClient;
for (const now of ['00:24:00', '00:34:59.999']) {
expect(await loadCurrentGameTime(db, new Date(`2026-09-07T${now}Z`))).toMatchObject({
tick: 6000000,
running: false,
startsAt: new Date('2026-09-07T00:35:00Z'),
recovery: { startsAt: '2026-09-07T00:35:00.000Z', endsAt: '2026-09-07T01:00:00.000Z' },
});
}
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:35:00.001Z'))).toMatchObject({
tick: 6000020,
running: true,
startsAt: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:59:59.999Z'))).toMatchObject({ tick: 35999980 });
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00Z'))).toMatchObject({
tick: 36000000,
running: true,
recovery: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00.001Z'))).toMatchObject({ tick: 36000010 });
});
it('holds an invader restart until its future turn boundary', async () => { it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING'); const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z')); const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
@@ -0,0 +1,82 @@
import { afterAll, describe, expect, it, vi } from 'vitest';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox } from '../src/messages/store.js';
vi.mock('../src/services/gameClock.js', () => ({
loadCurrentGameTime: vi.fn(async () => ({ tick: 100 })),
}));
// SQL is mocked; this client is never connected. Keep the real delegate types.
const connector = createGamePostgresConnector({ url: 'postgresql://localhost:1/message_lifecycle_unit' });
const db = connector.prisma;
const queryRaw = vi.spyOn(db, '$queryRaw');
afterAll(() => connector.disconnect());
const target = { generalId: 1, generalName: '장수', nationId: 1, nationName: '국가', color: '#000', icon: '' };
for (const older of [false, true]) {
describe(older ? 'older message lifecycle' : 'recent message lifecycle', () => {
it.each([
['PENDING', 101n, 'pending', false],
['PENDING', 100n, 'expired', true],
['PENDING', 99n, 'expired', true],
['PENDING', null, 'pending', false],
['RESOLVED', 101n, 'resolved', true],
['RESOLVED', 99n, 'resolved', true],
['CANCELLED', 101n, 'unavailable', true],
['UNKNOWN', 101n, 'unavailable', true],
[null, null, undefined, undefined],
])('preserves body for status %s and deadline %s', async (status, deadline, state, used) => {
const row = {
id: 10,
mailbox: 1,
type: 'diplomacy',
src: 2,
dest: 1,
time: new Date('0200-01-01T00:00:00Z'),
created_at_wall: new Date('2026-09-10T00:00:00Z'),
action_status: status,
expires_game_tick: deadline,
message: {
src: target,
dest: target,
text: '210년 1월까지 불가침 제의',
option: { action: 'noAggression' },
},
};
queryRaw.mockResolvedValue([row]);
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
const result = older
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
expect(result[0]?.text).toBe(row.message.text);
expect(result[0]?.option?.invalid).toBeUndefined();
expect(result[0]?.option?.actionState).toBe(state);
expect(result[0]?.option?.used).toBe(used);
});
it('keeps actual deletion authoritative even when an action remains', async () => {
queryRaw.mockResolvedValue([
{
id: 10,
type: 'diplomacy',
time: new Date(),
created_at_wall: new Date(),
action_status: 'RESOLVED',
expires_game_tick: 99n,
message: {
src: target,
dest: target,
text: '삭제된 메시지입니다.',
option: { action: 'noAggression', invalid: true },
},
},
]);
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
const result = older
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
expect(result[0]).toMatchObject({ text: '삭제된 메시지입니다.', option: { invalid: true, used: true } });
});
});
}
+59 -54
View File
@@ -128,64 +128,69 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(false); expect(result.canRespondDiplomacy).toBe(false);
}); });
it('redacts recent and old diplomacy content below secret permission 3', async () => { it.each([null, 'RESOLVED'])(
const diplomacyRow = { 'redacts recent and old diplomacy content below secret permission 3 (%s)',
id: 19, async (actionStatus) => {
mailbox: 9001, const diplomacyRow = {
type: 'diplomacy', id: 19,
src: 9002, action_status: actionStatus,
dest: 9001, expires_game_tick: null,
time: new Date(), mailbox: 9001,
valid_until: new Date('9999-12-31T00:00:00Z'), type: 'diplomacy',
message: { src: 9002,
src: { dest: 9001,
generalId: 8, time: new Date(),
generalName: '외교관', valid_until: new Date('9999-12-31T00:00:00Z'),
nationId: 2, message: {
nationName: '촉', src: {
color: '#000000', generalId: 8,
icon: '', generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
}, },
dest: { };
generalId: 0, const queryRaw = vi.fn(async () => [diplomacyRow]);
generalName: '', const { caller } = buildContext({
nationId: 1, $queryRaw: queryRaw,
nationName: '위', nation: {
color: '#ffffff', findMany: vi.fn(async () => []),
icon: '', findUnique: vi.fn(async () => ({ meta: {} })),
}, },
text: '보이면 안 되는 외교 본문', });
option: { action: 'noAggression' },
},
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const recent = await caller.messages.getRecent({ generalId: general.id }); const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({ const old = await caller.messages.getOld({
generalId: general.id, generalId: general.id,
type: 'diplomacy', type: 'diplomacy',
to: 20, to: 20,
}); });
expect(recent.permission).toBe(2); expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({ expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression', permissionRedacted: true }, option: { action: 'noAggression', permissionRedacted: true },
}); });
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({ expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression', permissionRedacted: true }, option: { action: 'noAggression', permissionRedacted: true },
}); });
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
}); }
);
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]); const queryRaw = vi.fn(async () => [{ id: 51 }]);
@@ -716,4 +716,59 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0); expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false); expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
}, 45_000); }, 45_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'reserves and possesses an NPC in %s without changing finalized records',
async (phase) => {
await stopRuntime('prepare frozen possession');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `npc-visitor-${phase}`;
const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802);
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: { ...(original.meta as Record<string, GamePrisma.InputJsonValue>), isUnited: 2, isunited: 2 },
},
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
const records = async () => ({
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await records();
try {
await startRuntime(`npc-visitor-${phase}`);
const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor));
const candidates = await caller.join.listPossessCandidates({});
const picked = candidates.candidates[0]!;
expect(picked).toBeDefined();
await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce });
expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({
userId: visitorId,
npcState: 1,
});
expect(await records()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
await stopRuntime('verify frozen possession reload');
await startRuntime(`npc-visitor-reload-${phase}`);
expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore frozen possession');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('frozen-possession-restored');
}
},
30_000
);
}); });
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveNationPermission } from '../src/router/nation/shared.js';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
@@ -288,3 +289,28 @@ describe('public realtime event privacy boundary', () => {
}); });
}); });
}); });
describe('diplomatic notification recipient roles', () => {
it.each([
['군주', 12, 'normal', {}, true],
['외교권자', 1, 'ambassador', {}, true],
['조언자', 1, 'auditor', {}, true],
['일반 장수', 1, 'normal', {}, false],
['수뇌', 11, 'normal', {}, false],
['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false],
] as const)(
'%s receives only eligible nation diplomacy invalidations',
(_, officerLevel, permission, penalty, allowed) => {
const canReadDiplomacy =
resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3;
expect(canReadDiplomacy).toBe(allowed);
const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] };
expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual(
allowed ? { type: 'messagesInvalidated', refreshGrant } : null
);
expect(
toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }])
).toBeNull();
}
);
});
@@ -664,4 +664,50 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
error: null, error: null,
}); });
}, 30_000); }, 30_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'creates from the selection pool while %s',
async (phase) => {
await runtime!.lifecycle.stop('prepare frozen pool creation');
await daemonLoop;
await runtime!.close();
const original = await db.worldState.findFirstOrThrow();
await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } });
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: `frozen-pool-${phase}`,
});
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start();
await turnDaemon.requestStatus(10_000);
const visitorId = `pool-visitor-${phase}`;
const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } };
const readRecords = async () => ({
hall: await db.hallOfFame.count({ where: { serverId: profile } }),
old: await db.oldGeneral.count({ where: { serverId: profile } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await readRecords();
const candidates = await appRouter
.createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth))
.join.getSelectionPool();
const result = await appRouter
.createCaller(buildContext(`pool-create-${phase}`, visitorAuth))
.join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' });
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
});
expect(await readRecords()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
},
30_000
);
}); });
@@ -0,0 +1,232 @@
import { it, expect } from 'vitest';
import {
createGamePostgresConnector,
createRedisConnector,
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
} from '@sammo-ts/infra';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import type { GameApiContext } from '../src/context.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
integration.each([
'setState',
'patchState',
'setParticipants',
'setMatches',
'setBettingEntries',
'seedParticipants',
'cancel',
])(
'serializes %s behind a joining user without holding the ENGINE clock lock',
async (adminAction) => {
const url = databaseUrl!;
const connector = createGamePostgresConnector({ url });
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
await connector.connect();
await redisConnector.connect();
const db = connector.prisma;
const redis = redisConnector.client;
const profileName = 'che:tournament-lock-integration';
const keys = buildTournamentKeys(profileName);
const redisKeys = [...Object.values(keys), `${keys.stateKey}:mutation-lock`];
const prefix = 'integration:tournament-lock:';
const nextAt = new Date().toISOString();
let notifyJoin: () => void;
const joinAtDaemon = new Promise<void>((resolve) => {
notifyJoin = resolve;
});
let notifyAdmin: () => void;
const adminAtRedis = new Promise<void>((resolve) => {
notifyAdmin = resolve;
});
const transport = new DatabaseTurnDaemonTransport(db, 4_000);
try {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await db.turnDaemonLease.create({
data: {
profile: profileName,
ownerId: 'audit',
leaseUntil: new Date(Date.now() + 60000),
clockReady: true,
},
});
await db.worldState.create({
data: {
id: -991900,
scenarioCode: 'audit',
currentYear: 200,
currentMonth: 1,
tickSeconds: 60,
clockBaseTime: new Date(),
clockTick: 0n,
clockWallAnchor: new Date(),
clockPhase: 'RUNNING',
clockRevision: 1n,
config: { const: { develCost: 10 } },
},
});
await db.general.create({
data: { id: 991900, userId: 'audit-user', name: 'audit', turnTime: new Date(), gold: 1000 },
});
await redis.set(
keys.stateKey,
JSON.stringify({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
})
);
const base: Partial<GameApiContext> = {
db,
redis,
profile: { id: 'che', scenario: 'tournament-lock-integration', name: profileName },
auth: {
version: 1,
profile: profileName,
issuedAt: new Date().toISOString(),
expiresAt: '2999-01-01T00:00:00Z',
sessionId: 'audit',
user: { id: 'audit-user', username: 'audit', displayName: 'audit', roles: [] },
sanctions: {},
},
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
notifyJoin!();
await adminAtRedis;
const requestId = await transport.sendCommand(command);
return db.$transaction(async (tx) => {
// 이전 순서에서는 관리자가 DB lock을 보유하여 별도 ENGINE 연결이 막힌다.
await tx.$executeRawUnsafe("SET LOCAL lock_timeout = '500ms'");
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (command.type !== 'adjustGeneralResources') throw new Error('unexpected command');
const result = {
type: 'adjustGeneralResources' as const,
ok: true as const,
processed: 1,
missing: 0,
totalGoldDelta: -10,
totalRiceDelta: 0,
};
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result } });
return result;
});
},
},
};
const joinContext = { ...base, requestId: `${prefix}join` } as GameApiContext;
const adminRedis = new Proxy(redis, {
get(target, property) {
if (property === 'set')
return async (...args: Parameters<typeof redis.set>) => {
if (args[0] === `${keys.stateKey}:mutation-lock`) notifyAdmin!();
return redis.set(...args);
};
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const adminContext = {
...base,
requestId: `${prefix}admin`,
redis: adminRedis,
auth: { ...base.auth!, user: { ...base.auth!.user, roles: ['admin'] } },
} as GameApiContext;
const callAdmin = async (context: GameApiContext) => {
const caller = appRouter.createCaller(context).tournament;
switch (adminAction) {
case 'setState':
return caller.setState({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
});
case 'patchState':
return caller.patchState({ auto: true });
case 'setParticipants':
return caller.setParticipants([]);
case 'setMatches':
return caller.setMatches([]);
case 'setBettingEntries':
return caller.setBettingEntries([]);
case 'seedParticipants':
return caller.seedParticipants({ generalIds: [991900] });
default:
return caller.cancel();
}
};
const join = appRouter.createCaller(joinContext).tournament.join();
const joinOutcome = join.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await joinAtDaemon;
const admin = callAdmin(adminContext);
const adminOutcome = admin.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await adminAtRedis;
const [joined, managed] = await Promise.all([joinOutcome, adminOutcome]);
expect(joined).toMatchObject({ ok: true, value: { ok: true, count: 1 } });
expect(managed).toMatchObject({ ok: true, value: { ok: true } });
const revision = await redis.get(keys.sourceRevisionKey);
const engineCount = await db.inputEvent.count({
where: { requestId: { startsWith: prefix }, target: 'ENGINE' },
});
// API 입력 원장은 유지한다. 동일 요청 재실행은 Redis/환불 command를 다시 쓰지 않는다.
await expect(callAdmin(adminContext)).resolves.toMatchObject({ ok: true });
expect(await redis.get(keys.sourceRevisionKey)).toBe(revision);
expect(await db.inputEvent.count({ where: { requestId: { startsWith: prefix }, target: 'ENGINE' } })).toBe(
engineCount
);
const inputEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}admin:tournament.${adminAction}` },
});
expect(inputEvent).toMatchObject({ target: 'API', status: 'SUCCEEDED', attempts: 1 });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
const retryContext = { ...adminContext, requestId: `${prefix}retry` };
const beforeFailure = await redis.get(keys.sourceRevisionKey);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'SUSPENDED' } });
await expect(callAdmin(retryContext)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
expect(await redis.get(keys.sourceRevisionKey)).toBe(beforeFailure);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'RUNNING' } });
await expect(callAdmin(retryContext)).resolves.toMatchObject({ ok: true });
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}retry:tournament.${adminAction}` },
})
).toMatchObject({ status: 'SUCCEEDED', attempts: 2 });
} finally {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await redisConnector.disconnect();
await connector.disconnect();
}
},
15000
);
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
tryGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
await closeDb?.(); await closeDb?.();
}); });
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
const requestId = 'integration:vote-comment-timestamp:submit';
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
const context: Partial<GameApiContext> = {
requestId,
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
const acceptedId = await transport.sendCommand(command);
expect(acceptedId).toBe(engineRequestId);
await db.$transaction(async (transaction) => {
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
true
);
const event = await transaction.inputEvent.findUniqueOrThrow({
where: { requestId: acceptedId },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'PENDING',
actorUserId: fixtureUserId,
});
await transaction.inputEvent.update({
where: { requestId: acceptedId },
data: {
status: 'SUCCEEDED',
result: {
type: 'voteReward',
ok: true,
voteId: fixtureId,
generalId: fixtureId,
awardedUnique: false,
},
},
});
});
return transport.requestCommand(command);
},
},
};
const caller = appRouter.createCaller(context as GameApiContext);
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
ok: true,
wonLottery: false,
});
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => { it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>` const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone" SELECT current_setting('TIMEZONE') AS "timeZone"
+35 -1
View File
@@ -216,6 +216,40 @@ const buildContext = (options: {
}; };
describe('vote router actor and permission boundaries', () => { describe('vote router actor and permission boundaries', () => {
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
const fixture = buildContext({ requestId: 'vote-boundary' });
const transaction = vi.fn(async () => {
throw new Error('API transaction blocks the vote ENGINE clock fence');
});
Object.assign(fixture.context.db, { $transaction: transaction });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
generalId: 7,
})
);
});
it.each([
{ auth: null, code: 'UNAUTHORIZED' },
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
])(
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
async ({ auth, code }) => {
const fixture = buildContext({ auth });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).rejects.toMatchObject({ code });
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
);
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => { it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z'); const now = new Date('2026-07-26T00:00:00Z');
const time = { const time = {
@@ -259,7 +293,7 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe( expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false false
); );
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]); expect(fixture.changeJournal.snapshot()).toEqual([]);
expect(fixture.redisIncr).not.toHaveBeenCalled(); expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
}); });
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js'; import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js';
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const revisionRow = (overrides: Record<string, unknown> = {}) => ({ const revisionRow = (overrides: Record<string, unknown> = {}) => ({
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
db: { $queryRaw: queryRaw }, db: { $queryRaw: queryRaw },
} as unknown as GameApiContext; } as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
'sammo:map:base:hwe:scenario_2400:pg12'
);
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
'sammo:map:public:hwe:scenario_2400:pg12' 'sammo:map:public:hwe:scenario_2400:pg12'
); );
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('read_model_revision_meta'); expect(statement.sql).toContain('read_model_revision_meta');
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'"); expect(statement.sql).toContain('revision."domain" = \'map.world\'');
expect(statement.values).toEqual([]); expect(statement.values).toEqual([]);
}); });
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true }); const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true }); const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } }); expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } }); expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
expect(redis.set).toHaveBeenCalledTimes(1); expect(redis.set).toHaveBeenCalledTimes(1);
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
expect(shared).not.toHaveProperty('shownByGeneralList'); expect(shared).not.toHaveProperty('shownByGeneralList');
expect(shared).not.toHaveProperty('myCity'); expect(shared).not.toHaveProperty('myCity');
expect(shared).not.toHaveProperty('myNation'); expect(shared).not.toHaveProperty('myNation');
// 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다.
delete shared.uniqueItemLimit;
cache.set(cache.keys().next().value!, JSON.stringify(shared));
const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true });
expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(redis.set).toHaveBeenCalledTimes(2);
}); });
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => { it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
redis, redis,
db: { db: {
$queryRaw: queryRaw, $queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) }, worldState: {
findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })),
},
}, },
} as unknown as GameApiContext; } as unknown as GameApiContext;
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
expect(redis.set).not.toHaveBeenCalled(); expect(redis.set).not.toHaveBeenCalled();
}); });
}); });
describe('map unique ownership limit', () => {
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
currentYear,
meta: { scenarioMeta: { startYear: 180 } },
config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } },
});
it.each([
[179, 1, 182],
[181, 1, 182],
[182, 1, 182],
[183, 2, 189],
[189, 2, 189],
[190, 3, 199],
[200, 4, null],
])('projects year %i and the inclusive last month', (year, count, untilYear) => {
expect(resolveMapUniqueItemLimit(state(year!))).toEqual({
count,
until: untilYear === null ? null : { year: untilYear, month: 12 },
});
});
it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => {
expect(
resolveMapUniqueItemLimit(
state(181, {
maxUniqueItemLimit: [
[-1, 1],
[2, 1],
[4, 3],
[8, 4],
],
})
)
).toEqual({ count: 1, until: { year: 183, month: 12 } });
expect(
resolveMapUniqueItemLimit(
state(183, {
allItems: { horse: {}, weapon: {} },
})
)
).toEqual({ count: 2, until: null });
expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null });
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`, text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic'; import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
@@ -124,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: options.text, text: options.text,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
@@ -169,6 +169,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND ( AND (
${gameplayAllowed} ${gameplayAllowed}
OR "event_type" = 'getStatus' OR "event_type" = 'getStatus'
OR (
${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'}
AND "event_type" IN (
'joinCreateGeneral', 'npcPossessGeneral',
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
)
)
OR ( OR (
${suspendedTournamentBetCommand} ${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
} }
export class TurnDaemonLeaseLostError extends Error { export class TurnDaemonLeaseLostError extends Error {
constructor(profile: string) { constructor(profile: string, reason?: string) {
super(`Turn daemon lease was lost for profile "${profile}".`); super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
this.name = 'TurnDaemonLeaseLostError'; this.name = 'TurnDaemonLeaseLostError';
} }
} }
@@ -50,6 +50,7 @@ export class DatabaseTurnDaemonLease {
private expiryTimer: NodeJS.Timeout | null = null; private expiryTimer: NodeJS.Timeout | null = null;
private renewalInFlight = false; private renewalInFlight = false;
private lost = false; private lost = false;
private lossReason: string | undefined;
private constructor( private constructor(
db: GamePrismaClient, db: GamePrismaClient,
@@ -116,6 +117,7 @@ export class DatabaseTurnDaemonLease {
fencingEpoch: BigInt(row.fencing_epoch), fencingEpoch: BigInt(row.fencing_epoch),
}; };
this.lost = false; this.lost = false;
this.lossReason = undefined;
this.scheduleExpiryWatchdog(requestStartedAt); this.scheduleExpiryWatchdog(requestStartedAt);
if (this.heartbeatEnabled) { if (this.heartbeatEnabled) {
this.startHeartbeat(); this.startHeartbeat();
@@ -139,6 +141,10 @@ export class DatabaseTurnDaemonLease {
return this.lost; return this.lost;
} }
getLossError(): TurnDaemonLeaseLostError {
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
}
async renew(): Promise<boolean> { async renew(): Promise<boolean> {
const token = this.token; const token = this.token;
if (!token || this.lost || this.renewalInFlight) { if (!token || this.lost || this.renewalInFlight) {
@@ -160,7 +166,7 @@ export class DatabaseTurnDaemonLease {
RETURNING "profile", "owner_id", "fencing_epoch" RETURNING "profile", "owner_id", "fencing_epoch"
`); `);
if (rows.length === 0) { if (rows.length === 0) {
this.markLost(); this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
return false; return false;
} }
if (this.lost) { if (this.lost) {
@@ -176,7 +182,7 @@ export class DatabaseTurnDaemonLease {
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> { async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
const token = this.token; const token = this.token;
if (!token || this.lost) { if (!token || this.lost) {
throw new TurnDaemonLeaseLostError(this.profile); throw this.getLossError();
} }
const db = transaction ?? this.db; const db = transaction ?? this.db;
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql` const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
@@ -190,8 +196,8 @@ export class DatabaseTurnDaemonLease {
FOR UPDATE FOR UPDATE
`); `);
if (rows.length === 0) { if (rows.length === 0) {
this.markLost(); this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
throw new TurnDaemonLeaseLostError(this.profile); throw this.getLossError();
} }
} }
@@ -228,8 +234,10 @@ export class DatabaseTurnDaemonLease {
} }
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3)); const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
this.heartbeatTimer = setInterval(() => { this.heartbeatTimer = setInterval(() => {
void this.renew().catch(() => { void this.renew().catch((error: unknown) => {
this.markLost(); this.markLost(
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
);
}); });
}, intervalMs); }, intervalMs);
this.heartbeatTimer.unref(); this.heartbeatTimer.unref();
@@ -248,7 +256,9 @@ export class DatabaseTurnDaemonLease {
this.stopExpiryWatchdog(); this.stopExpiryWatchdog();
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt)); const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
this.expiryTimer = setTimeout(() => { this.expiryTimer = setTimeout(() => {
this.markLost(); this.markLost(
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
);
}, remainingMs); }, remainingMs);
this.expiryTimer.unref(); this.expiryTimer.unref();
} }
@@ -260,7 +270,9 @@ export class DatabaseTurnDaemonLease {
} }
} }
private markLost(): void { private markLost(reason: string): void {
if (this.lost) return;
this.lossReason = reason;
this.lost = true; this.lost = true;
this.stopHeartbeat(); this.stopHeartbeat();
this.stopExpiryWatchdog(); this.stopExpiryWatchdog();
@@ -90,7 +90,20 @@ export class TurnDaemonLifecycle {
start(): Promise<void> { start(): Promise<void> {
if (!this.loopPromise) { if (!this.loopPromise) {
this.loopPromise = this.runLoop(); this.loopPromise = this.runLoop().catch(async (error: unknown) => {
// 초기화와 pause gate 실패도 관리자에게 즉시 남긴다. lease를
// 잃은 runtime은 대기 상태로 살아 있으면 안전하게 재개할 수 없다.
this.status.state = 'stopping';
this.status.running = false;
this.status.paused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown lifecycle error.';
try {
await this.hooks?.onRunError?.(error);
} catch {
// 장애 기록 실패가 원래 종료 원인을 덮어쓰지 않게 한다.
}
throw error;
});
} }
return this.loopPromise; return this.loopPromise;
} }
+12 -9
View File
@@ -242,20 +242,23 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS); : (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60)); const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
const sync = install?.sync ?? false; const sync = install?.sync ?? false;
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
const gameClockMode = options.gameClockMode ?? 'realtime'; const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume // A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands. // wall time while users are only allowed to edit reserved commands.
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now); const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow; const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
const openingFloor = cutTurn(requestedOpening, turnTermMinutes); // Opening is an exact wall instant, independent of the calendar's 12-turn
const initialClockWallAnchor = // grouping. Only the initial year/month uses the legacy calendar alignment.
gameClockMode === 'manual' const initialClockWallAnchor = requestedOpening;
? requestedOpening const startState = resolveStartState(
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0)); scenario.startYear ?? null,
gameClockMode === 'manual' ? now : requestedOpening,
turnTermMinutes,
sync
);
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor); const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({ const initialClock = new GameClock({
baseTime: startState.startTime, baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
tick: 0, tick: 0,
mode: gameClockMode, mode: gameClockMode,
wallAnchor: initialClockWallAnchor, wallAnchor: initialClockWallAnchor,
@@ -324,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// monthly pre-handler recalculates the same value at each boundary. // monthly pre-handler recalculates the same value at each boundary.
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime), starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now), turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
opentime: formatDateTime(initialClockWallAnchor), opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now), lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
}; };
const firstGameIdx = const firstGameIdx =
+3 -1
View File
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
import { JosaUtil, asRecord } from '@sammo-ts/common'; import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic'; import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
@@ -253,7 +254,8 @@ export const createTournamentRewardFinalizer = async (options: {
})) }))
.filter((entry) => !!entry.userId); .filter((entry) => !!entry.userId);
for (const entry of pointUpdates) { const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId);
for (const entry of recordsFinalized ? [] : pointUpdates) {
await db.inheritancePoint.upsert({ await db.inheritancePoint.upsert({
where: { where: {
userId_key: { userId: entry.userId!, key: 'tournament' }, userId_key: { userId: entry.userId!, key: 'tournament' },
@@ -192,6 +192,13 @@ const respondToScout = async (options: {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }; return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
} }
// 등용장은 발신 장수의 현재 소속이 아니라 발송 당시 국가에 귀속된다.
// 멸망 처리 도입 전에 남은 편지도 수락/거절 전에 영구 만료한다.
if (!world.getNationById(payload.src.nationId)) {
await invalidateMessageIds(db, world, [row.id], now);
return { ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' };
}
const sourceNationName = payload.src.nationName; const sourceNationName = payload.src.nationName;
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로'); const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
if (response) { if (response) {
+23
View File
@@ -5,6 +5,8 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js'; import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
import { createTurnDaemonRuntime } from './turnDaemon.js'; import { createTurnDaemonRuntime } from './turnDaemon.js';
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js'; import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonCliOptions { export interface TurnDaemonCliOptions {
profile?: string; profile?: string;
@@ -89,6 +91,27 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
pauseGateIntervalMs, pauseGateIntervalMs,
adminActionIntervalMs, adminActionIntervalMs,
gameClockMode, gameClockMode,
}).catch(async (error: unknown) => {
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
// lifecycle hook이 아직 없으므로 여기서 별도로 관리자에게 기록한다.
if (!(error instanceof TurnDaemonLeaseUnavailableError)) {
try {
const gate = await createGatewayProfileGate({
databaseUrl,
gatewayDatabaseUrl,
profileName,
incidentContext: () => ({ stage: 'startup' }),
});
try {
await gate.markPaused(error);
} finally {
await gate.close();
}
} catch {
/* 원래 시작 실패를 보존한다. */
}
}
throw error;
}); });
const memoryReporter = createTurnDaemonMemoryReporter({ const memoryReporter = createTurnDaemonMemoryReporter({
@@ -1,9 +1,18 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { GameClock, parseGameClockPhase } from '@sammo-ts/common'; import {
ChangeJournal,
GameClock,
formatServerDateTime,
parseGameClockPhase,
readTurnRecovery,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_PUBLIC, resolveMessageTargetIcon, sendMessage } from '@sammo-ts/logic';
import { import {
CLOCK_OPERATION_PERSISTENCE_LOCK, CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma, GamePrisma,
persistMessageEnvelope,
writeReadModelChangeJournal,
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient, type GamePrismaClient,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
@@ -302,6 +311,47 @@ export const applyNextClockProjection = async (options: {
throw new Error('Clock projection final RUNNING transition fence failed.'); throw new Error('Clock projection final RUNNING transition fence failed.');
} }
const appliedAt = await readDbWall(transaction); const appliedAt = await readDbWall(transaction);
// RUNNING 전이와 같은 transaction에 남겨 재시도 시 전체 공지를 중복 발송하지 않는다.
const recovery = readTurnRecovery(world);
const journal = new ChangeJournal();
journal.mark('world.content').mark('map.world');
if (recovery) {
const recoveredClock = new GameClock({
baseTime: world.clockBaseTime!,
tick: Number(world.clockTick),
wallAnchor: world.clockWallAnchor!,
mode: 'realtime',
turnSeconds: world.tickSeconds,
recovery,
});
const endsAt = recoveredClock.tickToWallDate(recovery.endTick);
const system = {
generalId: 0,
generalName: '시스템',
nationId: 0,
nationName: '',
color: '#000000',
icon: resolveMessageTargetIcon(),
};
await sendMessage(
{ insertMessage: (draft) => persistMessageEnvelope(transaction, draft) },
{
msgType: 'public',
src: system,
dest: system,
text: `서버 재개에 따른 2배속 복구 시간: ${formatServerDateTime(recovery.startWallAt)} ~ ${formatServerDateTime(endsAt)} (한국 시각). 시작 전까지 대기하며, 종료 시 정상 속도로 진행합니다.`,
time: appliedAt,
validUntil: new Date('9999-12-31T00:00:00Z'),
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: endsAt.toISOString(),
},
}
);
journal.mark('messages.mailbox', MESSAGE_MAILBOX_PUBLIC);
}
await writeReadModelChangeJournal(transaction, journal.snapshot());
await transaction.clockProjectionOutbox.update({ await transaction.clockProjectionOutbox.update({
where: { id: outbox.id }, where: { id: outbox.id },
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null }, data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
+54 -7
View File
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK, CLOCK_OPERATION_PERSISTENCE_LOCK,
@@ -453,8 +454,21 @@ const markIds = (journal: ChangeJournal, domain: ReadModelDomain, ids: readonly
* actor-targeted. General name/nation changes affect the global online list, * actor-targeted. General name/nation changes affect the global online list,
* while frontStatusActorIds is the private actor projection. * while frontStatusActorIds is the private actor projection.
*/ */
export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges): ChangeJournal => { export const createReadModelChangeJournal = (
changes: RealtimeReadModelChanges,
commandResult?: TurnDaemonCommandResult
): ChangeJournal => {
const journal = new ChangeJournal(); const journal = new ChangeJournal();
// 설문 완료 여부는 투표한 actor만의 projection이다. 보상과 같은 ENGINE
// transaction에 기록해야 API 응답 실패·재시도에도 commit 뒤 알림이 보존된다.
if (commandResult?.type === 'voteReward' && commandResult.ok) {
journal.mark('front.general', commandResult.generalId);
}
// 과거 멸망국 등용장의 거부 응답도 action을 만료시킬 수 있다.
// 개인 메시지 응답 뒤에는 성공 여부와 관계없이 해당 수신함을 다시 읽는다.
if (commandResult?.type === 'messageRespond' && commandResult.action === 'scout') {
journal.mark('messages.mailbox', commandResult.generalId);
}
markIds(journal, 'general.content', changes.generalIds); markIds(journal, 'general.content', changes.generalIds);
markIds(journal, 'city.content', changes.cityIds); markIds(journal, 'city.content', changes.cityIds);
markIds(journal, 'nation.content', changes.nationIds); markIds(journal, 'nation.content', changes.nationIds);
@@ -602,7 +616,8 @@ const persistNationBettingOpen = async (
const persistNationBettingFinish = async ( const persistNationBettingFinish = async (
prisma: GamePrisma.TransactionClient, prisma: GamePrisma.TransactionClient,
finish: PendingNationBettingFinish finish: PendingNationBettingFinish,
recordsFinalized: boolean
): Promise<void> => { ): Promise<void> => {
await prisma.$queryRaw` await prisma.$queryRaw`
SELECT id SELECT id
@@ -652,7 +667,7 @@ const persistNationBettingFinish = async (
})), })),
}); });
for (const reward of rewards) { for (const reward of recordsFinalized ? [] : rewards) {
if (!reward.userId) { if (!reward.userId) {
continue; continue;
} }
@@ -1451,11 +1466,12 @@ export const createDatabaseTurnHooks = async (
`); `);
} }
const recordsFinalized = await areSeasonRecordsFinalized(prisma, asRecord(state.meta).serverId);
for (const betting of pendingNationBettingOpens) { for (const betting of pendingNationBettingOpens) {
await persistNationBettingOpen(prisma, betting); await persistNationBettingOpen(prisma, betting);
} }
for (const finish of pendingNationBettingFinishes) { for (const finish of pendingNationBettingFinishes) {
await persistNationBettingFinish(prisma, finish); await persistNationBettingFinish(prisma, finish, recordsFinalized);
} }
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
@@ -1464,7 +1480,7 @@ export const createDatabaseTurnHooks = async (
const persistInheritancePointAdjustments = async ( const persistInheritancePointAdjustments = async (
entries: typeof inheritancePointAdjustments entries: typeof inheritancePointAdjustments
): Promise<void> => { ): Promise<void> => {
if (entries.length === 0) { if (recordsFinalized || entries.length === 0) {
return; return;
} }
const grouped = new Map<string, { userId: string; key: string; amount: number }>(); const grouped = new Map<string, { userId: string; key: string; amount: number }>();
@@ -1490,7 +1506,7 @@ export const createDatabaseTurnHooks = async (
} }
}; };
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => { const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
if (entries.length === 0) { if (recordsFinalized || entries.length === 0) {
return; return;
} }
await prisma.inheritanceLog.createMany({ await prisma.inheritanceLog.createMany({
@@ -1899,6 +1915,37 @@ export const createDatabaseTurnHooks = async (
{ sendDestOnly: message.sendDestOnly } { sendDestOnly: message.sendDestOnly }
); );
} }
if (deletedNations.length > 0) {
// 발신자 하야/이적은 등용장을 바꾸지 않는다. 발송 당시 국가가
// 멸망할 때만 같은 transaction에서 action과 구버전 만료 투영을 닫는다.
// 이번 flush에 생성된 편지도 포함하도록 message 저장 뒤에 처리한다.
const letters = await prisma.message.findMany({
where: {
type: 'private',
action: { actionType: 'scout', status: 'PENDING' },
OR: deletedNations.map((nationId) => ({
message: { path: ['src', 'nationId'], equals: nationId },
})),
},
select: { id: true, mailbox: true },
});
if (letters.length > 0) {
const ids = letters.map(({ id }) => id);
const resolvedGameTick = BigInt(state.clockTick ?? state.lastTurnTick ?? 0);
await prisma.messageAction.updateMany({
where: { messageId: { in: ids }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick },
});
await prisma.message.updateMany({
where: { id: { in: ids } },
data: {
validUntil: world.gameTickToDate(Number(resolvedGameTick)),
validUntilTick: resolvedGameTick,
},
});
persistedMessageMailboxes.push(...letters.map(({ mailbox }) => mailbox));
}
}
if (options?.reservedTurns && persistedReservedTurnChanges) { if (options?.reservedTurns && persistedReservedTurnChanges) {
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges); await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
} }
@@ -1978,7 +2025,7 @@ export const createDatabaseTurnHooks = async (
if (worldReadModelSignature !== worldReadModelBaseline) { if (worldReadModelSignature !== worldReadModelBaseline) {
readModelChanges.worldChanged = true; readModelChanges.worldChanged = true;
} }
const journal = createReadModelChangeJournal(readModelChanges); const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
if (hasDashboardSourceMutation(changes, readModelChanges)) { if (hasDashboardSourceMutation(changes, readModelChanges)) {
journal.mark('dashboard.global'); journal.mark('dashboard.global');
} }
+39 -11
View File
@@ -1,6 +1,7 @@
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
import { randomUUID } from 'node:crypto';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common'; import { describeRuntimeError, gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra'; import { createGatewayPostgresConnector } from '@sammo-ts/infra';
export interface GatewayProfileGateOptions { export interface GatewayProfileGateOptions {
@@ -8,6 +9,7 @@ export interface GatewayProfileGateOptions {
gatewayDatabaseUrl?: string; gatewayDatabaseUrl?: string;
profileName: string; profileName: string;
cacheMs?: number; cacheMs?: number;
incidentContext?: () => Record<string, string | number | boolean | null>;
} }
export interface GatewayProfileGate { export interface GatewayProfileGate {
@@ -22,6 +24,7 @@ const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => { export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({ const connector = createGatewayPostgresConnector({
url: options.gatewayDatabaseUrl ?? options.databaseUrl, url: options.gatewayDatabaseUrl ?? options.databaseUrl,
connectionTimeoutMillis: 3000,
}); });
await connector.connect(); await connector.connect();
const prisma = connector.prisma; const prisma = connector.prisma;
@@ -54,19 +57,44 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return cachedPause; return cachedPause;
}, },
async markPaused(error?: unknown): Promise<void> { async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null; const failure = error ? describeRuntimeError(error) : null;
const message = failure?.message ?? null;
try { try {
await prisma.gatewayProfile.updateMany({ await prisma.$transaction(async (tx) => {
where: { const updated = await tx.gatewayProfile.updateMany({
profileName: options.profileName, where: {
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] }, profileName: options.profileName,
}, status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
data: { OR: [{ status: { not: 'PAUSED' } }, { lastError: { not: message } }, { lastError: null }],
status: 'PAUSED', },
lastError: message, data: {
}, status: 'PAUSED',
lastError: message,
},
});
if (updated.count && failure) {
// 상태와 이력을 함께 commit한다. 재개가 lastError를 지워도
// 당시 원인과 실행 좌표는 관리자 감사 저장소에 남는다.
await tx.adminAuditEvent.create({
data: {
correlationId: randomUUID(),
actorUserId: 'system:turn-daemon',
actorUsername: 'turn-daemon',
credentialKind: 'DAEMON',
action: 'runtime.failure',
targetType: 'profile-runtime',
targetId: options.profileName,
profileName: options.profileName,
outcome: 'FAILED',
errorCode: failure.code,
errorMessage: failure.message,
summary: { frames: failure.frames, ...options.incidentContext?.() },
},
});
}
}); });
} catch { } catch {
if (failure) console.error('[turn-daemon] failed to persist runtime incident', failure);
return; return;
} }
}, },
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { import {
asRecord, asRecord,
HALL_OF_FAME_TYPES, HALL_OF_FAME_TYPES,
@@ -403,10 +404,15 @@ export const persistGeneralLifecycleEvents = async (
data: { refreshScore: 0 }, data: { refreshScore: 0 },
}); });
const recordsFinalized = await areSeasonRecordsFinalized(prisma, worldMeta.serverId);
for (const event of events) { for (const event of events) {
if (event.outcome === 'detached' || event.outcome === 'deleted') { if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
} }
if (recordsFinalized) {
if (event.outcome === 'retired') await persistPostRetirementRankValues(prisma, event);
continue;
}
if (event.outcome !== 'deleted' && event.outcome !== 'retired') { if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
continue; continue;
} }
+66 -1
View File
@@ -9,12 +9,13 @@ import type {
TurnSchedule, TurnSchedule,
UnitSetDefinition, UnitSetDefinition,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic'; import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { import {
GAME_TICKS_PER_TURN, GAME_TICKS_PER_TURN,
GameClock, GameClock,
assertGameplayCommitAllowed, assertGameplayCommitAllowed,
inferClockPhase, inferClockPhase,
JosaUtil,
type GameClockMode, type GameClockMode,
type GameClockPhase, type GameClockPhase,
type TurnRecoveryWindow, type TurnRecoveryWindow,
@@ -78,6 +79,7 @@ export interface GeneralTurnResult {
troopIds?: number[]; troopIds?: number[];
}; };
destroyedNationIds?: number[]; destroyedNationIds?: number[];
successorlessNationId?: number;
lifecycleEvent?: GeneralLifecycleEvent; lifecycleEvent?: GeneralLifecycleEvent;
} }
@@ -2028,6 +2030,11 @@ export class InMemoryTurnWorld {
this.removeTroop(troopId); this.removeTroop(troopId);
} }
} }
if (result.successorlessNationId !== undefined) {
// 사망 군주도 삭제 전 archive의 장수 목록과 멸망 로그에 포함한다.
this.generals.set(currentGeneral.id, result.general ?? currentGeneral);
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id);
}
if (result.deleted?.general) { if (result.deleted?.general) {
this.removeGeneral(currentGeneral.id); this.removeGeneral(currentGeneral.id);
} }
@@ -2227,6 +2234,64 @@ export class InMemoryTurnWorld {
return changes; return changes;
} }
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number): boolean {
const nation = this.nations.get(nationId);
if (!nation) {
return false;
}
const members = this.listGenerals().filter((general) => general.nationId === nationId);
const dyingLord = members.find((general) => general.id === dyingLordId);
if (
(dyingLordId !== undefined && dyingLord?.officerLevel !== 12) ||
members.some(
(general) => general.id !== dyingLordId && (general.npcState !== 5 || general.officerLevel === 12)
)
) {
throw new Error(`Nation ${nationId} still has a ruler or successor.`);
}
// Ref nextRuler() -> deleteNation(true): 부대장(npc=5)은 후계자가
// 될 수 없다. 자원 약탈·포상·난수 소비 없이 도시를 공백지로 돌린다.
for (const city of this.listCities()) {
if (city.nationId === nationId) {
this.updateCity(city.id, { nationId: 0, frontState: 0 });
}
}
const orderedMembers = members.sort((left, right) => {
if (left.id === dyingLordId) return 1;
if (right.id === dyingLordId) return -1;
return left.id - right.id;
});
const pushHistory = (): void => {
this.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: `<R><b>【멸망】</b></><D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '은')} <R>멸망</>했습니다.`,
});
};
for (const general of orderedMembers) {
if (general.id === dyingLordId) pushHistory();
// Ref applyDB()는 개인 역사 bucket을 행동 bucket보다 먼저 저장한다.
this.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: general.id,
format: LogFormat.YEAR_MONTH,
text: `<D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '이')} <R>멸망</>`,
});
this.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: general.id,
format: LogFormat.PLAIN,
text: `<D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '이')} <R>멸망</>했습니다.`,
});
}
// 과거 누락으로 군주가 이미 삭제된 국가의 운영 복구도 같은 정산을 쓴다.
if (dyingLordId === undefined) pushHistory();
return this.collapseNation(nationId);
}
collapseNation(nationId: number): boolean { collapseNation(nationId: number): boolean {
const nation = this.nations.get(nationId); const nation = this.nations.get(nationId);
if (!nation) { if (!nation) {
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
import { import {
@@ -613,14 +614,20 @@ export const createGeneralFromJoin = async (options: {
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat); const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
const inheritConstants = resolveInheritConstants(worldState); const inheritConstants = resolveInheritConstants(worldState);
const worldMeta = asRecord(worldState.meta); const worldMeta = asRecord(worldState.meta);
const recordsFinalized = await areSeasonRecordsFinalized(db, worldMeta.serverId);
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus); const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
const currentInheritancePoint = await applyInheritanceUser( if (recordsFinalized && inheritRequiredPoint > 0) {
db, fail('BAD_REQUEST', '통일 이후에는 유산 포인트를 사용하는 생성 옵션을 적용할 수 없습니다.');
input.userId, }
worldState.currentYear, const currentInheritancePoint = recordsFinalized
worldState.currentMonth ? (await db.inheritancePoint.findMany({ where: { userId: input.userId }, select: { value: true } })).reduce(
); (sum, row) => sum + row.value,
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint); 0
)
: await applyInheritanceUser(db, input.userId, worldState.currentYear, worldState.currentMonth);
if (!recordsFinalized) {
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
}
if (currentInheritancePoint < inheritRequiredPoint) { if (currentInheritancePoint < inheritRequiredPoint) {
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!'); fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
} }
@@ -743,7 +750,7 @@ export const createGeneralFromJoin = async (options: {
? input.ownerIconRevision ? input.ownerIconRevision
: undefined; : undefined;
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const restInheritanceBonus = recordsFinalized ? 0 : await resolveRestInheritanceBonus(db, worldState, input.userId);
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다. // Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면 // 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -127,7 +128,7 @@ export const createOpenNationBettingHandler = (options: {
nationId: general.nationId, nationId: general.nationId,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000', color: nation?.color ?? '#000000',
icon: general.picture ?? '', icon: resolveMessageTargetIcon(general),
}, },
text, text,
time: now, time: now,
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { immediateRecoveryLimitSeconds } from '@sammo-ts/common';
import type { GamePrismaClient } from '@sammo-ts/infra'; import type { GamePrismaClient } from '@sammo-ts/infra';
import { import {
readClockDatabaseWall, readClockDatabaseWall,
@@ -27,8 +28,12 @@ export const prepareRealtimeRecovery = async (
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return; if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
const now = await readClockDatabaseWall(db); const now = await readClockDatabaseWall(db);
// 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다. // 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다.
// 한 턴 미만의 장애는 잔여 구간 실행만 필요하므로 새 좌표 세대를 만들지 않는다. // 짧은 중단만 즉시 처리한다. 기준값과 같으면 대기 후 복구한다.
if (!options.paused && now.getTime() - world.clockWallAnchor.getTime() < world.tickSeconds * 1_000) return; if (
!options.paused &&
now.getTime() - world.clockWallAnchor.getTime() < immediateRecoveryLimitSeconds(world.tickSeconds) * 1_000
)
return;
const suspensionId = `recovery-${randomUUID()}`; const suspensionId = `recovery-${randomUUID()}`;
await startClockSuspension({ await startClockSuspension({
db, db,
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type { import type {
ActionContextBase, ActionContextBase,
ActionContextBuilder, ActionContextBuilder,
@@ -2121,7 +2122,7 @@ export const createReservedTurnHandler = async (options: {
nationId: currentGeneral.nationId, nationId: currentGeneral.nationId,
nationName: currentNation?.name ?? '재야', nationName: currentNation?.name ?? '재야',
color: currentNation?.color ?? '#000000', color: currentNation?.color ?? '#000000',
icon: currentGeneral.picture ?? '', icon: resolveMessageTargetIcon(currentGeneral),
}; };
messages.push({ messages.push({
msgType: 'public', msgType: 'public',
@@ -2252,6 +2253,7 @@ export const createReservedTurnHandler = async (options: {
? 'retired' ? 'retired'
: 'active'; : 'active';
let deleteGeneral = false; let deleteGeneral = false;
let successorlessNationId: number | undefined;
const deletedTroopIds = Array.from(commandDeletedTroopIds); const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
if (currentGeneral.meta.killturn <= 0) { if (currentGeneral.meta.killturn <= 0) {
@@ -2351,6 +2353,10 @@ export const createReservedTurnHandler = async (options: {
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다` `<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
) )
); );
} else {
// Ref nextRuler()는 후계자가 없으면 군주 삭제 전에
// deleteNation($general, true)로 국가 전체를 정산한다.
successorlessNationId = currentNation.id;
} }
} }
if (currentGeneral.troopId === currentGeneral.id) { if (currentGeneral.troopId === currentGeneral.id) {
@@ -2424,6 +2430,7 @@ export const createReservedTurnHandler = async (options: {
} }
: undefined), : undefined),
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined), ...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
...(successorlessNationId !== undefined ? { successorlessNationId } : {}),
lifecycleEvent: { lifecycleEvent: {
generalId: currentGeneral.id, generalId: currentGeneral.id,
outcome: lifecycleOutcome, outcome: lifecycleOutcome,
+14
View File
@@ -0,0 +1,14 @@
import type { DatabaseClient } from '@sammo-ts/infra';
/** 통일 대기/이민족전으로 시계가 재개되어도 확정된 기수의 기록은 다시 열지 않는다. */
export const areSeasonRecordsFinalized = async (
db: Pick<DatabaseClient, 'gameHistory'>,
serverId: unknown
): Promise<boolean> => {
if (typeof serverId !== 'string' || !serverId.trim()) return false;
const history = await db.gameHistory.findUnique({
where: { serverId: serverId.trim() },
select: { status: true },
});
return history?.status === 'COMPLETED';
};
+16 -1
View File
@@ -888,6 +888,19 @@ const createTurnDaemonRuntimeWithLease = async (
gatewayDatabaseUrl: options.gatewayDatabaseUrl, gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName, profileName: options.profileName,
cacheMs: options.pauseGateIntervalMs, cacheMs: options.pauseGateIntervalMs,
incidentContext: () => {
const state = world.getState();
const clock = world.getGameClockState();
const token = turnDaemonLease?.getToken();
return {
year: state.currentYear,
month: state.currentMonth,
clockPhase: clock.phase,
clockTick: clock.tick,
ownerId: token?.ownerId ?? null,
fencingEpoch: token?.fencingEpoch.toString() ?? null,
};
},
}) })
: null; : null;
if (gatewayGate) { if (gatewayGate) {
@@ -1060,7 +1073,9 @@ const createTurnDaemonRuntimeWithLease = async (
hooks, hooks,
pauseGate: async () => { pauseGate: async () => {
if (turnDaemonLease?.isLost()) { if (turnDaemonLease?.isLost()) {
return true; // 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
throw turnDaemonLease.getLossError();
} }
const gatewayPaused = (await pauseGate?.()) ?? false; const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase; const phase = world.getGameClockState().phase;
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common'; import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
@@ -156,7 +157,7 @@ export const createUnificationHandler = (options: {
nationId: winner.id, nationId: winner.id,
nationName: winner.name, nationName: winner.name,
color: winner.color, color: winner.color,
icon: recipient.picture ?? '', icon: resolveMessageTargetIcon(recipient),
}, },
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`, text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
time: context.turnTime, time: context.turnTime,
@@ -1,3 +1,5 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
@@ -95,6 +97,8 @@ const claimGeneration = async (
} }
return 'ALREADY_APPLIED'; return 'ALREADY_APPLIED';
} }
// 이전 버전/이관 기수에 generation row가 없어도 통일 기록을 다시 정산하지 않는다.
if (await areSeasonRecordsFinalized(transaction, input.serverId)) return 'ALREADY_APPLIED';
await transaction.unificationFinalization.create({ await transaction.unificationFinalization.create({
data: { data: {
generationKey: input.generationKey, generationKey: input.generationKey,
@@ -243,7 +247,7 @@ const cancelPendingUniqueAuctions = async (
nationId: bidder.nationId, nationId: bidder.nationId,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000', color: nation?.color ?? '#000000',
icon: bidder.picture ?? '', icon: resolveMessageTargetIcon(bidder),
}, },
text: `${planned.auctionId}${planned.title}가 취소되었습니다.`, text: `${planned.auctionId}${planned.title}가 취소되었습니다.`,
time: input.completedAt, time: input.completedAt,
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
const snapshot: TurnWorldSnapshot = { const snapshot: TurnWorldSnapshot = {
generals: [actor], generals: [actor],
cities: [], cities: [],
nations: [], nations: [
{
id: 2,
name: '촉',
color: '#000000',
level: 1,
capitalCityId: 2,
chiefGeneralId: 8,
gold: 0,
rice: 0,
power: 0,
typeCode: 'che_중립',
meta: {},
},
],
troops: [], troops: [],
diplomacy: [], diplomacy: [],
events: [], events: [],
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0); expect(world.peekDirtyState().messages).toHaveLength(0);
}); });
it.each([true, false])(
'invalidates a surviving letter from a collapsed nation on response=%s',
async (response) => {
const world = buildWorld();
world.removeNation(source.nationId);
const { db, actionUpdateMany, updateMany } = buildDb([[buildRow('scout')]]);
const executor = buildExecutor();
const result = await respondToActionableMessage({
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response,
});
expect(result).toEqual({ ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' });
expect(executor.execute).not.toHaveBeenCalled();
expect(actionUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { messageId: { in: [29] }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: expect.any(BigInt) },
})
);
expect(updateMany).toHaveBeenCalledOnce();
expect(world.getGeneralById(actor.id)?.nationId).toBe(actor.nationId);
expect(world.peekDirtyState().messages).toHaveLength(0);
}
);
it('treats a legacy truthy used value as an invalid scout letter', async () => { it('treats a legacy truthy used value as an invalid scout letter', async () => {
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) { for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
const world = buildWorld(); const world = buildWorld();
@@ -29,6 +29,8 @@ describeIntegration('durable clock reconciliation', () => {
const clean = async (): Promise<void> => { const clean = async (): Promise<void> => {
await redis.client.flushDb(); await redis.client.flushDb();
await db.$transaction([ await db.$transaction([
db.readModelOutbox.deleteMany(),
db.readModelRevision.deleteMany(),
db.clockProjectionOutbox.deleteMany(), db.clockProjectionOutbox.deleteMany(),
db.clockReconciliationParticipant.deleteMany(), db.clockReconciliationParticipant.deleteMany(),
db.clockSuspension.deleteMany(), db.clockSuspension.deleteMany(),
@@ -65,6 +67,66 @@ describeIntegration('durable clock reconciliation', () => {
await clean(); await clean();
}); });
it('accepts partial starts while rejecting incomplete and off-boundary DB windows', async () => {
const row = await db.worldState.create({
data: {
scenarioCode: 'constraint',
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockRecoveryStartTick: 1n,
clockRecoveryEndTick: BigInt(T),
clockRecoveryStartWallAt: new Date(),
},
});
for (const data of [
{ clockRecoveryStartWallAt: null },
{ clockRecoveryEndTick: BigInt(T + 1) },
{ clockRecoveryStartTick: BigInt(T) },
{ clockRecoveryStartTick: 0n, clockRecoveryEndTick: BigInt(25 * T) },
]) {
await expect(db.worldState.update({ where: { id: row.id }, data })).rejects.toThrow(
'world_state_turn_recovery_window_check'
);
}
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).clockRecoveryStartTick).toBe(1n);
});
it.each([300, 420])('applies startup recovery after %i seconds on a 60-minute server', async (delay) => {
const profile = 'short-startup';
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: BigInt(T / 6),
clockWallAnchor: new Date(Date.now() - delay * 1000),
clockMode: 'realtime',
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
lastTurnTick: 0n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = (await lease.acquire())!;
await prepareRealtimeRecovery(db, {
kind: 'DAEMON',
profileName: profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
});
const world = await db.worldState.findFirstOrThrow();
expect(world.clockPhase).toBe(delay < 360 ? 'RUNNING' : 'RECONCILING');
expect(readTurnRecovery(world) === null).toBe(delay < 360);
} finally {
await lease.close();
}
});
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => { it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
const profile = 'recovery-startup'; const profile = 'recovery-startup';
await db.worldState.create({ await db.worldState.create({
@@ -115,7 +177,7 @@ describeIntegration('durable clock reconciliation', () => {
expect(pending.clockPhase).toBe('RECONCILING'); expect(pending.clockPhase).toBe('RECONCILING');
const recoveredWindow = readTurnRecovery(pending)!; const recoveredWindow = readTurnRecovery(pending)!;
expect(recoveredWindow).not.toBeNull(); expect(recoveredWindow).not.toBeNull();
expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 12 : 8) * T); expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 13 : 9) * T);
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false); expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
await applyNextClockProjection({ db, redis: redis.client, workerId: profile }); await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
await lease.markClockReady(); await lease.markClockReady();
@@ -140,7 +202,7 @@ describeIntegration('durable clock reconciliation', () => {
it.each([4, 12, 13, 23, 24])( it.each([4, 12, 13, 23, 24])(
'persists recovery for %i turns and reloads the same normal boundary', 'persists recovery for %i turns and reloads the same normal boundary',
async (turns) => { async (turns) => {
const now = new Date(); const now = new Date(Date.now() + 3_600_000);
await db.worldState.create({ await db.worldState.create({
data: { data: {
scenarioCode: 'turn-recovery', scenarioCode: 'turn-recovery',
@@ -214,6 +276,102 @@ describeIntegration('durable clock reconciliation', () => {
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority }); const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
expect(retry.recovery).toEqual(plan.recovery); expect(retry.recovery).toEqual(plan.recovery);
expect(retry.catchUpTicks).toBe(plan.catchUpTicks); expect(retry.catchUpTicks).toBe(plan.catchUpTicks);
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'retry' })).toBe('IDLE');
const announcements = await db.message.findMany({ where: { mailbox: 9999 } });
expect(announcements).toHaveLength(recovery ? 1 : 0);
if (recovery) {
expect(announcements[0]!.message).toMatchObject({
src: { generalName: '시스템' },
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: reloaded.tickToWallDate(recovery.endTick).toISOString(),
},
});
expect(await db.messageAction.count()).toBe(0);
expect(
await db.readModelRevision.findFirst({ where: { domain: 'messages.mailbox', entityId: 9999 } })
).toMatchObject({ revision: 1n });
}
}
);
it.each([359999, 360000, 360001, 840000, 12 * 3600000 + 1000])(
'persists strict recovery boundaries for %i ms',
async (gap) => {
const observed = T / 6;
const future = new Date(Date.now() + 3600000);
await db.worldState.create({
data: {
scenarioCode: 'wait-boundary',
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: BigInt(observed),
clockMode: 'realtime',
clockWallAnchor: future,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const authority = { kind: 'OFFLINE' as const, profileName: 'wait-boundary', reason: 'fixture' };
const suspension = await startClockSuspension({
db,
suspensionId: 'wait-boundary',
source: 'MAINTENANCE',
policy: 'RECOVER_TURNS',
authority,
});
const now = new Date(suspension.cutWallAt.getTime() + gap);
const plan = await reconcileClockSuspension({
db,
suspensionId: suspension.suspensionId,
authority,
testResumeWallAt: now,
});
expect(plan.recovery === null).toBe(gap < 360000);
expect(await db.message.count()).toBe(0);
// Redis 장애 후에도 알림은 DB의 RUNNING 전이와 함께 한 번만 저장한다.
await expect(
applyNextClockProjection({
db,
workerId: 'failure',
redis: {
get: (key) => redis.client.get(key),
eval: async (script, options) => {
await redis.client.eval(script, options);
throw new Error('fixture Redis outage');
},
},
})
).rejects.toThrow('fixture Redis outage');
expect(await db.message.count()).toBe(0);
await db.clockProjectionOutbox.updateMany({ data: { availableAt: new Date(0) } });
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'retry' })).toBe('RECOVERED');
const row = await db.worldState.findFirstOrThrow();
const recovery = readTurnRecovery(row);
expect(await db.message.count()).toBe(recovery ? 1 : 0);
const clock = new GameClock({
baseTime: row.clockBaseTime!,
tick: Number(row.clockTick),
wallAnchor: row.clockWallAnchor!,
turnSeconds: row.tickSeconds,
mode: 'realtime',
recovery,
});
if (recovery) {
expect(row.clockWallAnchor).toEqual(recovery.startWallAt);
expect(clock.nowTick(now)).toBe(observed + plan.shiftTicks);
expect(clock.nowTick(new Date(recovery.startWallAt.getTime() - 1))).toBe(observed + plan.shiftTicks);
const end = clock.tickToWallDate(recovery.endTick);
expect(clock.nowTick(end)).toBe(clock.normalNowTick(end));
} else {
expect(clock.nowTick(now)).toBe(observed + gap * 10);
}
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'done' })).toBe('IDLE');
expect(await db.message.count()).toBe(recovery ? 1 : 0);
} }
); );
@@ -387,6 +387,69 @@ integration('database command queue', () => {
} }
); );
it.each(['SUSPENDED', 'COMPLETED', 'RECONCILING'])(
'admits only participation commands while the clock is %s',
async (phase) => {
await db.worldState.updateMany({ data: { clockPhase: phase } });
const payloads: TurnDaemonCommand[] = [
{
type: 'joinCreateGeneral',
userId: 'visitor',
ownerDisplayName: '방문자',
seedOwnerIdentity: 'visitor',
name: '방문',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'che_안전',
profileId: 'che',
},
{
type: 'npcPossessGeneral',
userId: 'visitor',
ownerDisplayName: '방문자',
profileId: 'che',
generalId: 7,
tokenNonce: 1,
},
{ type: 'selectPoolReserve', userId: 'visitor', seedOwnerIdentity: 'visitor' },
{
type: 'selectPoolCreate',
userId: 'visitor',
ownerDisplayName: '방문자',
uniqueName: '후보',
personality: 'che_안전',
seedOwnerIdentity: 'visitor',
},
{ type: 'selectPoolReselect', userId: 'visitor', ownerDisplayName: '방문자', uniqueName: '후보' },
{ type: 'vacation', userId: 'visitor', generalId: 7 },
];
const types = payloads.map((payload) => payload.type);
await db.inputEvent.createMany({
data: payloads.map((payload) => ({
requestId: `integration:engine:participation:${payload.type}`,
target: 'ENGINE',
eventType: payload.type,
actorUserId: 'visitor',
payload: payload as GamePrisma.InputJsonValue,
})),
});
const commands = await new DatabaseTurnDaemonCommandQueue(db).drain();
expect(commands.map((command) => command.type)).toEqual(phase === 'RECONCILING' ? [] : types.slice(0, -1));
const pending = await db.inputEvent.findUniqueOrThrow({
where: { requestId: 'integration:engine:participation:vacation' },
});
expect(pending).toMatchObject({ status: 'PENDING', attempts: 0 });
expect(await db.worldState.findFirst()).toMatchObject({
clockPhase: phase,
clockTick: 123n,
clockRevision: 1n,
deadlineGeneration: 1n,
});
}
);
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => { it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld const world = existingWorld
@@ -112,6 +112,7 @@ integration('gateway runtime action consumer', () => {
}); });
it('does not overwrite a terminal operator status while reporting a daemon error', async () => { it('does not overwrite a terminal operator status while reporting a daemon error', async () => {
const existingIncidents = await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } });
const gate = await createGatewayProfileGate({ const gate = await createGatewayProfileGate({
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
gatewayDatabaseUrl: databaseUrl!, gatewayDatabaseUrl: databaseUrl!,
@@ -127,6 +128,19 @@ integration('gateway runtime action consumer', () => {
status: 'PAUSED', status: 'PAUSED',
lastError: 'running failure', lastError: 'running failure',
}); });
await gate.markPaused(new Error('running failure'));
const incidents = await db.adminAuditEvent.findMany({ where: { profileName, action: 'runtime.failure' } });
expect(incidents).toHaveLength(existingIncidents + 1);
expect(incidents[0]).toMatchObject({
credentialKind: 'DAEMON',
errorCode: 'Error',
errorMessage: 'running failure',
});
await db.gatewayProfile.update({ where: { profileName }, data: { status: 'RUNNING', lastError: null } });
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
existingIncidents + 1
);
await db.gatewayProfile.update({ await db.gatewayProfile.update({
where: { profileName }, where: { profileName },
@@ -137,6 +151,9 @@ integration('gateway runtime action consumer', () => {
status: 'STOPPED', status: 'STOPPED',
lastError: null, lastError: null,
}); });
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
existingIncidents + 1
);
} finally { } finally {
await gate.close(); await gate.close();
} }
@@ -337,6 +337,62 @@ describe('legacy general turn lifecycle', () => {
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
}); });
it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => {
const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } });
const troopLeader = makeGeneral({
id: 2,
userId: null,
npcState: 5,
officerLevel: 11,
troopId: 2,
turnTime: new Date(start.getTime() + 3_600_000),
meta: { killturn: 24, officer_city: 1, belong: 5, permission: 'ambassador' },
});
const snapshot = makeSnapshot([leader, troopLeader]);
snapshot.cities[0]!.conflict = { '1': 1 };
const harness = await createTurnTestHarness({ snapshot, state: makeState(), schedule, map });
await harness.runOneTick();
expect(harness.world.getGeneralById(1)).toBeNull();
expect(harness.world.getNationById(1)).toBeNull();
expect(harness.world.getCityById(1)).toMatchObject({ nationId: 0, frontState: 0, conflict: [] });
expect(harness.world.getGeneralById(2)).toMatchObject({
nationId: 0,
officerLevel: 0,
troopId: 0,
gold: troopLeader.gold,
rice: troopLeader.rice,
meta: { officer_city: 0, officerCity: 0, belong: 0, permission: 'normal' },
});
const dirty = harness.world.peekDirtyState();
expect(dirty.deletedNations).toContain(1);
expect(dirty.deletedNationSnapshots[0]?.generalIds).toEqual([1, 2]);
expect(dirty.logs.filter((log) => log.text.includes('【멸망】'))).toHaveLength(1);
});
it('repairs an already orphaned nation once but refuses a nation with a successor', async () => {
const troopLeader = makeGeneral({ id: 2, userId: null, npcState: 5, officerLevel: 11 });
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([troopLeader]),
state: makeState(),
schedule,
map,
});
expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(true);
expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(false);
expect(harness.world.getGeneralById(2)?.nationId).toBe(0);
const intact = await createTurnTestHarness({
snapshot: makeSnapshot([makeGeneral({ officerLevel: 9 })]),
state: makeState(),
schedule,
map,
});
expect(() => intact.world.dissolveNationWithoutSuccessor(1)).toThrow('still has a ruler or successor');
expect(intact.world.getCityById(1)?.nationId).toBe(1);
expect(intact.world.getNationById(1)).not.toBeNull();
});
it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => { it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => {
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot([ snapshot: makeSnapshot([
@@ -1360,4 +1360,33 @@ integration('general turn lifecycle persistence', () => {
await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0); await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0);
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0); await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0);
}); });
it('preserves all finalized settlements and archives even after an invader resume or NPC ownership change', async () => {
await db.gameHistory.update({ where: { serverId }, data: { status: 'COMPLETED' } });
const readRecords = async () => ({
hall: await db.hallOfFame.findMany({ where: { serverId }, orderBy: { id: 'asc' } }),
points: await db.inheritancePoint.findMany({ where: { userId: { in: userIds } }, orderBy: { id: 'asc' } }),
results: await db.inheritanceResult.findMany({ where: { serverId }, orderBy: { id: 'asc' } }),
logs: await db.inheritanceLog.findMany({ where: { userId: { in: userIds } }, orderBy: { id: 'asc' } }),
archives: await db.oldGeneral.findMany({ where: { serverId }, orderBy: { id: 'asc' } }),
});
const before = await readRecords();
for (const united of [1, 2, 3, 0]) {
const general = makeGeneral(generalIds[0]!, userIds[1]!, {
experience: 999999,
meta: { killturn: 0, inheritRandomUnique: true, inherit_active_action: 999999 },
});
await db.$transaction(async (transaction) => {
await persistGeneralLifecycleEvents(
transaction,
[
{ ...event(general, 'retired'), isUnitedAtEvent: united },
{ ...event(general, 'deleted'), isUnitedAtEvent: united },
],
{ serverId, isUnited: united },
{}
);
});
expect(await readRecords()).toEqual(before);
}
});
}); });
@@ -51,6 +51,7 @@ describe('general lifecycle archive history', () => {
const general = archivedGeneral(); const general = archivedGeneral();
const upsert = vi.fn(async () => undefined); const upsert = vi.fn(async () => undefined);
const prisma = { const prisma = {
gameHistory: { findUnique: vi.fn(async () => ({ status: 'OPEN' })) },
generalAccessLog: { generalAccessLog: {
updateMany: vi.fn(async () => ({ count: 1 })), updateMany: vi.fn(async () => ({ count: 1 })),
deleteMany: vi.fn(async () => ({ count: 1 })), deleteMany: vi.fn(async () => ({ count: 1 })),
@@ -146,6 +147,7 @@ describe('general lifecycle archive history', () => {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
}, },
gameHistory: { gameHistory: {
findUnique: vi.fn(async () => ({ status: 'OPEN' })),
count: vi.fn(async () => 99), count: vi.fn(async () => 99),
}, },
hallOfFame: { hallOfFame: {
@@ -4,6 +4,8 @@ import { SystemClock } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js'; import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -17,9 +19,9 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const worldId = 991_731; const worldId = 991_731;
const generalId = 991_731; const generalId = 731;
const cityId = 991_731; const cityId = 731;
const existingNationId = 991_730; const existingNationId = 730;
const requestId = 'integration:engine:immediate-action-uprising'; const requestId = 'integration:engine:immediate-action-uprising';
const occupiedUniqueItem = 'che_무기_12_칠성검'; const occupiedUniqueItem = 'che_무기_12_칠성검';
@@ -179,7 +181,8 @@ integration('immediate general action persistence', () => {
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }], OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
}, },
}); });
await db.general.deleteMany({ where: { id: generalId } }); await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
await db.city.deleteMany({ where: { id: cityId } }); await db.city.deleteMany({ where: { id: cityId } });
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } }); await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
await db.worldState.deleteMany({ where: { id: worldId } }); await db.worldState.deleteMany({ where: { id: worldId } });
@@ -288,13 +291,193 @@ integration('immediate general action persistence', () => {
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }], OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
}, },
}); });
await db.general.deleteMany({ where: { id: generalId } }); await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
await db.city.deleteMany({ where: { id: cityId } }); await db.city.deleteMany({ where: { id: cityId } });
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } }); await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
await db.worldState.deleteMany({ where: { id: worldId } }); await db.worldState.deleteMany({ where: { id: worldId } });
await disconnect?.(); await disconnect?.();
}); });
it.each([
'normal',
'resigned',
'transferred',
'deleted',
'ruler',
'collapsed',
'legacyCollapsed',
'random',
] as const)('persists the recruitment-letter lifecycle: %s', async (mode) => {
const recruiterId = generalId + 1;
await db.general.create({
data: {
id: recruiterId,
name: '권유자',
meta: { killturn: 24 },
nationId: existingNationId,
officerLevel: 1,
cityId,
turnTime: general.turnTime,
},
});
await db.nation.update({
where: { id: existingNationId },
data: { capitalCityId: cityId, meta: { gennum: 1 } },
});
await db.city.update({ where: { id: cityId }, data: { nationId: existingNationId } });
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const handler = createTurnDaemonCommandHandler({ world, scenarioMeta, map });
const flush = () =>
hooks.hooks.flushChanges!({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
try {
const draft = buildScoutMessageDraft({
srcGeneral: world.getGeneralById(recruiterId)!,
destGeneral: world.getGeneralById(generalId)!,
srcNation: world.getNationById(existingNationId),
destNation: null,
time: world.gameTickToDate(0),
});
expect(draft).not.toBeNull();
world.queueMessage(draft!);
await flush();
const letter = await db.message.findFirstOrThrow({
where: { mailbox: generalId },
include: { action: true },
});
expect(letter.action?.status).toBe('PENDING');
const envelopeWallTime = letter.createdAtWall;
if (mode === 'collapsed') {
world.queueMessage({
...draft!,
src: { ...draft!.src, nationId: existingNationId + 10 },
text: '다른 국가의 등용장',
});
world.queueMessage({ ...draft!, option: {}, text: '일반 서신' });
await flush();
}
while (hooks.takeCommittedReadModelChangeReceipt()) {
/* discard setup receipts */
}
if (mode === 'resigned' || mode === 'transferred') {
world.updateGeneral(recruiterId, { nationId: mode === 'resigned' ? 0 : existingNationId + 10 });
} else if (mode === 'deleted') {
world.removeGeneral(recruiterId);
} else if (mode === 'ruler') {
world.updateGeneral(generalId, { nationId: existingNationId + 10, officerLevel: 12 });
} else if (mode === 'random') {
world.updateWorldConfig({ joinMode: 'onlyRandom' });
} else if (mode === 'collapsed' || mode === 'legacyCollapsed') {
world.removeNation(existingNationId);
}
if (mode === 'collapsed') {
// Force failure after nation deletion: the envelope/action and nation must roll back together.
await db.$executeRawUnsafe(
"ALTER TABLE message_action ADD CONSTRAINT scout_test_pending CHECK (status = 'PENDING')"
);
await expect(flush()).rejects.toThrow();
expect(await db.nation.findUnique({ where: { id: existingNationId } })).not.toBeNull();
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
'PENDING'
);
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT scout_test_pending');
}
await flush();
if (mode === 'legacyCollapsed') {
// Simulate an old deployment which deleted the nation but left this action pending.
await db.messageAction.update({
where: { messageId: letter.id },
data: { status: 'PENDING', resolvedGameTick: null },
});
}
const currentLetter = await db.message.findUniqueOrThrow({
where: { id: letter.id },
include: { action: true },
});
expect(currentLetter.createdAtWall).toEqual(envelopeWallTime);
expect(currentLetter.action?.status).toBe(mode === 'collapsed' ? 'RESOLVED' : 'PENDING');
if (mode === 'collapsed') {
expect(currentLetter.validUntilTick).not.toBeNull();
const receipt = hooks.takeCommittedReadModelChangeReceipt();
expect(receipt?.invalidation.revisions).toContainEqual(
expect.objectContaining({
domain: 'messages.mailbox',
entityId: generalId,
})
);
const controls = await db.message.findMany({
where: { mailbox: generalId, id: { not: letter.id } },
include: { action: true },
orderBy: { id: 'asc' },
});
expect(controls.map((entry) => entry.action?.status ?? null)).toEqual(['PENDING', null]);
expect(controls.every((entry) => entry.validUntil.getUTCFullYear() === 9999)).toBe(true);
}
const actionRequestId = `${requestId}:scout:${mode}`;
const payload = {
type: 'messageRespond' as const,
requestId: actionRequestId,
userId: general.userId!,
generalId,
messageId: letter.id,
response: true,
};
await db.inputEvent.create({
data: {
requestId: actionRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: general.userId,
payload,
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const commands = await queue.drain();
expect(commands).toHaveLength(1);
const result = await hooks.hooks.executeCommand!(actionRequestId, async (ctx) => {
const value = await handler.handle(commands[0]!, ctx);
if (!value) throw new Error('missing message response');
return value;
});
if (mode === 'legacyCollapsed') {
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toContainEqual(
expect.objectContaining({ domain: 'messages.mailbox', entityId: generalId })
);
}
const accepted = ['normal', 'resigned', 'transferred', 'deleted'].includes(mode);
expect(result).toMatchObject(
accepted ? { ok: true, reason: 'success' } : { reason: expect.not.stringMatching(/^success$/) }
);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find(({ id }) => id === generalId)).toMatchObject({
nationId: accepted ? existingNationId : mode === 'ruler' ? existingNationId + 10 : 0,
officerLevel: accepted ? 1 : mode === 'ruler' ? 12 : 0,
});
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
mode === 'ruler' || mode === 'random' ? 'PENDING' : 'RESOLVED'
);
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId: actionRequestId } })).status).toBe(
'SUCCEEDED'
);
expect(await queue.drain()).toEqual([]);
} finally {
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT IF EXISTS scout_test_pending');
await hooks.close();
}
});
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => { it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
const snapshot: TurnWorldSnapshot = { const snapshot: TurnWorldSnapshot = {
generals: [general], generals: [general],
@@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => {
await hooks.close(); await hooks.close();
} }
}); });
it('atomically repairs a rulerless nation with only troop NPCs and preserves its archive', async () => {
await cleanup();
const nation = buildNation(nationIds[1]!, '방랑국', 3, 0);
const city = buildCity(cityIds[0]!, '방랑성', nation.id);
const general = buildGeneral({
id: generalIds[0]!,
name: '부대장',
nationId: nation.id,
cityId: city.id,
officerLevel: 11,
npcState: 5,
gold: 2345,
rice: 6789,
belong: 5,
turnTime: new Date('0195-01-01T00:05:00.000Z'),
});
await db.nation.create({
data: {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
typeCode: nation.typeCode,
chiefGeneralId: 12345,
},
});
await db.city.create({
data: {
id: city.id,
name: city.name,
level: city.level,
nationId: nation.id,
region: 1,
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
frontState: 1,
},
});
await db.general.create({
data: {
id: general.id,
name: general.name,
nationId: nation.id,
cityId: city.id,
officerLevel: 11,
npcState: 5,
gold: general.gold,
rice: general.rice,
turnTime: general.turnTime,
},
});
await db.nationTurn.create({
data: { nationId: nation.id, officerLevel: 12, turnIdx: 0, actionCode: '휴식', arg: {} },
});
const row = await db.worldState.create({
data: {
scenarioCode,
currentYear: 195,
currentMonth: 1,
tickSeconds: 600,
meta: { serverId },
},
});
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
};
const world = new InMemoryTurnWorld(
{
id: row.id,
currentYear: 195,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0195-01-01T00:00:00.000Z'),
meta: { serverId },
},
{
scenarioConfig,
scenarioMeta: {
title: 'test',
startYear: 193,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: { id: 'test', name: 'test', cities: [] },
nations: [nation],
cities: [city],
generals: [general],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
},
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
world.dissolveNationWithoutSuccessor(nation.id);
const result = {
lastTurnTime: world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
};
// A stale clock fence must leave the entire repair uncommitted and retryable.
await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 2 } });
await expect(hooks.hooks.flushChanges!(result)).rejects.toThrow('Game clock fence changed');
expect(await db.nation.count({ where: { id: nation.id } })).toBe(1);
expect((await db.general.findUniqueOrThrow({ where: { id: general.id } })).nationId).toBe(nation.id);
expect(await db.oldNation.count({ where: { serverId } })).toBe(0);
await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 1 } });
await hooks.hooks.flushChanges!(result);
expect(await db.nation.findUnique({ where: { id: nation.id } })).toBeNull();
expect(await db.city.findUniqueOrThrow({ where: { id: city.id } })).toMatchObject({
nationId: 0,
frontState: 0,
});
expect(await db.general.findUniqueOrThrow({ where: { id: general.id } })).toMatchObject({
nationId: 0,
officerLevel: 0,
gold: 2345,
rice: 6789,
});
expect(await db.nationTurn.count({ where: { nationId: nation.id } })).toBe(0);
const archive = await db.oldNation.findUniqueOrThrow({
where: { serverId_nation_sourceId: { serverId, nation: nation.id, sourceId: 0 } },
});
expect(archive.data).toMatchObject({ nation: nation.id, generals: [general.id] });
const logCount = await db.logEntry.count({ where: { text: { contains: '방랑국' } } });
expect(logCount).toBe(3);
expect(world.dissolveNationWithoutSuccessor(nation.id)).toBe(false);
await hooks.hooks.flushChanges!(result);
expect(await db.logEntry.count({ where: { text: { contains: '방랑국' } } })).toBe(logCount);
} finally {
await hooks.close();
}
});
}); });
@@ -600,102 +600,126 @@ describe('my information world commands', () => {
expect(nextIntInclusive).not.toHaveBeenCalled(); expect(nextIntInclusive).not.toHaveBeenCalled();
}); });
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => { it.each([
const originalLastTurn = { command: '전투태세', arg: { term: 3 } }; { recruiterNationId: 2, ruler: false },
const recipient = buildGeneral({ { recruiterNationId: 0, ruler: false },
id: 8, { recruiterNationId: 3, ruler: false },
userId: 'user-8', { recruiterNationId: 2, ruler: true },
name: '재야장수', ])(
nationId: 0, 'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
cityId: 1, async ({ recruiterNationId, ruler }) => {
officerLevel: 0, const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
lastTurn: originalLastTurn, const recipient = buildGeneral({
}); id: 8,
const recruiter = buildGeneral({ userId: 'user-8',
id: 9, name: '재야장수',
userId: 'user-9', nationId: 0,
name: '등용장수', cityId: 1,
nationId: 2, officerLevel: 0,
cityId: 2, lastTurn: originalLastTurn,
}); });
const map = { const recruiter = buildGeneral({
id: 'test', id: 9,
name: 'test', userId: 'user-9',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])], name: '등용장수',
}; nationId: 2,
const fixture = buildImmediateActionWorld({ cityId: 2,
general: recipient, });
additionalGenerals: [recruiter], const map = {
cities: [ id: 'test',
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} }, name: 'test',
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} }, cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
] as TurnWorldSnapshot['cities'], };
nations: [ const fixture = buildImmediateActionWorld({
{ general: recipient,
id: 2, additionalGenerals: [recruiter],
name: '등용국', cities: [
color: '#222222', { id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
typeCode: 'che_중립', { id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
level: 1, ] as TurnWorldSnapshot['cities'],
capitalCityId: 2, nations: [
chiefGeneralId: recruiter.id, {
gold: 0, id: 2,
rice: 0, name: '등용국',
power: 0, color: '#222222',
meta: { gennum: 1 }, typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: recruiter.id,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
},
] as TurnWorldSnapshot['nations'],
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: {
general: ['che_등용'],
nation: [],
}, },
] as TurnWorldSnapshot['nations'], });
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: {
general: ['che_등용'],
nation: [],
},
});
await expect( fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
executor.execute({ if (ruler) {
actionKey: 'che_등용수락', fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
generalId: recipient.id, const before = fixture.world.captureState();
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')), await expect(
args: { destNationId: 2, destGeneralId: recruiter.id }, executor.execute({
}) actionKey: 'che_등용수락',
).resolves.toEqual({ ok: true }); generalId: recipient.id,
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({ rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
nationId: 2, args: { destNationId: 2, destGeneralId: recruiter.id },
cityId: 2, })
officerLevel: 1, ).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
lastTurn: originalLastTurn, expect(fixture.world.captureState()).toEqual(before);
}); return;
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({ }
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100, await expect(
}); executor.execute({
const actionLogs = fixture.world actionKey: 'che_등용수락',
.consumeDirtyState() generalId: recipient.id,
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION); rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
expect(actionLogs.map((log) => log.text)).toEqual([ args: { destNationId: 2, destGeneralId: recruiter.id },
expect.stringContaining('레벨업'), })
expect.stringContaining('승급'), ).resolves.toEqual({ ok: true });
expect.stringContaining('망명하여 수도로'), expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
expect.stringContaining('레벨업'), nationId: 2,
expect.stringContaining('승급'), cityId: 2,
expect.stringContaining('등용에 성공했습니다.'), officerLevel: 1,
]); lastTurn: originalLastTurn,
expect(actionLogs.map((log) => log.format)).toEqual([ });
LogFormat.PLAIN, expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
LogFormat.PLAIN, experience: recruiter.experience + 100,
LogFormat.MONTH, dedication: recruiter.dedication + 100,
LogFormat.PLAIN, });
LogFormat.PLAIN, const actionLogs = fixture.world
LogFormat.MONTH, .consumeDirtyState()
]); .logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
}); expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
}
);
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => { it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
const recipient = buildGeneral({ const recipient = buildGeneral({
@@ -105,6 +105,8 @@ describe('NPC 일반 내정 턴', () => {
{ {
id: 1, id: 1,
name: 'NPC_무장', name: 'NPC_무장',
picture: '롤시나리오/다이애나.png',
imageServer: 0,
nationId: 1, nationId: 1,
cityId: 1, cityId: 1,
troopId: 0, troopId: 0,
@@ -297,7 +299,12 @@ describe('NPC 일반 내정 턴', () => {
expect.objectContaining({ expect.objectContaining({
msgType: 'public', msgType: 'public',
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다', text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }), src: expect.objectContaining({
generalId: 1,
generalName: 'NPC_무장',
nationId: 1,
icon: 'https://sam-image.hided.net/icons/롤시나리오/다이애나.png',
}),
time: logicalGameNow, time: logicalGameNow,
}) })
); );
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common'; import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
await db.$executeRawUnsafe(` await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox ALTER TABLE read_model_outbox
ADD CONSTRAINT ${rollbackConstraint} ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1) CHECK ((payload->>'version')::integer <> 1) NOT VALID
`); `);
world.updateWorldMeta({ durableFixture: 'must-rollback' }); world.updateWorldMeta({ durableFixture: 'must-rollback' });
world.pushLog({ world.pushLog({
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true); expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull(); expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.readModelOutbox.count()).resolves.toBe(5); await expect(db.readModelOutbox.count()).resolves.toBe(5);
await db.inputEvent.update({
where: { requestId },
data: { eventType: 'voteReward', status: 'PROCESSING', result: GamePrisma.DbNull },
});
const voteResult: TurnDaemonCommandResult = {
type: 'voteReward',
ok: true,
voteId: 1,
generalId: directLogGeneralId,
awardedUnique: false,
};
await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1) NOT VALID
`);
await expect(hooks.hooks.executeCommand?.(requestId, async () => voteResult)).rejects.toThrow(
rollbackConstraint
);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'PROCESSING',
result: null,
});
await expect(db.readModelOutbox.count()).resolves.toBe(5);
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT ${rollbackConstraint}`);
await hooks.hooks.executeCommand?.(requestId, async () => voteResult);
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toEqual([
{ domain: 'front.general', entityId: directLogGeneralId, revision: 1n },
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: voteResult,
});
await expect(db.readModelOutbox.count()).resolves.toBe(6);
}); });
}); });
@@ -16,6 +16,34 @@ import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js'; import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
describe('durable read-model change journal mapping', () => { describe('durable read-model change journal mapping', () => {
it.each([false, true])(
'invalidates only the voting actor on successful vote completion (replay=%s)',
(alreadyApplied) => {
expect(
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
type: 'voteReward',
ok: true,
voteId: 1,
generalId: 7,
awardedUnique: false,
alreadyApplied,
}).snapshot()
).toEqual([{ domain: 'front.general', entityId: 7 }]);
}
);
it('does not publish vote completion for a rejected vote', () => {
expect(
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
type: 'voteReward',
ok: false,
voteId: 1,
generalId: 7,
reason: 'closed',
}).snapshot()
).toEqual([]);
});
it('maps every final engine invalidation to its precise durable domain', () => { it('maps every final engine invalidation to its precise durable domain', () => {
const changes = { const changes = {
...createEmptyRealtimeReadModelChanges(), ...createEmptyRealtimeReadModelChanges(),
@@ -1,3 +1,4 @@
import { GameClock } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra'; import { createGamePostgresConnector } from '@sammo-ts/infra';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
@@ -94,6 +95,99 @@ const canRun = await canConnectToDatabase(databaseUrl);
const describeDb = describe.runIf(canRun); const describeDb = describe.runIf(canRun);
describeDb('scenario database seed', () => { describeDb('scenario database seed', () => {
test.each([
{ sync: true, turnMinutes: 60, hour: 10, month: 10, yearOffset: -1 },
{ sync: true, turnMinutes: 60, hour: 1, month: 1, yearOffset: 0 },
{ sync: true, turnMinutes: 60, hour: 13, month: 1, yearOffset: 0 },
{ sync: false, turnMinutes: 60, hour: 10, month: 1, yearOffset: 0 },
{ sync: true, turnMinutes: 5, hour: 10, month: 7, yearOffset: -1 },
])(
'preserves exact opening and its calendar: $sync / $hour / $turnMinutes',
async ({ sync, hour, month, yearOffset, turnMinutes }) => {
const openAt = new Date(2030, 0, 1, hour, 30, 15, 123);
const preopenAt = new Date(openAt.getTime() - 90 * 60_000);
const scenario = await loadScenarioDefinitionById(scenarioId);
await seedScenarioToDatabase({
scenarioId,
databaseUrl,
resetTables: true,
now: preopenAt,
wallNow: preopenAt,
installOptions: { sync, turnTermMinutes: turnMinutes, preopenAt, openAt },
});
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
currentYear: (scenario.startYear ?? 0) + yearOffset,
currentMonth: month,
clockBaseTime: openAt,
clockWallAnchor: openAt,
clockTick: 0n,
lastTurnTick: 0n,
clockPhase: 'PREOPEN',
});
const clock = new GameClock({
baseTime: world.clockBaseTime!,
wallAnchor: world.clockWallAnchor!,
tick: Number(world.clockTick),
mode: 'realtime',
phase: 'PREOPEN',
turnSeconds: world.tickSeconds,
});
expect(clock.nowTick(new Date(openAt.getTime() - 1))).toBeLessThan(0);
expect(clock.nowTick(openAt)).toBe(0);
expect(clock.tickToDate(clock.nowTick(preopenAt))).toEqual(preopenAt);
const afterOpening = new Date(openAt.getTime() + turnMinutes * 60_000);
expect(clock.tickToDate(clock.nowTick(afterOpening))).toEqual(afterOpening);
expect(clock.nowTick(afterOpening)).toBe(36_000_000);
const generals = await connector.prisma.general.findMany({
select: { turnTick: true, turnTime: true },
});
expect(generals.length).toBeGreaterThan(0);
for (const general of generals) {
expect(general.turnTick).toBeGreaterThanOrEqual(0n);
expect(general.turnTime.getTime()).toBeGreaterThanOrEqual(openAt.getTime());
}
} finally {
await connector.disconnect();
}
}
);
test.each([false, true])(
'starts immediately without rounding when the opening is absent or late: %s',
async (late) => {
const wallNow = new Date(2030, 0, 1, 10, 30, 15, 123);
await seedScenarioToDatabase({
scenarioId,
databaseUrl,
resetTables: true,
now: new Date(2030, 0, 1, 1, 0),
wallNow,
installOptions: {
sync: true,
turnTermMinutes: 60,
openAt: late ? new Date(2030, 0, 1, 9, 0) : null,
},
});
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
await expect(connector.prisma.worldState.findFirstOrThrow()).resolves.toMatchObject({
clockBaseTime: wallNow,
clockWallAnchor: wallNow,
clockPhase: 'RUNNING',
clockTick: 0n,
currentMonth: 10,
});
} finally {
await connector.disconnect();
}
}
);
test('persists each blank-land scenario item contract without leaking the shared addon', async () => { test('persists each blank-land scenario item contract without leaking the shared addon', async () => {
const readPersistedItemContract = async (targetScenarioId: number) => { const readPersistedItemContract = async (targetScenarioId: number) => {
const { applied } = await seedScenarioToDatabase({ const { applied } = await seedScenarioToDatabase({
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common'; import { ManualClock } from '@sammo-ts/common';
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
import { import {
InMemoryControlQueue, InMemoryControlQueue,
@@ -14,6 +15,39 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => { describe('TurnDaemonLifecycle', () => {
it.each([false, true])('reports a fatal lease gate and exits even if reporting fails (%s)', async (reportFails) => {
const now = new Date('2026-09-09T17:30:00Z');
const error = new TurnDaemonLeaseLostError('che:default');
const processor = { run: vi.fn() };
const onRunError = vi.fn(async () => {
if (reportFails) throw new Error('gateway unavailable');
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: new InMemoryControlQueue(),
processor,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
},
hooks: { onRunError },
pauseGate: async () => {
throw error;
},
},
{ profile: 'che', defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 } }
);
await expect(lifecycle.start()).rejects.toBe(error);
expect(onRunError).toHaveBeenCalledExactlyOnceWith(error);
expect(processor.run).not.toHaveBeenCalled();
expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message });
});
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)( it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)(
'does not dispatch an explicit run while the clock phase is %s', 'does not dispatch an explicit run while the clock phase is %s',
async (phase) => { async (phase) => {
@@ -49,6 +83,36 @@ describe('TurnDaemonLifecycle', () => {
} }
); );
it('holds even an explicit run during the recovery wait', async () => {
const now = new Date('2026-09-07T00:24:00Z');
const startsAt = new Date('2026-09-07T00:35:00Z');
const controlQueue = new InMemoryControlQueue();
controlQueue.enqueue({ type: 'run', reason: 'manual' });
const processor = { run: vi.fn() };
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue,
processor,
getNextTickTime: (value) => addMinutes(value, 60),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => {
controlQueue.enqueue({ type: 'shutdown' });
return { mode: 'realtime', phase: 'RUNNING', now, startsAt };
},
},
},
{ profile: 'recovery-wait-gate', defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 } }
);
await lifecycle.start();
expect(processor.run).not.toHaveBeenCalled();
});
it('durably rebases a long realtime backlog before executing another turn', async () => { it('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z'); const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime()); const clock = new ManualClock(wallNow.getTime());
@@ -464,6 +464,19 @@ integration('unification finalization transaction', () => {
expect(await db.message.count({ where: { mailbox: fixtureId } })).toBe(0); expect(await db.message.count({ where: { mailbox: fixtureId } })).toBe(0);
expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0); expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0);
const assertLateInheritanceIgnored = async () => {
const beforePoints = await db.inheritancePoint.findMany({ where: { userId }, orderBy: { id: 'asc' } });
const beforeLogs = await db.inheritanceLog.count({ where: { userId } });
world.queueInheritancePointAdjustment(userId, 'previous', 999999);
world.queueInheritancePointAdjustment(userId, 'tournament', 100);
world.queueInheritanceLog({ userId, year: 190, month: 7, text: '확정 후 보상은 기록하지 않음' });
await hooks.hooks.flushChanges?.(runResult);
expect(await db.inheritancePoint.findMany({ where: { userId }, orderBy: { id: 'asc' } })).toEqual(
beforePoints
);
expect(await db.inheritanceLog.count({ where: { userId } })).toBe(beforeLogs);
};
await db.gameHistory.create({ await db.gameHistory.create({
data: { data: {
serverId, serverId,
@@ -597,6 +610,7 @@ integration('unification finalization transaction', () => {
}, },
}); });
expect(yearbook.globalHistory).toEqual(expect.arrayContaining([expect.stringContaining('【통일】')])); expect(yearbook.globalHistory).toEqual(expect.arrayContaining([expect.stringContaining('【통일】')]));
await assertLateInheritanceIgnored();
expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0); expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0);
await hooks.hooks.flushChanges?.(runResult); await hooks.hooks.flushChanges?.(runResult);
@@ -772,6 +786,8 @@ integration('unification finalization transaction', () => {
await db.clockProjectionOutbox.findFirstOrThrow({ where: { suspensionId: suspension.id } }) await db.clockProjectionOutbox.findFirstOrThrow({ where: { suspensionId: suspension.id } })
).toMatchObject({ status: 'PENDING', targetRevision: 2n }); ).toMatchObject({ status: 'PENDING', targetRevision: 2n });
await assertLateInheritanceIgnored();
if (process.env.REDIS_URL) { if (process.env.REDIS_URL) {
const redis = createRedisConnector({ url: process.env.REDIS_URL }); const redis = createRedisConnector({ url: process.env.REDIS_URL });
await redis.connect(); await redis.connect();
@@ -176,6 +176,19 @@ describe('persistUnificationFinalization', () => {
expect(transaction.unificationFinalization.create).not.toHaveBeenCalled(); expect(transaction.unificationFinalization.create).not.toHaveBeenCalled();
}); });
it('does not reopen a completed imported season without a finalization generation', async () => {
const transaction = Object.assign({} as GamePrisma.TransactionClient, {
$executeRaw: vi.fn().mockResolvedValue(1),
$queryRaw: vi.fn().mockResolvedValue([]),
gameHistory: { findUnique: vi.fn().mockResolvedValue({ status: 'COMPLETED' }) },
unificationFinalization: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn() },
});
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toMatchObject({
status: 'ALREADY_APPLIED',
});
expect(transaction.unificationFinalization.create).not.toHaveBeenCalled();
});
it('uses one supplied transaction for absolute inheritance and archive writes', async () => { it('uses one supplied transaction for absolute inheritance and archive writes', async () => {
const inheritanceUpsert = vi.fn().mockResolvedValue({}); const inheritanceUpsert = vi.fn().mockResolvedValue({});
const inheritanceResultCreate = vi.fn().mockResolvedValue({}); const inheritanceResultCreate = vi.fn().mockResolvedValue({});
@@ -216,7 +229,11 @@ describe('persistUnificationFinalization', () => {
{ generalId: 1, type: 'ttl', value: 1 }, { generalId: 1, type: 'ttl', value: 1 },
]), ]),
}, },
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate }, gameHistory: {
findUnique: vi.fn().mockResolvedValue({ status: 'OPEN' }),
count: vi.fn().mockResolvedValue(1),
update: gameHistoryUpdate,
},
hallOfFame: { hallOfFame: {
findMany: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]),
create: hallCreate, create: hallCreate,
+246 -4
View File
@@ -840,12 +840,23 @@ const install = async (
page: Page, page: Page,
rejectGeneral = false, rejectGeneral = false,
commandTableResponse: unknown = commandTable, commandTableResponse: unknown = commandTable,
generalId = 1 generalId = 1,
recoveryClock?: {
serverTime: string;
serverWallTime: string;
clockRunning: boolean;
clockRecovery: { startsAt: string; endsAt: string } | null;
turnEngineRunning: boolean;
}
) => { ) => {
const requests: unknown[] = []; const requests: unknown[] = [];
const currentGeneralContext = { const currentGeneralContext = {
...generalContext, ...generalContext,
general: { ...generalContext.general, id: generalId }, general: {
...generalContext.general,
id: generalId,
...(recoveryClock ? { turnTime: '2026-09-10T01:20:00Z' } : {}),
},
}; };
const generalTurns = turns(30); const generalTurns = turns(30);
const nationTurns = turns(12); const nationTurns = turns(12);
@@ -929,7 +940,8 @@ const install = async (
myGeneral: { id: generalId, name: '장수' }, myGeneral: { id: generalId, name: '장수' },
year: 200, year: 200,
month: 1, month: 1,
turnTerm: 10, turnTerm: recoveryClock ? 60 : 10,
...recoveryClock,
userCnt: 1, userCnt: 1,
maxUserCnt: 100, maxUserCnt: 100,
npcCnt: 0, npcCnt: 0,
@@ -959,7 +971,19 @@ const install = async (
}); });
} }
if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'turns.getCommandTable') return response(commandTableResponse);
if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'nation.getChiefCenter')
return response(
recoveryClock
? {
...chiefCenter,
turnTermMinutes: 60,
chiefs: chiefCenter.chiefs.map((chief) => ({
...chief,
turnTime: '2026-09-10T01:20:00Z',
})),
}
: chiefCenter
);
if (name === 'turns.reserved.getGeneral') if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 }); return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
@@ -987,6 +1011,20 @@ const install = async (
if (name === 'messages.getContacts') return response({ nation: [] }); if (name === 'messages.getContacts') return response({ nation: [] });
if (name === 'board.getAccess') return response({ canMeeting: false, canSecret: false }); if (name === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
if (name === 'tournament.getState') return response({ stage: 0 }); if (name === 'tournament.getState') return response({ stage: 0 });
if (name === 'turns.reserved.shiftGeneral') {
const input = (body as Record<string, { generalId: number; amount: number; expectedRevision: number }>)[
String(names.indexOf(name))
];
expect(route.request().method()).toBe('POST');
expect(input.generalId).toBe(generalId);
expect(input.expectedRevision).toBe(generalRevision);
expect(
Number.isInteger(input.amount) && Math.abs(input.amount) >= 1 && Math.abs(input.amount) <= 6
).toBe(true);
requests.push(input);
generalRevision += 1;
return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
}
if (name === 'turns.reserved.setGeneralBulk') { if (name === 'turns.reserved.setGeneralBulk') {
requests.push(body); requests.push(body);
if (rejectGeneral) return errorResponse(name, '대상 도시를 선택할 수 없습니다.'); if (rejectGeneral) return errorResponse(name, '대상 도시를 선택할 수 없습니다.');
@@ -1019,6 +1057,80 @@ const install = async (
return requests; return requests;
}; };
for (const width of [1200, 500]) {
test(`split turn shift applies one turn or the chosen count in both modes at ${width}px`, async ({
page,
}, testInfo) => {
const requests = await install(page);
const shifts = () =>
requests.filter((entry) => typeof entry === 'object' && entry !== null && 'amount' in entry);
await page.setViewportSize({ width, height: 900 });
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]:visible');
const evidence: unknown[] = [];
for (const advanced of [false, true]) {
if (advanced) await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
for (const [index, label] of ['당기기', '미루기'].entries()) {
const direction = index === 0 ? -1 : 1;
const control = editor.locator('.bottom-shift-control').nth(index);
const main = control.getByRole('button', { name: label, exact: true });
const menu = control.locator('details');
const toggle = menu.locator('summary');
const before = shifts().length;
await main.click();
await expect.poll(() => shifts().length).toBe(before + 1);
expect(shifts().at(-1)).toMatchObject({ amount: direction });
await expect(menu).not.toHaveAttribute('open');
await toggle.focus();
await page.keyboard.press('Enter');
await expect(menu).toHaveAttribute('open');
expect(shifts().length).toBe(before + 1);
await expect(menu.locator('.menu-items > button')).toHaveText([
'1턴',
'2턴',
'3턴',
'4턴',
'5턴',
'6턴',
]);
const geometry = await control.evaluate((element) => {
const main = element.querySelector('button')!;
const toggle = element.querySelector('summary')!;
const menu = element.querySelector('.menu-items')!;
return {
main: main.getBoundingClientRect().toJSON(),
toggle: toggle.getBoundingClientRect().toJSON(),
menu: menu.getBoundingClientRect().toJSON(),
style: { radius: getComputedStyle(main).borderRadius, font: getComputedStyle(main).fontFamily },
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(geometry.main.right).toBeCloseTo(geometry.toggle.left, 1);
expect(geometry.menu.right).toBeLessThanOrEqual(width);
expect(geometry.menu.left).toBeGreaterThanOrEqual(0);
expect(geometry.overflow).toBeLessThanOrEqual(0);
evidence.push({ advanced, label, geometry });
await page.screenshot({
path: testInfo.outputPath(`split-${advanced}-${index}-${width}.png`),
fullPage: true,
});
await menu.getByRole('button', { name: '6턴', exact: true }).click();
await expect.poll(() => shifts().length).toBe(before + 2);
expect(shifts().at(-1)).toMatchObject({ amount: direction * 6 });
await expect(menu).not.toHaveAttribute('open');
await main.click();
await expect.poll(() => shifts().length).toBe(before + 3);
expect(shifts().at(-1)).toMatchObject({ amount: direction });
}
}
await writeFile(testInfo.outputPath('split-evidence.json'), JSON.stringify(evidence, null, 2));
await writeFile(
testInfo.outputPath('split-editor.html'),
await editor.evaluate((element) => element.outerHTML)
);
});
}
test('offers 12 repeat turns and six shift turns for general turns while keeping the chief range', async ({ page }) => { test('offers 12 repeat turns and six shift turns for general turns while keeping the chief range', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
@@ -3253,3 +3365,133 @@ test('keeps the chief footer return button the same size as the top return butto
const mobile = await measure(); const mobile = await measure();
expect(mobile.bottom).toEqual(mobile.top); expect(mobile.bottom).toEqual(mobile.top);
}); });
const captureRecoveryControl = async (page: Page, selector: string, name: string) => {
const control = page.locator(selector).first();
await control.scrollIntoViewIfNeeded();
const read = () =>
control.evaluate((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
rect: rect.toJSON(),
color: style.color,
background: style.backgroundColor,
font: style.font,
outline: style.outline,
cursor: style.cursor,
html: el.outerHTML,
overflow: el.scrollWidth > el.clientWidth,
};
});
const normal = await read();
expect(normal.rect.width).toBeGreaterThan(0);
expect(normal.rect.x).toBeGreaterThanOrEqual(0);
expect(normal.rect.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1);
await control.hover();
const hover = await read();
await control.focus();
const focus = await read();
await page.mouse.down();
const active = await read();
await page.mouse.move(0, 0);
await page.mouse.up();
await writeFile(test.info().outputPath(`${name}.json`), JSON.stringify({ normal, hover, focus, active }, null, 2));
};
for (const width of [1200, 500]) {
test(`recovery clock preference and quick switches at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
await page.clock.setFixedTime(new Date('2026-09-10T02:00:00Z'));
const recoveryClock: NonNullable<Parameters<typeof install>[4]> = {
serverTime: '2026-09-10T01:00:00Z',
serverWallTime: '2026-09-10T02:00:00Z',
clockRunning: true,
turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
};
await install(page, false, commandTable, 1, recoveryClock);
await page.goto(gamePath('/'));
const top = page.locator('.execution-status');
const clock = page.locator('[data-command-current-time]:visible').first();
await expect(top).toContainText('게임 시간');
await expect(top).toHaveCSS('color', 'rgb(255, 209, 128)');
const gameText = await clock.textContent();
await top.click();
await expect(top).toContainText('실제 시간');
await expect(top).toHaveCSS('color', 'rgb(165, 214, 167)');
await expect(clock).not.toHaveText(gameText!);
await expect(page.locator('[data-general-turn-time]:visible').first()).toContainText('02:10:00');
const editor = page.locator('[data-command-scope="general"]:visible').first();
await expect(editor).toContainText('02:40');
await expect(editor).toContainText('03:20');
await clock.focus();
await page.keyboard.press('Enter');
await expect(top).toContainText('게임 시간');
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
await clock.click();
await expect(top).toContainText('실제 시간');
await page.reload();
await expect(top).toContainText('실제 시간');
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: test.info().outputPath(`recovery-main-${width}.png`), fullPage: true });
await writeFile(
test.info().outputPath(`recovery-main-${width}.json`),
JSON.stringify(
await top.evaluate((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
text: el.textContent,
rect: rect.toJSON(),
color: style.color,
font: style.font,
html: el.outerHTML,
};
}),
null,
2
)
);
await captureRecoveryControl(page, '.execution-status', `recovery-main-states-${width}`);
await page.goto(gamePath('/chief-center'));
const chiefClock = page.locator('[data-command-current-time]:visible').first();
await expect(chiefClock).toHaveText('11:00:00');
await chiefClock.click();
await expect(chiefClock).toHaveText('10:00:00');
const chief = page.locator('[data-command-scope="nation"]:visible').first();
await chief.getByRole('button', { name: '고급 모드', exact: true }).click();
await chiefClock.click();
await expect(chiefClock).toHaveText('11:00:00');
await page.screenshot({ path: test.info().outputPath(`recovery-chief-${width}.png`), fullPage: true });
await captureRecoveryControl(page, '[data-command-current-time]:visible', `recovery-chief-states-${width}`);
// At the end boundary, 1x clicks cannot change the preference.
recoveryClock.clockRecovery = null;
recoveryClock.serverTime = '2026-09-10T03:00:00Z';
recoveryClock.serverWallTime = '2026-09-10T03:00:00Z';
await page.clock.setFixedTime(new Date('2026-09-10T03:00:00Z'));
await expect(chiefClock).toBeDisabled();
await expect(chiefClock).toHaveText('12:00:00');
await chiefClock.evaluate((element: HTMLButtonElement) => element.click());
await expect(chiefClock).toHaveAttribute('title', '실제 시간 기준');
await page.goto(gamePath('/my-settings'));
const setting = page.getByRole('radiogroup', { name: '가속 시 시간 표시 기준' });
await expect(setting.getByRole('radio', { name: '실제 시간 기준', exact: true })).toBeChecked();
await setting.getByRole('radio', { name: '게임 시간 기준', exact: true }).check();
await page.screenshot({ path: test.info().outputPath(`recovery-settings-${width}.png`), fullPage: true });
await captureRecoveryControl(
page,
'[aria-label="가속 시 시간 표시 기준"]',
`recovery-settings-states-${width}`
);
await page.reload();
await expect(setting.getByRole('radio', { name: '게임 시간 기준', exact: true })).toBeChecked();
await page.goto(gamePath('/'));
await expect(top).toHaveCSS('color', 'rgb(0, 255, 255)');
await expect(top).toBeDisabled();
recoveryClock.turnEngineRunning = false;
await page.reload();
await expect(top).toHaveCSS('color', 'rgb(255, 0, 255)');
await expect(clock).toBeDisabled();
});
}
+40
View File
@@ -6,6 +6,7 @@ const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
type FixtureState = { type FixtureState = {
finalized?: boolean;
mapRequests: number; mapRequests: number;
generalRequests: number; generalRequests: number;
created?: boolean; created?: boolean;
@@ -69,6 +70,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
npcGeneralCount: 1, npcGeneralCount: 1,
}, },
inherit: { inherit: {
enabled: !state.finalized,
totalPoint: 30, totalPoint: 30,
costs: { costs: {
inheritBornSpecialPoint: 10, inheritBornSpecialPoint: 10,
@@ -453,3 +455,41 @@ test('shows the creation success dialog exactly once before navigating home', as
await expect(page).toHaveURL(new RegExp(`${gameBasePath}/?$`)); await expect(page).toHaveURL(new RegExp(`${gameBasePath}/?$`));
expect(state.createRequests).toBe(1); expect(state.createRequests).toBe(1);
}); });
for (const width of [1280, 390]) {
test(`allows completed-season creation without inheritance options at ${width}px`, async ({ page }, testInfo) => {
const state: FixtureState = { mapRequests: 0, generalRequests: 0, finalized: true };
await installFixture(page, state);
await page.setViewportSize({ width, height: 900 });
await page.goto('join');
await page.locator('.advanced-options summary').click();
await expect(page.locator('.advanced-options')).toContainText('기본 옵션으로 장수를 생성할 수 있습니다.');
await expect(page.locator('.inherit-options')).toHaveCount(0);
await expect(
page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true })
).toBeEnabled();
await page.evaluate(() => document.fonts.ready);
const geometry = await page.locator('.advanced-options').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: getComputedStyle(element).color,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
html: element.outerHTML,
};
});
expect(geometry.documentWidth).toBe(geometry.viewportWidth);
await testInfo.attach('completed-join-geometry', {
body: JSON.stringify(geometry),
contentType: 'application/json',
});
await page.screenshot({ path: testInfo.outputPath('completed-join.png'), fullPage: true });
await page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true }).click();
await expect(page.getByRole('alertdialog', { name: '완료' })).toBeVisible();
expect(state.createRequests).toBe(1);
});
}
+513 -14
View File
@@ -1,8 +1,10 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; import { expect, test, type BrowserContext, type Locator, type Page, type Route } from '@playwright/test';
import { buildEquipmentTradeItemOptions } from '../../game-api/src/turns/commandInput.js'; import { buildEquipmentTradeItemOptions, loadEquipmentTradeItemOrder } from '../../game-api/src/turns/commandInput.js';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js';
const response = (data: unknown) => ({ result: { data } }); const response = (data: unknown) => ({ result: { data } });
const runtimeNavigation = JSON.parse( const runtimeNavigation = JSON.parse(
@@ -63,6 +65,7 @@ type NavigationFixture = {
draftCommandTable?: boolean; draftCommandTable?: boolean;
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>; equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
refCommandCategories?: boolean; refCommandCategories?: boolean;
uniqueItemLimit?: { count: number; until: { year: number; month: number } | null };
currentYear?: number; currentYear?: number;
currentMonth?: number; currentMonth?: number;
mapName?: string; mapName?: string;
@@ -552,7 +555,7 @@ const generalContext = (state: NavigationFixture) => ({
penalties: {}, penalties: {},
}); });
const installFixture = async (page: Page, state: NavigationFixture) => { const installFixture = async (page: Page | BrowserContext, state: NavigationFixture) => {
await page.addInitScript( await page.addInitScript(
({ profile }) => { ({ profile }) => {
localStorage.setItem('sammo-game-token', 'ga_navigation'); localStorage.setItem('sammo-game-token', 'ga_navigation');
@@ -623,6 +626,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
} }
const results = operations.map((operation, index) => { const results = operations.map((operation, index) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
if (operation === 'vote.getVoteList') return response({ polls: [], voteReward: 150 });
if (operation === 'vote.getAdminStatus') return response({ ok: false });
if (operation === 'lobby.info') { if (operation === 'lobby.info') {
return response({ return response({
myGeneral: { id: 7, name: '메뉴검증장수' }, myGeneral: { id: 7, name: '메뉴검증장수' },
@@ -749,6 +754,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
} }
if (operation === 'world.getMap') { if (operation === 'world.getMap') {
return response({ return response({
uniqueItemLimit: state.uniqueItemLimit,
result: true, result: true,
version: 0, version: 0,
startYear: 180, startYear: 180,
@@ -1278,6 +1284,90 @@ test('keeps the survey footer close button the same size as the top close button
} }
}); });
for (const entry of ['menu', 'status', 'notice'] as const) {
test(`closes the survey popup opened from ${entry} without navigating the opener`, async ({
page,
context,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(context, state);
await waitForMain(page);
const openerUrl = page.url();
for (const width of [1000, 500]) {
await page.setViewportSize({ width, height: 900 });
for (const bar of ['back_bar', 'bottom_bar']) {
const link =
entry === 'menu'
? page.locator('[data-menu-position="top"] [data-navigation-id="survey"]')
: entry === 'status'
? page.locator('.vote-status a')
: page.locator('.survey-notice a');
if (entry === 'notice' && width === 500) {
const noticeRect = await page.locator('.survey-notice').boundingBox();
const menuRect = await page.locator('.main-mobile-bottom').boundingBox();
expect(noticeRect!.y + noticeRect!.height).toBeLessThanOrEqual(menuRect!.y - 16);
await page.screenshot({ path: testInfo.outputPath(`survey-notice-${width}-${bar}.png`) });
await writeFile(
testInfo.outputPath(`survey-notice-${width}-${bar}.json`),
JSON.stringify({ noticeRect, menuRect }, null, 2)
);
}
const popupPromise = page.waitForEvent('popup', { timeout: 5_000 });
await link.click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await popup.setViewportSize({ width, height: 900 });
await expect(popup).toHaveURL(`${basePath}/survey`);
expect(await popup.evaluate(() => window.opener === null)).toBe(true);
if (bar === 'bottom_bar') await popup.reload();
const button = popup.locator(`.${bar} .back_btn`);
await expect(button).toHaveText('창 닫기');
await popup.evaluate(() => document.fonts.ready);
await button.focus();
await expect(button).toBeFocused();
await button.hover();
const artifactName = `survey-close-${width}-${bar}`;
await popup.screenshot({ path: testInfo.outputPath(`${artifactName}.png`), fullPage: true });
await writeFile(
testInfo.outputPath(`${artifactName}.html`),
await popup.locator('.pageVote').evaluate((el) => el.outerHTML)
);
await writeFile(
testInfo.outputPath(`${artifactName}.json`),
JSON.stringify(
await button.evaluate((el) => ({
rect: el.getBoundingClientRect().toJSON(),
openerIsNull: window.opener === null,
historyLength: window.history.length,
documentWidth: document.documentElement.scrollWidth,
style: { height: getComputedStyle(el).height, font: getComputedStyle(el).font },
})),
null,
2
)
);
const closed = popup.waitForEvent('close');
// 실제 창이 닫히면 click 응답보다 CDP target 종료가 먼저 도착할 수 있다.
await button.click().catch((error: unknown) => {
if (!popup.isClosed()) throw error;
});
await closed;
expect(popup.isClosed()).toBe(true);
expect(page.isClosed()).toBe(false);
expect(page.url()).toBe(openerUrl);
}
}
});
}
test('notifies only for a new incoming private message and marks it read from the notice', async ({ test('notifies only for a new incoming private message and marks it read from the notice', async ({
page, page,
}, testInfo) => { }, testInfo) => {
@@ -3473,8 +3563,9 @@ test('main map year exposes the Ref restriction and technology limit on desktop
nationLevel: 3, nationLevel: 3,
stage: 6, stage: 6,
npcMode: 1, npcMode: 1,
currentYear: 182, currentYear: 181,
currentMonth: 1, currentMonth: 1,
uniqueItemLimit: { count: 1, until: { year: 182, month: 12 } },
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
validMapImages: true, validMapImages: true,
@@ -3487,13 +3578,15 @@ test('main map year exposes the Ref restriction and technology limit on desktop
const mapPanel = page.locator(`.layout-${layout} [data-main-target="map"]`); const mapPanel = page.locator(`.layout-${layout} [data-main-target="map"]`);
const title = mapPanel.locator('.map-title'); const title = mapPanel.locator('.map-title');
const tooltip = mapPanel.getByRole('tooltip'); const tooltip = mapPanel.getByRole('tooltip');
await expect(title).toHaveText(/182 1/u); await expect(title).toHaveText(/181 1/u);
await expect(title).toHaveCSS('color', 'rgb(255, 255, 0)'); await expect(title).toHaveCSS('color', 'rgb(255, 165, 0)');
await expect(title).toHaveAttribute('tabindex', '0'); await expect(title).toHaveAttribute('tabindex', '0');
await expect(tooltip).toBeHidden(); await expect(tooltip).toBeHidden();
await title.hover(); await title.hover();
await expect(tooltip).toBeVisible(); await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText('초반제한 기간 : 0년 12개월 (183년)기술등급 제한 : 1등급 (185년 해제)'); await expect(tooltip).toHaveText(
'초반제한 기간 : 1년 12개월 (183년)기술등급 제한 : 1등급 (185년 해제)보유 유니크 한도: 182년 12월까지 1개'
);
const geometry = await mapPanel.evaluate((panel) => { const geometry = await mapPanel.evaluate((panel) => {
const panelRect = panel.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect();
const titleRect = panel.querySelector('.map-title')?.getBoundingClientRect(); const titleRect = panel.querySelector('.map-title')?.getBoundingClientRect();
@@ -3520,6 +3613,9 @@ test('main map year exposes the Ref restriction and technology limit on desktop
return geometry; return geometry;
}; };
await page.evaluate(async () => {
await document.fonts.ready;
});
const desktopGeometry = await assertTitleTooltip('desktop'); const desktopGeometry = await assertTitleTooltip('desktop');
await testInfo.attach('main-map-year-tooltip-desktop.png', { await testInfo.attach('main-map-year-tooltip-desktop.png', {
body: await page.screenshot({ fullPage: false }), body: await page.screenshot({ fullPage: false }),
@@ -3536,10 +3632,29 @@ test('main map year exposes the Ref restriction and technology limit on desktop
body: await page.screenshot({ fullPage: false }), body: await page.screenshot({ fullPage: false }),
contentType: 'image/png', contentType: 'image/png',
}); });
await testInfo.attach('main-map-year-tooltip-dom.html', {
body: Buffer.from(await page.locator('.layout-mobile [data-main-target="map"]').evaluate((el) => el.outerHTML)),
contentType: 'text/html',
});
await testInfo.attach('main-map-year-tooltip-geometry.json', { await testInfo.attach('main-map-year-tooltip-geometry.json', {
body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`), body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`),
contentType: 'application/json', contentType: 'application/json',
}); });
state.currentYear = 183;
state.uniqueItemLimit = { count: 2, until: { year: 189, month: 12 } };
await waitForMain(page);
const mobileMap = page.locator('.layout-mobile [data-main-target="map"]');
await mobileMap.locator('.map-title').focus();
await expect(mobileMap.getByRole('tooltip')).toContainText('보유 유니크 한도: 189년 12월까지 2개');
state.currentYear = 200;
state.uniqueItemLimit = { count: 4, until: null };
await waitForMain(page);
await mobileMap.locator('.map-title').focus();
await expect(mobileMap.getByRole('tooltip')).toContainText('보유 유니크 한도: 4개 (최종)');
state.uniqueItemLimit = undefined;
await waitForMain(page);
await mobileMap.locator('.map-title').focus();
await expect(mobileMap.getByRole('tooltip')).not.toContainText('보유 유니크 한도');
}); });
test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => { test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => {
@@ -4278,10 +4393,12 @@ test('all main Lumen button families share the rounded pressed geometry', async
[ [
'당기기', '당기기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }), page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
{ radius: '5.25px 0px 0px 5.25px' },
], ],
[ [
'미루기', '미루기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }), page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
{ radius: '5.25px 0px 0px 5.25px' },
], ],
[ [
'펼치기', '펼치기',
@@ -4342,7 +4459,7 @@ test('all main Lumen button families share the rounded pressed geometry', async
filter: 'none', filter: 'none',
}); });
if (label === '당기기' || label === '미루기') { if (label === '당기기' || label === '미루기') {
expect(base.width, `${label} fills its menu column`).toBeCloseTo(base.parentWidth, 2); expect(base.width, `${label} leaves room for the split button`).toBeCloseTo(base.parentWidth - 28, 2);
} }
await control.focus(); await control.focus();
@@ -4587,7 +4704,7 @@ test('mobile main Lumen button families keep the same state geometry without ove
await expect(control).toHaveClass(/legacy-button/u); await expect(control).toHaveClass(/legacy-button/u);
await expect(control).toHaveCSS( await expect(control).toHaveCSS(
'border-radius', 'border-radius',
index === 7 ? '0px 5.25px 5.25px 0px' : index === 8 ? '5.25px 0px 0px 5.25px' : '5.25px' index === 7 ? '0px 5.25px 5.25px 0px' : [4, 5, 8].includes(index) ? '5.25px 0px 0px 5.25px' : '5.25px'
); );
await expect(control).toHaveCSS('border-bottom-width', '4px'); await expect(control).toHaveCSS('border-bottom-width', '4px');
} }
@@ -5076,6 +5193,80 @@ for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 }, { name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 }, { name: 'mobile', width: 500, height: 900 },
] as const) { ] as const) {
test(`preserves scenario JSON equipment purchase order on ${viewport.name}`, async ({ page }) => {
const scenario = await loadScenarioDefinitionById(2701);
const modules = await loadItemModules([...ITEM_KEYS]);
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
const expectedKeys = Object.keys(pool.item!).filter(
(key) => pool.item![key]! <= 0 && modules.some((item) => item.key === key && item.buyable)
);
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: Object.fromEntries(
Object.entries(pool).map(([slot, entries]) => [
slot,
Object.fromEntries(Object.entries(entries).reverse()),
])
),
},
itemOrder: await loadEquipmentTradeItemOrder('2701'),
itemModules: modules,
currentSecurity: 5000,
generalGold: 100000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
draftCommandTable: true,
equipmentItemOptions: items.item.map((option) => ({ ...option, value: String(option.value) })),
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
};
await installFixture(page, state);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await waitForMain(page);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('item');
const equipment = picker.getByLabel('장비', { exact: true });
await equipment.click();
await equipment.press('Escape');
expect(
await equipment
.locator('option')
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value))
).toEqual(['None', ...expectedKeys]);
await expect(equipment.locator('option').nth(1)).toHaveText('환약(치료)');
await equipment.selectOption('che_훈련_청주');
await equipment.focus();
await expect(equipment).toBeFocused();
await page.evaluate(() => document.fonts.ready);
const artifact = await equipment.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
font: getComputedStyle(element).font,
color: getComputedStyle(element).color,
html: element.outerHTML,
selected: (element as HTMLSelectElement).value,
documentWidth: document.documentElement.scrollWidth,
}));
expect(artifact.documentWidth).toBeLessThanOrEqual(viewport.width);
await writeFile(
test.info().outputPath(`equipment-order-${viewport.name}.json`),
JSON.stringify(artifact, null, 2)
);
await picker.screenshot({ path: test.info().outputPath(`equipment-order-${viewport.name}.png`) });
const request = page.waitForRequest((entry) => entry.url().includes('turns.reserved.setGeneral'));
await picker.getByRole('button', { name: '입력', exact: true }).click();
expect(JSON.stringify((await request).postDataJSON())).toContain('"itemCode":"che_훈련_청주"');
});
test(`reserves owned unique equipment sales and preserves the slot brief on ${viewport.name}`, async ({ page }) => { test(`reserves owned unique equipment sales and preserves the slot brief on ${viewport.name}`, async ({ page }) => {
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
configConst: {}, configConst: {},
@@ -6085,8 +6276,8 @@ for (const viewport of [
{ width: 1200, height: 900 }, { width: 1200, height: 900 },
{ width: 390, height: 844 }, { width: 390, height: 844 },
]) { ]) {
test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => { test(`turn recovery waits then accelerates and returns to normal speed (${viewport.width}px)`, async ({ page }) => {
const start = new Date('2026-09-06T07:59:50Z'); const start = new Date('2026-09-06T07:59:45Z');
await page.clock.install({ time: start }); await page.clock.install({ time: start });
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
const state: NavigationFixture = { const state: NavigationFixture = {
@@ -6100,15 +6291,18 @@ for (const viewport of [
serverTime: '2026-09-06T07:59:40Z', serverTime: '2026-09-06T07:59:40Z',
serverWallTime: start.toISOString(), serverWallTime: start.toISOString(),
clockMode: 'realtime', clockMode: 'realtime',
clockRunning: true, clockRunning: false,
clockStartsAt: '2026-09-06T07:59:50Z',
turnEngineRunning: true, turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-06T04:00:00Z', endsAt: '2026-09-06T08:00:00Z' }, clockRecovery: { startsAt: '2026-09-06T07:59:50Z', endsAt: '2026-09-06T08:00:00Z' },
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('./'); await page.goto('./');
await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 });
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
const status = page.locator('.execution-status:visible'); const status = page.locator('.execution-status:visible');
await expect(status).not.toContainText('복구 2배속');
await page.clock.runFor(5_000);
await expect(status).toContainText('복구 2배속'); await expect(status).toContainText('복구 2배속');
const root = process.env.TURN_RECOVERY_ARTIFACT_DIR; const root = process.env.TURN_RECOVERY_ARTIFACT_DIR;
const measure = () => const measure = () =>
@@ -6126,7 +6320,7 @@ for (const viewport of [
await mkdir(root, { recursive: true }); await mkdir(root, { recursive: true });
await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true }); await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true });
} }
await page.clock.runFor(20_000); await page.clock.runFor(10_000);
await expect(status).not.toContainText('복구 2배속'); await expect(status).not.toContainText('복구 2배속');
await expect(status).toContainText('17:00'); await expect(status).toContainText('17:00');
const after = await measure(); const after = await measure();
@@ -6140,3 +6334,308 @@ for (const viewport of [
} }
}); });
} }
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
test(`NPC message portraits resolve stored and new scenario icons at ${viewport.width}px`, async ({
page,
}, testInfo) => {
const picture = '롤시나리오/다이애나.png';
const iconUrl = `https://sam-image.hided.net/icons/${picture.split('/').map(encodeURIComponent).join('/')}`;
const asset = await readFile(resolve(process.cwd(), '../../../image/icons', picture));
const defaultAsset = await readFile(resolve(process.cwd(), '../../../image/icons/default.jpg'));
const state: NavigationFixture = {
officerLevel: 1,
permission: 0,
nationLevel: 1,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
messages: {
...emptyMessages(0),
public: [picture, `https://sam-image.hided.net/icons/${picture}`, ''].map((icon, index) => ({
id: 801 + index,
text: '새로운 달이 떠오르고 있다.',
time: '2026-09-07 12:00:00',
msgType: 'public',
src: {
generalId: 22 + index,
generalName: index === 2 ? '유저' : '다이애나',
nationId: 1,
nationName: '위',
color: '#008000',
icon,
},
dest: null,
option: {},
})),
},
};
await installFixture(page, state);
await page.route('https://sam-image.hided.net/icons/**', (route) =>
route.fulfill({
contentType: route.request().url() === iconUrl ? 'image/png' : 'image/jpeg',
body: route.request().url() === iconUrl ? asset : defaultAsset,
})
);
await page.setViewportSize(viewport);
await waitForMain(page);
const measurements = [];
for (const id of [801, 802, 803]) {
const icon = page.locator(`.msg-plate[data-id="${id}"]:visible img.general-icon`).first();
await expect(icon).toBeVisible();
await expect
.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
.toBe(id === 803 ? 64 : 128);
const result = await icon.evaluate((element) => {
const image = element as HTMLImageElement;
const rect = image.getBoundingClientRect();
return {
src: image.src,
width: rect.width,
height: rect.height,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
};
});
expect(result.src).toBe(id === 803 ? 'https://sam-image.hided.net/icons/default.jpg' : iconUrl);
expect(result.width).toBe(64);
expect(result.height).toBe(64);
measurements.push(result);
}
await page.reload();
await expect(page.locator('.msg-plate[data-id="801"]:visible img').first()).toHaveAttribute('src', iconUrl);
await writeFile(testInfo.outputPath('icon-geometry.json'), JSON.stringify({ viewport, measurements }, null, 2));
await writeFile(
testInfo.outputPath('messages.html'),
await page
.locator('.msg-plate[data-id="801"]:visible')
.first()
.evaluate((el) => el.outerHTML)
);
await page.screenshot({ path: testInfo.outputPath('npc-message-icons.png'), fullPage: true });
});
}
for (const role of [
{ name: '군주', officerLevel: 12, permission: 4 },
{ name: '외교권자', officerLevel: 1, permission: 4 },
{ name: '조언자', officerLevel: 1, permission: 3 },
{ name: '일반 장수', officerLevel: 1, permission: 0 },
]) {
for (const width of [1200, 390]) {
test(`diplomacy arrival notice ${role.name} ${width}`, async ({ page }, testInfo) => {
const state: NavigationFixture = {
...role,
nationLevel: 3,
stage: 0,
npcMode: 1,
latestVote: null,
generalMeCalls: 0,
operations: [],
messages: { ...emptyMessages(role.permission), nationId: 1 },
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width, height: 900 });
await waitForMain(page);
await waitForMainRealtime(page);
const notice = page.getByTestId('diplomacy-message-notice');
const message = (id: number, sourceNation = 2, option: Record<string, unknown> | null = null) => ({
...privateMessage(id, 9),
msgType: 'diplomacy',
src: { ...privateMessage(id, 9).src, nationId: sourceNation },
text: id === 50 ? '외교 문서가 도착했습니다.' : '불가침 제의 서신',
option,
});
state.messages = { ...emptyMessages(role.permission), nationId: 1, diplomacy: [message(49, 1)] };
await emitMessagesInvalidation(page);
await expect(notice).toHaveCount(0);
for (const id of [50, 51]) {
state.messages = {
...emptyMessages(role.permission),
nationId: 1,
diplomacy: [message(id, 2, id === 51 ? { action: 'noAggression' } : null)],
};
const before = state.operations.filter((op) => op === 'messages.getRecent').length;
await emitMessagesInvalidation(page);
await expect
.poll(() => state.operations.filter((op) => op === 'messages.getRecent').length)
.toBeGreaterThan(before);
if (role.permission < 3) {
await expect(notice).toHaveCount(0);
continue;
}
await expect(notice).toContainText('새로운 외교 메시지가 도착했습니다.');
await emitMessagesInvalidation(page);
await expect(notice).toHaveCount(1);
const titleVisible = await notice.locator('strong').evaluate((el) => {
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return hit === el || el.contains(hit);
});
expect(titleVisible).toBe(true);
await testInfo.attach(`diplomacy-${id}.png`, {
body: await notice.screenshot(),
contentType: 'image/png',
});
const geometry = await notice.evaluate((el) => {
const r = el.getBoundingClientRect();
return {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: getComputedStyle(el).font,
html: el.outerHTML,
};
});
expect(geometry.x).toBeGreaterThanOrEqual(0);
expect(geometry.x + geometry.width).toBeLessThanOrEqual(width);
await testInfo.attach(`diplomacy-${id}.json`, {
body: JSON.stringify(geometry),
contentType: 'application/json',
});
await notice.getByRole('button', { name: id === 50 ? '이미읽음' : '보러가기' }).click();
await expect(notice).toHaveCount(0);
await expect
.poll(() =>
state.trpcRequests?.some(
({ operations, body }) =>
operations.includes('messages.readLatest') &&
JSON.stringify(body).includes('"type":"diplomacy"') &&
JSON.stringify(body).includes(`"messageId":${id}`)
)
)
.toBe(true);
}
if (role.permission >= 3) {
state.messages = {
...emptyMessages(role.permission),
nationId: 1,
private: [privateMessage(60, 9)],
diplomacy: [message(60)],
};
await emitMessagesInvalidation(page);
await expect(notice).toBeVisible();
const privateNotice = page.getByTestId('private-message-notice');
await expect(privateNotice).toBeVisible();
const first = await privateNotice.boundingBox();
const second = await notice.boundingBox();
expect(second!.y).toBeGreaterThan(first!.y + first!.height);
await notice.getByRole('button', { name: '외교 메시지 알림 닫기' }).click();
await emitMessagesInvalidation(page);
await expect(notice).toHaveCount(0);
state.messages = { ...emptyMessages(role.permission), nationId: 1, diplomacy: [message(61)] };
await emitMessagesInvalidation(page);
await expect(notice).toBeVisible();
state.messages = {
...emptyMessages(2),
nationId: 1,
diplomacy: [message(61, 2, { permissionRedacted: true })],
};
await emitMessagesInvalidation(page);
await expect(notice).toHaveCount(0);
}
});
}
}
for (const width of [1200, 390]) {
test(`message lifecycle states preserve body and prevent stale actions ${width}`, async ({ page }, testInfo) => {
const makeMessage = (
id: number,
option: Record<string, unknown>,
text = '210년 1월까지 불가침을 제의합니다.'
) => ({
...privateMessage(id, 9),
msgType: 'diplomacy',
src: { ...privateMessage(id, 9).src, nationId: 2 },
text,
option: { action: 'noAggression', ...option },
});
const state: NavigationFixture = {
officerLevel: 1,
permission: 4,
nationLevel: 3,
stage: 0,
npcMode: 1,
latestVote: null,
generalMeCalls: 0,
operations: [],
messages: {
...emptyMessages(4),
nationId: 1,
diplomacy: [
makeMessage(801, { actionState: 'expired', used: true }),
makeMessage(802, { actionState: 'resolved', used: true }),
makeMessage(803, { invalid: true }, '삭제된 메시지입니다.'),
makeMessage(
804,
{ actionState: 'expired', used: true, permissionRedacted: true },
'조회 권한이 없는 외교 메시지입니다.'
),
makeMessage(805, { actionState: 'expired', used: true, action: 'stopWar' }, '종전을 제의합니다.'),
makeMessage(806, { actionState: 'unavailable', used: true }),
],
},
};
await installFixture(page, state);
await page.setViewportSize({ width, height: 900 });
await waitForMain(page);
const mobileButton = page.getByRole('button', { name: '메시지', exact: true });
if (await mobileButton.isVisible()) await mobileButton.click();
const expected = [
[801, '만료된 불가침메시지입니다'],
[802, '처리 완료된 메시지입니다'],
[803, '삭제된 메시지입니다'],
[804, '조회 권한이 없는 외교 메시지입니다.'],
[805, '만료된 종전 제의 메시지입니다'],
[806, '더 이상 응답할 수 없는 메시지입니다'],
] as const;
for (const [id, label] of expected) {
const plate = page.locator(`.msg-plate[data-id="${id}"]:visible`).first();
await expect(plate).toContainText(label);
await expect(plate.locator('.message-response')).toHaveCount(0);
if ([801, 802, 806].includes(id)) await expect(plate).toContainText('210년 1월까지 불가침을 제의합니다.');
if (id !== 803) await expect(plate).not.toContainText('삭제된 메시지입니다');
if (id === 804) await expect(plate.locator('.message-action-status')).toHaveCount(0);
}
await expect(page.getByTestId('diplomacy-message-notice')).toHaveCount(0);
await page.reload();
if (await mobileButton.isVisible()) await mobileButton.click();
await expect(page.locator('.msg-plate[data-id="801"]:visible').first()).toContainText(
'만료된 불가침메시지입니다'
);
await page.evaluate(() => document.fonts.ready);
const plates = page.locator('.DiplomacyTalk .msg-plate:visible');
const geometry = await plates.evaluateAll((elements) =>
elements.map((el) => {
const r = el.getBoundingClientRect();
return {
id: el.getAttribute('data-id'),
width: r.width,
height: r.height,
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
font: getComputedStyle(el).font,
html: el.outerHTML,
};
})
);
expect(geometry.length).toBeGreaterThanOrEqual(6);
expect(geometry.every((item) => item.height >= 64 && item.scrollHeight <= item.clientHeight)).toBe(true);
await writeFile(testInfo.outputPath('lifecycle-geometry.json'), JSON.stringify(geometry, null, 2));
await page
.locator('.msg-plate[data-id="801"]:visible')
.first()
.screenshot({ path: testInfo.outputPath('expired-message.png') });
await page.screenshot({ path: testInfo.outputPath('lifecycle-page.png'), fullPage: true });
expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0);
});
}
+2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue'; import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue'; import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice'; import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
useDeploymentVersionNotice(); useDeploymentVersionNotice();
useClockDisplayRefresh();
</script> </script>
<template> <template>
@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useClockDisplay } from '../../composables/useClockDisplay';
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
const { time } = useClockDisplay();
const currentTime = computed(() => (time.value ? formatSeoulTimeSeconds(time.value) : '--:--:--'));
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { import type {
CommandMapData, CommandMapData,
@@ -50,7 +54,7 @@ const reserveBulk = (entries: CommandPatternEntry[], complete?: ReservationCompl
:mobile="props.mobile" :mobile="props.mobile"
:title="props.officerLevelText" :title="props.officerLevelText"
:name="props.name" :name="props.name"
:current-time="props.rows[0]?.time" :current-time="currentTime"
:map-data="props.mapData" :map-data="props.mapData"
:map-layout="props.mapLayout" :map-layout="props.mapLayout"
@reserve-bulk="reserveBulk" @reserve-bulk="reserveBulk"
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../../composables/useClockDisplay';
const { accelerated, label: clockLabel, toggle: toggleClock, mode: clockDisplayMode } = useClockDisplay();
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue'; import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue'; import CommandSelectForm from '../main/CommandSelectForm.vue';
@@ -382,7 +384,18 @@ const clickOutsideMenu = (event: Event) => {
><span>{{ props.title }}</span> ><span>{{ props.title }}</span>
</div> </div>
<button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button> <button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button>
<div class="clock" data-command-current-time>{{ props.currentTime }}</div> <button
type="button"
class="clock"
data-command-current-time
:class="{ 'clock--real': accelerated && clockDisplayMode === 'real' }"
:disabled="!accelerated"
:title="accelerated ? `${clockLabel} · 클릭하여 변경` : clockLabel"
:aria-label="`현재 시각 · ${clockLabel}`"
@click="toggleClock"
>
{{ props.currentTime }}
</button>
<details class="legacy-menu"> <details class="legacy-menu">
<summary>반복</summary> <summary>반복</summary>
<div class="menu-items"> <div class="menu-items">
@@ -701,38 +714,38 @@ const clickOutsideMenu = (event: Event) => {
</div> </div>
<div v-if="!props.compact" class="bottom-actions"> <div v-if="!props.compact" class="bottom-actions">
<details class="legacy-menu bottom-shift-menu"> <div v-for="direction in [-1, 1]" :key="direction" class="bottom-shift-control">
<summary class="legacy-button legacy-button--secondary" role="button">당기기</summary> <button
<div class="menu-items"> class="legacy-button legacy-button--secondary shift-main"
<button type="button"
v-for="amount in props.maxShiftTurn" :title="direction < 0 ? '1턴 당기기' : '1턴 미루기'"
:key="amount" @click="emit('shift', direction)"
type="button" >
@click=" {{ direction < 0 ? '당기기' : '미루기' }}
emit('shift', -amount); </button>
clickOutsideMenu($event); <details class="legacy-menu bottom-shift-menu">
" <summary
class="legacy-button legacy-button--secondary"
role="button"
:aria-label="direction < 0 ? '당길 턴 수 선택' : '미룰 턴 수 선택'"
> >
{{ amount }} <span aria-hidden="true"></span>
</button> </summary>
</div> <div class="menu-items">
</details> <button
<details class="legacy-menu bottom-shift-menu"> v-for="amount in props.maxShiftTurn"
<summary class="legacy-button legacy-button--secondary" role="button">미루기</summary> :key="amount"
<div class="menu-items"> type="button"
<button @click="
v-for="amount in props.maxShiftTurn" emit('shift', direction * amount);
:key="amount" clickOutsideMenu($event);
type="button" "
@click=" >
emit('shift', amount); {{ amount }}
clickOutsideMenu($event); </button>
" </div>
> </details>
{{ amount }} </div>
</button>
</div>
</details>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded"> <button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }} {{ expanded ? '접기' : '펼치기' }}
</button> </button>
@@ -881,10 +894,17 @@ const clickOutsideMenu = (event: Event) => {
place-items: center; place-items: center;
padding: 4px; padding: 4px;
} }
.clock { .control-pad > .clock {
background: #345c85; background: #345c85;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.clock:disabled {
opacity: 1;
cursor: default;
}
.control-pad > .clock--real {
background: #386b45;
}
.legacy-menu { .legacy-menu {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -1098,8 +1118,29 @@ const clickOutsideMenu = (event: Event) => {
gap: 4px; gap: 4px;
padding-top: 3px; padding-top: 3px;
} }
.bottom-shift-control {
display: flex;
min-width: 0;
}
.shift-main {
flex: 1;
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.bottom-shift-menu {
flex: 0 0 28px;
}
.bottom-shift-menu > summary { .bottom-shift-menu > summary {
width: 100%; width: 100%;
padding-inline: 0;
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.bottom-shift-menu .menu-items {
left: auto;
right: 0;
} }
.command-picker { .command-picker {
position: absolute; position: absolute;
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed } from 'vue';
import { useClockDisplay } from '../../composables/useClockDisplay';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { generalTurnEditorModeStorageKey } from '../command/commandQueue'; import { generalTurnEditorModeStorageKey } from '../command/commandQueue';
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection';
import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig'; import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig';
import type { import type {
CommandMapData, CommandMapData,
@@ -14,6 +15,9 @@ import type {
ReservedCommandRow, ReservedCommandRow,
} from '../command/types'; } from '../command/types';
const { projectTime, time } = useClockDisplay();
const currentServerTime = computed(() => (time.value ? formatLocalTimeSeconds(time.value) : '--:--:--'));
type ReservationCompletion = (success: boolean) => void; type ReservationCompletion = (success: boolean) => void;
const props = defineProps<{ const props = defineProps<{
@@ -69,7 +73,7 @@ const rows = computed<ReservedCommandRow[]>(() => {
const term = props.turnTermMinutes ?? 0; const term = props.turnTermMinutes ?? 0;
return (props.reservedGeneralTurns ?? []).map((turn, offset) => { return (props.reservedGeneralTurns ?? []).map((turn, offset) => {
const absoluteMonth = firstReservedMonth.value + offset; const absoluteMonth = firstReservedMonth.value + offset;
const date = base && Number.isFinite(base.getTime()) ? addMinutes(base, offset * term) : null; const date = base && Number.isFinite(base.getTime()) ? projectTime(addMinutes(base, offset * term)) : null;
return { return {
...turn, ...turn,
args: turn.args ?? {}, args: turn.args ?? {},
@@ -100,62 +104,9 @@ const autonomousUntil = computed(() => {
base && Number.isFinite(base.getTime()) base && Number.isFinite(base.getTime())
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term) ? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
: null; : null;
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가'; const currentTimeLabel = expiresAt ? formatLocalDateTime(projectTime(expiresAt)) : '현재시각 확인 불가';
return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`; return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`;
}); });
const currentServerTime = ref('--:--:--');
const MAX_SERVER_CLOCK_TIMER_DELAY_MS = 60_000;
let serverClockSample: SampledServerClock | null = null;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined;
if (serverClockSample === null) {
currentServerTime.value = '--:--:--';
return;
}
const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample);
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
serverClockTimer = setTimeout(
updateServerClock,
untilStartMs > 0
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
: (1_000 - projectedTime.getMilliseconds()) / rate
);
}
};
watch(
() =>
[
props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true }
);
onUnmounted(() => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
});
</script> </script>
<template> <template>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../../composables/useClockDisplay';
const { projectTime } = useClockDisplay();
import { computed } from 'vue'; import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
@@ -257,7 +259,7 @@ const specialText = computed(() => {
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} | {{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> <span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span>
<span data-general-turn-time>{{ <span data-general-turn-time>{{
props.general.turnTime ? formatLocalTimeSeconds(props.general.turnTime) : '-' props.general.turnTime ? formatLocalTimeSeconds(projectTime(props.general.turnTime)) : '-'
}}</span> }}</span>
</div> </div>
@@ -1,13 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, watch } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus'; import { resolveTournamentStageName } from '../../utils/tournamentStatus';
import { import { receiveClockSample, useClockDisplay } from '../../composables/useClockDisplay';
millisecondsUntilNextMinute,
projectServerClock,
sampleServerClock,
type SampledServerClock,
} from '../../utils/serverClockProjection';
const props = defineProps<{ const props = defineProps<{
tournamentStage: number; tournamentStage: number;
@@ -33,106 +28,60 @@ const props = defineProps<{
}>(); }>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const currentServerTime = ref('기록 없음'); const { time, accelerated: recovering, mode, label, toggle, engineRunning } = useClockDisplay();
const hasServerClock = ref(false); const currentServerTime = computed(() =>
const recovering = ref(false); formatServerDateTime(time.value, { format: 'monthDayTime', fallback: '기록 없음' })
const turnEngineStopped = computed(() => props.turnEngineRunning === false); );
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean'); const hasServerClock = computed(() => time.value !== null);
const turnEngineStopped = computed(() => engineRunning.value === false);
const turnEngineStatusUnknown = computed(() => typeof engineRunning.value !== 'boolean');
const serverClockTitle = computed(() => { const serverClockTitle = computed(() => {
if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.'; if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.';
if (turnEngineStopped.value) return '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.'; if (turnEngineStopped.value) return '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.';
if (turnEngineStatusUnknown.value) return '턴 엔진 진행 상태를 확인하지 못했습니다.'; if (turnEngineStatusUnknown.value) return '턴 엔진 진행 상태를 확인하지 못했습니다.';
return undefined; return recovering.value ? `${label.value} · 클릭하여 변경` : label.value;
}); });
let serverClockSample: SampledServerClock | null = null;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined;
if (serverClockSample === null) {
currentServerTime.value = '기록 없음';
hasServerClock.value = false;
return;
}
const now = Date.now();
const projection = projectServerClock(serverClockSample, now);
recovering.value = projection.rate === 2;
currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime',
fallback: '기록 없음',
});
hasServerClock.value = true;
if (props.turnEngineRunning !== true) return;
const nextDelays: number[] = [];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs;
nextDelays.push(
untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time) / projection.rate
);
}
for (const boundary of [serverClockSample.recoveryStartDelayMs, serverClockSample.recoveryEndDelayMs]) {
if (boundary !== undefined && boundary > projection.clientElapsedMs)
nextDelays.push(boundary - projection.clientElapsedMs);
}
if (nextDelays.length === 0) return;
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
};
watch( watch(
() => () => [
[ props.serverTime,
props.serverTime, props.serverWallTime,
props.serverWallTime, props.clockMode,
props.clockMode, props.clockRunning,
props.clockRunning, props.clockStartsAt,
props.clockStartsAt, props.clockRecovery,
props.clockRecovery, ],
] as const, () => receiveClockSample(props),
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true } { immediate: true }
); );
watch(() => props.turnEngineRunning, updateServerClock);
onUnmounted(() => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
});
</script> </script>
<template> <template>
<section class="front-status" aria-label="접속 현황과 국가 방침"> <section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="activity-status" aria-label="현재 시각, 토너먼트와 설문 진행 현황"> <div class="activity-status" aria-label="현재 시각, 토너먼트와 설문 진행 현황">
<div <button
type="button"
:disabled="!recovering || turnEngineStopped"
class="status-row execution-status" class="status-row execution-status"
:class="{ :class="{
'execution-status--game': recovering && mode === 'game',
'execution-status--real': recovering && mode === 'real',
'execution-status--empty': !hasServerClock, 'execution-status--empty': !hasServerClock,
'execution-status--stopped': hasServerClock && turnEngineStopped, 'execution-status--stopped': hasServerClock && turnEngineStopped,
'execution-status--unknown': hasServerClock && turnEngineStatusUnknown, 'execution-status--unknown': hasServerClock && turnEngineStatusUnknown,
}" }"
:title="serverClockTitle" :title="serverClockTitle"
@click="toggle"
> >
현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span> 현재 시각: {{ currentServerTime
</div> }}<span v-if="recovering"> · 2배속 · {{ mode === 'real' ? '실제 시간' : '게임 시간' }}</span>
</button>
<div class="status-row tournament-status"> <div class="status-row tournament-status">
<RouterLink to="/tournament"> <RouterLink to="/tournament">
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }} <span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
</RouterLink> </RouterLink>
</div> </div>
<div class="status-row vote-status"> <div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey"> <RouterLink v-if="status?.latestVote" to="/survey" target="_blank" rel="noopener noreferrer">
<span class="vote-label">설문: </span>{{ status.latestVote.title }} <span class="vote-label">설문: </span>{{ status.latestVote.title }}
</RouterLink> </RouterLink>
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span> <span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
@@ -208,9 +157,31 @@ onUnmounted(() => {
} }
.execution-status { .execution-status {
background: transparent;
border-right: 0;
border-bottom: 0;
border-left: 0;
font: inherit;
cursor: pointer;
color: cyan; color: cyan;
} }
.execution-status > span {
display: block;
white-space: nowrap;
font-size: 12px;
}
.execution-status:disabled {
opacity: 1;
cursor: default;
}
.execution-status--game {
color: #ffd180;
}
.execution-status--real {
color: #a5d6a7;
}
.execution-status--empty { .execution-status--empty {
color: magenta; color: magenta;
} }
@@ -20,6 +20,7 @@ interface MapSummary {
initialLevel: number; initialLevel: number;
increaseYears: number; increaseYears: number;
}; };
uniqueItemLimit?: { count: number; until: { year: number; month: number } | null };
cityList: [number, number, number, number, number, number][]; cityList: [number, number, number, number, number, number][];
nationList: [number, string, string, number][]; nationList: [number, string, string, number][];
myCity?: number | null; myCity?: number | null;
@@ -335,6 +336,11 @@ const titleTooltipLines = computed(() => {
} else { } else {
lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`); lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`);
} }
const uniqueLimit = props.mapData.uniqueItemLimit;
if (uniqueLimit) {
const period = uniqueLimit.until ? `${uniqueLimit.until.year}${uniqueLimit.until.month}월까지 ` : '';
lines.push(`보유 유니크 한도: ${period}${uniqueLimit.count}${uniqueLimit.until ? '' : ' (최종)'}`);
}
return lines; return lines;
}); });
@@ -56,6 +56,26 @@ const destination = computed<MessageTarget>(
const invalid = computed(() => props.message.option?.invalid === true); const invalid = computed(() => props.message.option?.invalid === true);
const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true); const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true);
const actionState = computed(() => props.message.option?.actionState);
const actionUnavailable = computed(
() => props.message.option?.used === true || (actionState.value != null && actionState.value !== 'pending')
);
const actionStatusText = computed(() => {
if (invalid.value || permissionRedacted.value || !actionUnavailable.value) return null;
if (actionState.value === 'expired') {
switch (props.message.option?.action) {
case 'noAggression':
return '만료된 불가침메시지입니다';
case 'stopWar':
return '만료된 종전 제의 메시지입니다';
case 'scout':
return '만료된 등용 권유 메시지입니다';
default:
return '만료된 메시지입니다';
}
}
return actionState.value === 'resolved' ? '처리 완료된 메시지입니다' : '더 이상 응답할 수 없는 메시지입니다';
});
const hasAction = computed(() => typeof props.message.option?.action === 'string'); const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => { const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) { if (props.message.src.nationId === destination.value.nationId) {
@@ -275,10 +295,11 @@ onBeforeUnmount(() => {
]" ]"
> >
<strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong> <strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong>
{{ invalid ? '삭제된 메시지입니다' : message.text }} {{ permissionRedacted ? message.text : invalid ? '삭제된 메시지입니다' : message.text }}
<div v-if="actionStatusText" class="message-action-status">{{ actionStatusText }}</div>
</div> </div>
<div v-if="hasAction && !invalid" class="message-response"> <div v-if="hasAction && !invalid && !permissionRedacted && !actionUnavailable" class="message-response">
<button <button
class="prompt-yes legacy-button legacy-button--primary" class="prompt-yes legacy-button legacy-button--primary"
type="button" type="button"
@@ -0,0 +1,98 @@
import { computed, ref, shallowRef } from 'vue';
import { useStorage } from '@vueuse/core';
import { formatServerDateTime, type ServerDateTimeOptions } from '@sammo-ts/common/time/ServerDateTime';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import {
projectRecoveryTime,
projectServerClock,
sampleServerClock,
type ServerClockProjectionInput,
} from '../utils/serverClockProjection';
export const clockDisplayStorageKey = `sammo-clock-display:${gameFrontendRuntimeConfig.profile}:${gameFrontendRuntimeConfig.appBasePath}`;
const storedMode = useStorage<string>(clockDisplayStorageKey, 'game');
const mode = computed({
get: () => (storedMode.value === 'real' ? 'real' : 'game'),
set: (value: string) => {
storedMode.value = value === 'real' ? 'real' : 'game';
},
});
const sample = shallowRef<ReturnType<typeof sampleServerClock>>(null);
const now = ref(Date.now());
const engineRunning = ref<boolean | null>(null);
const haltedAt = ref<number | null>(null);
export const receiveClockEngineState = (running: boolean | null): void => {
engineRunning.value = running;
haltedAt.value = running === false ? (haltedAt.value ?? Date.now()) : null;
};
export const receiveClockSample = (
input: ServerClockProjectionInput & { turnEngineRunning?: boolean | null }
): void => {
if (!input.serverTime) return;
const wallTime = input.serverWallTime ? new Date(input.serverWallTime).getTime() : undefined;
// 메인 화면의 이전 표본을 heartbeat가 재전달해도 더 최신 lobby 표본을 되감지 않는다.
if (
wallTime !== undefined &&
sample.value?.serverWallTimeMs !== undefined &&
wallTime < sample.value.serverWallTimeMs
)
return;
if (
sample.value &&
wallTime !== undefined &&
wallTime === sample.value.serverWallTimeMs &&
new Date(input.serverTime).getTime() === sample.value.serverTimeMs &&
input.turnEngineRunning === engineRunning.value
)
return;
receiveClockEngineState(input.turnEngineRunning ?? null);
sample.value = sampleServerClock(
input.turnEngineRunning === false ? { ...input, clockRunning: false, clockStartsAt: null } : input
);
now.value = Date.now();
};
export const advanceClockDisplay = (): void => {
now.value = Date.now();
};
export const clockSampleIsStale = (): boolean =>
!sample.value || Date.now() - sample.value.sampledClientTimeMs >= 30_000;
const projection = computed(() =>
sample.value ? projectServerClock(sample.value, haltedAt.value ?? now.value) : null
);
const accelerated = computed(() => projection.value?.rate === 2 && engineRunning.value !== false);
const label = computed(() => (mode.value === 'real' ? '실제 시간 기준' : '게임 시간 기준'));
const time = computed(() => {
const projected = projection.value?.time;
if (!projected) return null;
return mode.value === 'real' && accelerated.value ? projectRecoveryTime(sample.value, projected) : projected;
});
const toggle = (): void => {
if (accelerated.value) mode.value = mode.value === 'real' ? 'game' : 'real';
};
const projectTime = (value: string | Date): Date => {
// timezone 없는 API 값은 기존 고정 UTC+9 서버 벽시계 계약을 유지한다.
const normalized =
typeof value === 'string' && /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value)
? `${value.replace(' ', 'T')}+09:00`
: value;
const date = normalized instanceof Date ? normalized : new Date(normalized);
return mode.value === 'real' ? projectRecoveryTime(sample.value, date) : date;
};
const formatTime = (value: string | Date | null | undefined, options?: ServerDateTimeOptions): string =>
formatServerDateTime(value ? projectTime(value) : value, options);
export const useClockDisplay = () => ({
mode,
label,
time,
accelerated,
toggle,
projectTime,
formatTime,
engineRunning,
});
@@ -0,0 +1,37 @@
import { onMounted, onUnmounted } from 'vue';
import { trpc } from '../utils/trpc';
import { advanceClockDisplay, clockSampleIsStale } from './useClockDisplay';
export const useClockDisplayRefresh = (): void => {
let timer: ReturnType<typeof setInterval> | undefined;
let pending = false;
let lastAttempt = -Infinity;
const refresh = async () => {
advanceClockDisplay();
if (
Date.now() - lastAttempt < 30_000 ||
pending ||
document.visibilityState !== 'visible' ||
!clockSampleIsStale()
)
return;
pending = true;
lastAttempt = Date.now();
try {
await trpc.lobby.info.query();
} catch {
/* 다음 표본으로 복구한다. */
} finally {
pending = false;
}
};
onMounted(() => {
void refresh();
timer = setInterval(() => {
void refresh();
}, 250);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
};
@@ -1,3 +1,4 @@
import { receiveClockEngineState } from '../composables/useClockDisplay';
import { computed, ref, toRaw, watch } from 'vue'; import { computed, ref, toRaw, watch } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
@@ -130,6 +131,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const tournamentType = ref<TournamentType | null>(null); const tournamentType = ref<TournamentType | null>(null);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null); const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
const privateMessageNotice = ref<PrivateMessageNotice | null>(null); const privateMessageNotice = ref<PrivateMessageNotice | null>(null);
const diplomacyMessageNotice = ref<PrivateMessageNotice | null>(null);
let dismissedDiplomacyMessageId = 0;
let dismissedPrivateMessageId = 0; let dismissedPrivateMessageId = 0;
let lastGeneralRecordId = 0; let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0; let lastWorldHistoryId = 0;
@@ -351,6 +354,40 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
privateMessageNotice.value = null; privateMessageNotice.value = null;
}; };
const reconcileDiplomacyMessageNotice = (nextMessages: MessageBundle | null) => {
const viewerGeneralId = general.value?.id;
if (!nextMessages || !viewerGeneralId || nextMessages.permission < 3) {
diplomacyMessageNotice.value = null;
return;
}
const newestIncomingId = nextMessages.diplomacy
.filter(
(message) =>
message.src.nationId !== nextMessages.nationId &&
!message.option?.invalid &&
!message.option?.used &&
!message.option?.permissionRedacted
)
.reduce((latest, message) => Math.max(latest, message.id), 0);
const latestReadId = nextMessages.latestRead.diplomacy;
if (dismissedDiplomacyMessageId <= latestReadId) dismissedDiplomacyMessageId = 0;
if (newestIncomingId <= latestReadId || newestIncomingId <= dismissedDiplomacyMessageId) {
diplomacyMessageNotice.value = null;
return;
}
if (diplomacyMessageNotice.value?.messageId !== newestIncomingId) {
diplomacyMessageNotice.value = { messageId: newestIncomingId };
}
};
const dismissDiplomacyMessageNotice = () => {
dismissedDiplomacyMessageId = Math.max(
dismissedDiplomacyMessageId,
diplomacyMessageNotice.value?.messageId ?? 0
);
diplomacyMessageNotice.value = null;
};
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
const merged = new Map(current.map((entry) => [entry.id, entry])); const merged = new Map(current.map((entry) => [entry.id, entry]));
for (const entry of incoming) { for (const entry of incoming) {
@@ -370,6 +407,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
surveyNotice.value = null; surveyNotice.value = null;
privateMessageNotice.value = null; privateMessageNotice.value = null;
dismissedPrivateMessageId = 0; dismissedPrivateMessageId = 0;
diplomacyMessageNotice.value = null;
dismissedDiplomacyMessageId = 0;
}; };
const applyRecentRecords = (records: Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>) => { const applyRecentRecords = (records: Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>) => {
@@ -448,10 +487,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (patch.messages !== undefined) { if (patch.messages !== undefined) {
messages.value = structurallyShare(messages.value, patch.messages); messages.value = structurallyShare(messages.value, patch.messages);
reconcilePrivateMessageNotice(messages.value); reconcilePrivateMessageNotice(messages.value);
reconcileDiplomacyMessageNotice(messages.value);
} else if (patch.contextSnapshot !== undefined || patch.general !== undefined) { } else if (patch.contextSnapshot !== undefined || patch.general !== undefined) {
// Main data is fetched concurrently, so the message bundle can arrive // Main data is fetched concurrently, so the message bundle can arrive
// before the authenticated general context needed to identify senders. // before the authenticated general context needed to identify senders.
reconcilePrivateMessageNotice(messages.value); reconcilePrivateMessageNotice(messages.value);
reconcileDiplomacyMessageNotice(messages.value);
} }
if (patch.messageContacts !== undefined) { if (patch.messageContacts !== undefined) {
messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts); messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts);
@@ -518,6 +559,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => { const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => {
if (turnEngineRunning === undefined || !lobbyInfo.value) return; if (turnEngineRunning === undefined || !lobbyInfo.value) return;
receiveClockEngineState(turnEngineRunning);
lobbyInfo.value = structurallyShare(lobbyInfo.value, { lobbyInfo.value = structurallyShare(lobbyInfo.value, {
...lobbyInfo.value, ...lobbyInfo.value,
turnEngineRunning, turnEngineRunning,
@@ -688,6 +730,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
worldMap.value = structurallyShare(worldMap.value, map); worldMap.value = structurallyShare(worldMap.value, map);
messages.value = structurallyShare(messages.value, messageData); messages.value = structurallyShare(messages.value, messageData);
reconcilePrivateMessageNotice(messages.value); reconcilePrivateMessageNotice(messages.value);
reconcileDiplomacyMessageNotice(messages.value);
messageContacts.value = structurallyShare(messageContacts.value, contacts); messageContacts.value = structurallyShare(messageContacts.value, contacts);
reservedGeneralTurns.value = structurallyShare<unknown>( reservedGeneralTurns.value = structurallyShare<unknown>(
reservedGeneralTurns.value, reservedGeneralTurns.value,
@@ -960,6 +1003,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}, },
}; };
reconcilePrivateMessageNotice(messages.value); reconcilePrivateMessageNotice(messages.value);
reconcileDiplomacyMessageNotice(messages.value);
} }
return true; return true;
} catch (err) { } catch (err) {
@@ -974,6 +1018,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return readLatestMessage('private', messageId); return readLatestMessage('private', messageId);
}; };
const acknowledgeDiplomacyMessageNotice = async (): Promise<boolean> => {
const messageId = diplomacyMessageNotice.value?.messageId;
if (!messageId) return false;
return readLatestMessage('diplomacy', messageId);
};
const deleteMessage = async (messageId: number) => { const deleteMessage = async (messageId: number) => {
const id = generalId.value; const id = generalId.value;
if (!id) { if (!id) {
@@ -1399,6 +1449,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
tournamentType, tournamentType,
surveyNotice, surveyNotice,
privateMessageNotice, privateMessageNotice,
diplomacyMessageNotice,
messageDraftText, messageDraftText,
targetMailbox, targetMailbox,
mailboxGroups, mailboxGroups,
@@ -1409,6 +1460,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
stopRealtime, stopRealtime,
dismissSurveyNotice, dismissSurveyNotice,
dismissPrivateMessageNotice, dismissPrivateMessageNotice,
dismissDiplomacyMessageNotice,
acknowledgeDiplomacyMessageNotice,
acknowledgePrivateMessageNotice, acknowledgePrivateMessageNotice,
loadMainData, loadMainData,
refreshMessages, refreshMessages,
+2 -2
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_USER_ICON_PUBLIC_URL, DEFAULT_USER_ICON_PUBLIC_URL,
externalizeLegacyImageUrl, externalizeLegacyImageUrl,
} from './imageAssets.ts'; } from './imageAssets.ts';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig.ts';
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`; export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL; export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL;
@@ -69,7 +68,8 @@ export const resolveMessageGeneralIconUrl = (
if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) { if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) {
return normalized; return normalized;
} }
return `${gameFrontendRuntimeConfig.appBasePath}${normalized.replace(/^\/+/u, '')}`; // 기존 NPC 대사와 알림에는 URL 대신 picture 상대 경로가 저장되어 있다.
return resolveGeneralIconUrl({ picture: normalized, imageServer: 0 });
}; };
export const useDefaultGeneralIcon = (event: Event): void => { export const useDefaultGeneralIcon = (event: Event): void => {
@@ -10,6 +10,7 @@ export type ServerClockProjectionInput = {
export type SampledServerClock = { export type SampledServerClock = {
serverTimeMs: number; serverTimeMs: number;
sampledClientTimeMs: number; sampledClientTimeMs: number;
serverWallTimeMs?: number;
clockMode: 'realtime' | 'manual'; clockMode: 'realtime' | 'manual';
startDelayMs: number | null; startDelayMs: number | null;
recoveryStartDelayMs?: number; recoveryStartDelayMs?: number;
@@ -49,6 +50,7 @@ export const sampleServerClock = (
return { return {
serverTimeMs, serverTimeMs,
sampledClientTimeMs, sampledClientTimeMs,
...(wallSample !== null ? { serverWallTimeMs: wallSample } : {}),
clockMode: input.clockMode ?? 'realtime', clockMode: input.clockMode ?? 'realtime',
startDelayMs, startDelayMs,
...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart ...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart
@@ -93,3 +95,25 @@ export const millisecondsUntilNextMinute = (time: Date): number => {
const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000; const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000;
return remainder === 0 ? 60_000 : 60_000 - remainder; return remainder === 0 ? 60_000 : 60_000 - remainder;
}; };
// 복구 종료 좌표를 기준으로 역산한다. 종료 이후의 턴에는 2배속을 적용하지 않는다.
export const projectRecoveryTime = (sample: SampledServerClock | null, gameTime: Date): Date => {
if (
!sample ||
sample.clockMode === 'manual' ||
sample.startDelayMs === null ||
sample.serverWallTimeMs === undefined ||
sample.recoveryStartDelayMs === undefined ||
sample.recoveryEndDelayMs === undefined
)
return gameTime;
const endDelay = sample.recoveryEndDelayMs;
const endGame = projectServerClock(sample, sample.sampledClientTimeMs + Math.max(0, endDelay)).time.getTime();
const endWall = sample.serverWallTimeMs + endDelay;
const span = endDelay - sample.recoveryStartDelayMs;
const startGame = endGame - 2 * span;
const target = gameTime.getTime();
// 이전 기록은 이 복구 창으로 실제 발생 시각을 알 수 없으므로 그대로 둔다.
if (target < startGame) return gameTime;
return new Date(Math.ceil(target <= endGame ? endWall - (endGame - target) / 2 : endWall + target - endGame));
};
+21
View File
@@ -1,5 +1,8 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport'; import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types'; import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
import { observable } from '@trpc/server/observable';
import { receiveClockSample } from '../composables/useClockDisplay';
import type { ServerClockProjectionInput } from './serverClockProjection';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api'; import type { AppRouter } from '@sammo-ts/game-api';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig'; import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
@@ -25,6 +28,24 @@ const getGameToken = (): string | null => {
export const trpc = createTRPCProxyClient<AppRouter>({ export const trpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
() =>
({ op, next }) =>
observable((observer) =>
next(op).subscribe({
next(value) {
if (op.path === 'lobby.info' && 'data' in value.result && value.result.data) {
receiveClockSample(
value.result.data as ServerClockProjectionInput & {
turnEngineRunning?: boolean | null;
}
);
}
observer.next(value);
},
error: (error) => observer.error(error),
complete: () => observer.complete(),
})
),
httpBatchLink({ httpBatchLink({
url: gameFrontendRuntimeConfig.gameApiUrl, url: gameFrontendRuntimeConfig.gameApiUrl,
...trpcJsonBodyHttpClientOptions, ...trpcJsonBodyHttpClientOptions,
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -42,8 +44,8 @@ const resolveErrorMessage = (value: unknown): string => {
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString(); const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const displayCode = (value: string | null | undefined): string => const displayCode = (value: string | null | undefined): string =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, ''); !value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => { const cutDateTime = (value: string | null | undefined, showSecond = false, gameTime = true): string => {
return formatServerDateTime(value, { return (gameTime ? formatGameTime : formatServerDateTime)(value, {
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime', format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
fallback: '-', fallback: '-',
}); });
@@ -413,7 +415,7 @@ onMounted(() => {
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row"> <div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span> <span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
<span class="tnum">{{ formatNumber(bid.amount) }}</span> <span class="tnum">{{ formatNumber(bid.amount) }}</span>
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time> <time class="tnum">{{ cutDateTime(bid.eventAt, false, false) }}</time>
</div> </div>
<template v-if="uniqueDetail.auction.status === 'OPEN'"> <template v-if="uniqueDetail.auction.status === 'OPEN'">
<h3 class="subsection-title bg1">입찰하기</h3> <h3 class="subsection-title bg1">입찰하기</h3>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -130,7 +132,7 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => { const formatGeneralLabel = (general: GeneralEntry): string => {
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name; const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' }); const time = formatGameTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
if (orderBy.value === 'recentWar') { if (orderBy.value === 'recentWar') {
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`; return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
} }
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
@@ -53,7 +54,7 @@ const ratio = (id: number) => {
return amount ? (totalAmount.value / amount).toFixed(2) : '0'; return amount ? (totalAmount.value / amount).toFixed(2) : '0';
}; };
const openingTime = computed(() => const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' }) formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
); );
const selectedRatio = computed(() => { const selectedRatio = computed(() => {
const targetId = selectedTarget.value?.id; const targetId = selectedTarget.value?.id;
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core'; import { useMediaQuery } from '@vueuse/core';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue'; import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue'; import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
@@ -220,8 +221,8 @@ const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
baseTime && Number.isFinite(turnTermMinutes) ? addMinutes(baseTime, idx * turnTermMinutes) : null; baseTime && Number.isFinite(turnTermMinutes) ? addMinutes(baseTime, idx * turnTermMinutes) : null;
const timeLabel = turnDate const timeLabel = turnDate
? turnTermMinutes >= 5 ? turnTermMinutes >= 5
? formatServerDateTime(turnDate, { format: 'hourMinute' }) ? formatGameTime(turnDate, { format: 'hourMinute' })
: formatServerDateTime(turnDate, { format: 'minuteSecond' }) : formatGameTime(turnDate, { format: 'minuteSecond' })
: '--:--'; : '--:--';
const actionLabel = const actionLabel =
formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ?? formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ??
+6 -1
View File
@@ -10,7 +10,8 @@ import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat'; import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats'; import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor'; import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
@@ -808,6 +809,10 @@ onUnmounted(() => {
</span> </span>
</summary> </summary>
<div v-if="!inheritConfig" class="advanced-body muted">유산 포인트 정보를 불러오지 못했습니다.</div> <div v-if="!inheritConfig" class="advanced-body muted">유산 포인트 정보를 불러오지 못했습니다.</div>
<div v-else-if="inheritConfig.enabled === false" class="advanced-body muted">
통일 이후에는 유산 포인트를 사용하거나 생성 보너스를 받지 않습니다. 기본 옵션으로 장수를 생성할
있습니다.
</div>
<div v-else class="advanced-body inherit-panel"> <div v-else class="advanced-body inherit-panel">
<div class="inherit-summary"> <div class="inherit-summary">
<div>보유 포인트: {{ inheritTotalPoint }}</div> <div>보유 포인트: {{ inheritTotalPoint }}</div>
+56 -3
View File
@@ -89,6 +89,7 @@ const {
tournamentType, tournamentType,
surveyNotice, surveyNotice,
privateMessageNotice, privateMessageNotice,
diplomacyMessageNotice,
messageDraftText, messageDraftText,
targetMailbox, targetMailbox,
mailboxGroups, mailboxGroups,
@@ -168,6 +169,7 @@ const formatRecord = (entry: { text: string; createdAt?: string | Date }, append
}; };
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null; let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
let diplomacyMessageNoticeTimer: ReturnType<typeof setTimeout> | null = null;
let privateMessageNoticeTimer: ReturnType<typeof setTimeout> | null = null; let privateMessageNoticeTimer: ReturnType<typeof setTimeout> | null = null;
watch(surveyNotice, (notice) => { watch(surveyNotice, (notice) => {
if (surveyNoticeTimer) { if (surveyNoticeTimer) {
@@ -187,6 +189,15 @@ watch(privateMessageNotice, (notice) => {
privateMessageNoticeTimer = setTimeout(() => dashboard.dismissPrivateMessageNotice(), 10 * 60_000); privateMessageNoticeTimer = setTimeout(() => dashboard.dismissPrivateMessageNotice(), 10 * 60_000);
} }
}); });
watch(diplomacyMessageNotice, (notice) => {
if (diplomacyMessageNoticeTimer) {
clearTimeout(diplomacyMessageNoticeTimer);
diplomacyMessageNoticeTimer = null;
}
if (notice) {
diplomacyMessageNoticeTimer = setTimeout(() => dashboard.dismissDiplomacyMessageNotice(), 10 * 60_000);
}
});
onUnmounted(() => { onUnmounted(() => {
if (surveyNoticeTimer) { if (surveyNoticeTimer) {
clearTimeout(surveyNoticeTimer); clearTimeout(surveyNoticeTimer);
@@ -194,6 +205,7 @@ onUnmounted(() => {
if (privateMessageNoticeTimer) { if (privateMessageNoticeTimer) {
clearTimeout(privateMessageNoticeTimer); clearTimeout(privateMessageNoticeTimer);
} }
if (diplomacyMessageNoticeTimer) clearTimeout(diplomacyMessageNoticeTimer);
dashboard.stopRealtime(); dashboard.stopRealtime();
window.removeEventListener('storage', handleMobilePanelStorage); window.removeEventListener('storage', handleMobilePanelStorage);
document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder);
@@ -253,6 +265,13 @@ const acknowledgePrivateMessageNotice = async (moveToMessage: boolean) => {
document.querySelector('.PrivateTalk > .stickyAnchor')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelector('.PrivateTalk > .stickyAnchor')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}; };
const acknowledgeDiplomacyMessageNotice = async (moveToMessage: boolean) => {
if (!(await dashboard.acknowledgeDiplomacyMessageNotice())) return;
if (!moveToMessage) return;
await nextTick();
document.querySelector('.DiplomacyTalk > .stickyAnchor')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleNavigationAction = (action: NonNullable<MainNavigationLink['action']>) => { const handleNavigationAction = (action: NonNullable<MainNavigationLink['action']>) => {
if (action === 'show-version') versionDialog.value?.showModal(); if (action === 'show-version') versionDialog.value?.showModal();
}; };
@@ -321,7 +340,7 @@ watch(
<strong>설문조사 안내</strong> <strong>설문조사 안내</strong>
<button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button> <button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button>
</div> </div>
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink> <RouterLink to="/survey" target="_blank" rel="noopener noreferrer">새로운 설문조사가 있습니다.</RouterLink>
</aside> </aside>
<aside <aside
@@ -349,6 +368,32 @@ watch(
</div> </div>
</aside> </aside>
<aside
v-if="diplomacyMessageNotice"
class="private-message-notice diplomacy-message-notice"
:class="{ 'diplomacy-message-notice-stacked': privateMessageNotice }"
role="status"
aria-live="polite"
data-testid="diplomacy-message-notice"
>
<div class="private-message-notice-title">
<strong>새로운 외교 메시지</strong>
<button
type="button"
class="private-message-notice-close"
aria-label="외교 메시지 알림 닫기"
@click="dashboard.dismissDiplomacyMessageNotice"
>
×
</button>
</div>
<p>새로운 외교 메시지가 도착했습니다.</p>
<div class="private-message-notice-actions">
<button type="button" @click="acknowledgeDiplomacyMessageNotice(true)">보러가기</button>
<button type="button" @click="acknowledgeDiplomacyMessageNotice(false)">이미읽음</button>
</div>
</aside>
<section v-if="isMobile" class="layout-mobile"> <section v-if="isMobile" class="layout-mobile">
<template v-for="(panelId, panelIndex) in mobilePanelOrder" :key="panelId"> <template v-for="(panelId, panelIndex) in mobilePanelOrder" :key="panelId">
<div v-if="panelId === 'commands'" class="mobile-panel" data-mobile-panel-id="commands"> <div v-if="panelId === 'commands'" class="mobile-panel" data-mobile-panel-id="commands">
@@ -871,6 +916,10 @@ button {
line-height: 1.3; line-height: 1.3;
} }
.diplomacy-message-notice-stacked {
top: 180px;
}
.private-message-notice-title { .private-message-notice-title {
display: flex; display: flex;
min-height: 35px; min-height: 35px;
@@ -1133,14 +1182,18 @@ button {
.survey-notice { .survey-notice {
z-index: 90; z-index: 90;
bottom: 16px; bottom: 61px;
} }
.private-message-notice { .private-message-notice {
z-index: 90; z-index: 1080;
top: 16px; top: 16px;
} }
.diplomacy-message-notice-stacked {
top: 180px;
}
.layout-mobile [data-main-target='world-history'] { .layout-mobile [data-main-target='world-history'] {
height: 359px; height: 359px;
min-height: 0; min-height: 0;
+2 -1
View File
@@ -3,7 +3,8 @@ import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { mode: clockDisplayMode } = useClockDisplay();
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { formatSeoulDateTime } from '../utils/legacyDateTime';
@@ -148,6 +150,18 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div class="screen-mode-row">
<span
>가속 시간 표시 기준<br /><small
> 기기에 저장하며, 2배속 중에는 시계를 눌러 바꿀 있습니다.</small
></span
>
<div class="button-group" role="radiogroup" aria-label="가속 시간 표시 기준">
<label><input v-model="clockDisplayMode" type="radio" value="game" />게임 시간 기준</label>
<label><input v-model="clockDisplayMode" type="radio" value="real" />실제 시간 기준</label>
</div>
</div>
<div class="mobile-layout-setting-row"> <div class="mobile-layout-setting-row">
<span> <span>
모바일 메인 레이아웃<br /> 모바일 메인 레이아웃<br />
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -700,7 +701,7 @@ onMounted(async () => {
</template> </template>
</td> </td>
<td>{{ general.killTurn }}</td> <td>{{ general.killTurn }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td> <td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -495,7 +497,7 @@ const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue
case 'reservedCommand': case 'reservedCommand':
return commandText(general, false); return commandText(general, false);
case 'turntime': case 'turntime':
return formatServerDateTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' }); return formatGameTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' });
case 'recent_war': case 'recent_war':
return formatServerDateTime(details(general).recentWar, { format: 'minuteSecond', fallback: '-' }); return formatServerDateTime(details(general).recentWar, { format: 'minuteSecond', fallback: '-' });
case 'years_1': case 'years_1':
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief'; import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types'; import type { CommandTable } from '../components/command/types';
@@ -332,7 +333,7 @@ onMounted(load);
> >
</td> </td>
<td>{{ general.killTurn }}</td> <td>{{ general.killTurn }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td> <td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -4,7 +4,8 @@ import { useRouter } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor'; import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
+3 -4
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -25,7 +24,7 @@ const myComment = ref('');
const newVoteTitle = ref(''); const newVoteTitle = ref('');
const newVoteOptionsText = ref(''); const newVoteOptionsText = ref('');
const newVoteMultipleOptions = ref(1); const newVoteMultipleOptions = ref(1);
const router = useRouter(); const closeWindow = (): void => window.close();
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback(); const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
const getErrorMessage = (error: unknown): string => { const getErrorMessage = (error: unknown): string => {
@@ -209,7 +208,7 @@ onMounted(() => {
<button <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn" class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
type="button" type="button"
@click="router.push('/')" @click="closeWindow"
> >
닫기 닫기
</button> </button>
@@ -409,7 +408,7 @@ onMounted(() => {
<button <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn" class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
type="button" type="button"
@click="router.push('/')" @click="closeWindow"
> >
닫기 닫기
</button> </button>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
@@ -55,7 +56,7 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex); .sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-'); const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
const openingTime = computed(() => const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' }) formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
); );
const isParticipant = computed(() => const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value) (snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -170,7 +171,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {}); const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => { const formatTurn = (turnTime: string | null): string => {
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' }); return formatGameTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
}; };
onMounted(() => { onMounted(() => {
@@ -52,6 +52,13 @@ void describe('generalIcon', () => {
); );
}); });
void it('resolves stored NPC message pictures like directory icons across scenarios', () => {
for (const picture of ['롤시나리오/다이애나.png', '장수/관우 1.png', '22.jpg', 'default.jpg']) {
assert.equal(resolveMessageGeneralIconUrl(picture), resolveGeneralIconUrl({ picture, imageServer: 0 }));
}
assert.equal(resolveMessageGeneralIconUrl(null), resolveGeneralIconUrl({ picture: null }));
});
void it('translates legacy message d_pic references without changing absolute or external icons', () => { void it('translates legacy message d_pic references without changing absolute or external icons', () => {
assert.equal( assert.equal(
resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'), resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'),
@@ -4,6 +4,7 @@ import test from 'node:test';
import { import {
millisecondsUntilNextMinute, millisecondsUntilNextMinute,
projectServerClock, projectServerClock,
projectRecoveryTime,
sampleServerClock, sampleServerClock,
} from '../src/utils/serverClockProjection.ts'; } from '../src/utils/serverClockProjection.ts';
@@ -68,3 +69,83 @@ void test('a single browser sample accelerates only inside the recovery window a
assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z'); assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).rate, 1); assert.equal(projectServerClock(sample, 280 * minute).rate, 1);
}); });
void test('waits then accelerates from a partial month and returns to normal without resampling', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-07T00:10:00Z',
serverWallTime: '2026-09-07T00:24:00Z',
clockMode: 'realtime',
clockRunning: false,
clockStartsAt: '2026-09-07T00:35:00Z',
clockRecovery: { startsAt: '2026-09-07T00:35:00Z', endsAt: '2026-09-07T01:00:00Z' },
},
0
);
assert.ok(sample);
for (const elapsed of [0, 660000 - 1, 660000]) {
assert.equal(projectServerClock(sample, elapsed).time.toISOString(), '2026-09-07T00:10:00.000Z');
}
assert.equal(projectServerClock(sample, 660001).time.toISOString(), '2026-09-07T00:10:00.002Z');
assert.equal(projectServerClock(sample, 2160000 - 1).time.toISOString(), '2026-09-07T00:59:59.998Z');
assert.equal(projectServerClock(sample, 2160000 - 1).rate, 2);
assert.equal(projectServerClock(sample, 2160000).time.toISOString(), '2026-09-07T01:00:00.000Z');
assert.equal(projectServerClock(sample, 2160000).rate, 1);
assert.equal(projectServerClock(sample, 2160001).time.toISOString(), '2026-09-07T01:00:00.001Z');
});
void test('actual deadlines compress only the recovery window and preserve the original phase afterward', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-10T01:00:00Z',
serverWallTime: '2026-09-10T02:00:00Z',
clockRunning: true,
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
},
0
);
assert.ok(sample);
for (const [game, actual] of [
['01:20', '02:10'],
['02:20', '02:40'],
['03:20', '03:20'],
['01:00', '02:00'],
['03:00', '03:00'],
]) {
assert.equal(
projectRecoveryTime(sample, new Date(`2026-09-10T${game}:00Z`)).toISOString(),
`2026-09-10T${actual}:00.000Z`
);
}
// Recovery resampling must not move a deadline, even with a skewed browser clock.
const middle = sampleServerClock(
{
serverTime: '2026-09-10T02:00:00Z',
serverWallTime: '2026-09-10T02:30:00Z',
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
},
1234567
);
assert.equal(
projectRecoveryTime(middle, new Date('2026-09-10T02:20:00Z')).toISOString(),
'2026-09-10T02:40:00.000Z'
);
});
void test('actual deadline projection handles waiting, missing metadata, stopped clocks and historical dates', () => {
const input = {
serverTime: '2026-09-10T00:10:00Z',
serverWallTime: '2026-09-10T00:24:00Z',
clockRunning: false,
clockStartsAt: '2026-09-10T00:35:00Z',
clockRecovery: { startsAt: '2026-09-10T00:35:00Z', endsAt: '2026-09-10T01:00:00Z' },
};
const target = new Date('2026-09-10T00:20:00Z');
assert.equal(projectRecoveryTime(sampleServerClock(input, 0), target).toISOString(), '2026-09-10T00:40:00.000Z');
const history = new Date('2026-09-09T23:00:00Z');
assert.equal(projectRecoveryTime(sampleServerClock(input, 0), history), history);
assert.equal(projectRecoveryTime(sampleServerClock({ ...input, clockStartsAt: null }, 0), target), target);
assert.equal(projectRecoveryTime(sampleServerClock({ ...input, clockMode: 'manual' }, 0), target), target);
assert.equal(projectRecoveryTime(sampleServerClock({ ...input, clockRecovery: null }, 0), target), target);
assert.equal(projectRecoveryTime(null, target), target);
});
+20
View File
@@ -2078,6 +2078,26 @@ export const adminRouter = router({
}), }),
}), }),
profiles: router({ profiles: router({
diagnostics: adminProcedure
.input(z.object({ profileName: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
const auth = requireAdminAuth(ctx);
if (!canReadProfile(auth, input.profileName)) throw new TRPCError({ code: 'FORBIDDEN' });
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND' });
const [observation, incidents, processes] = await Promise.all([
ctx.orchestrator.inspectRuntime?.(input.profileName) ?? Promise.resolve(null),
ctx.adminAudit.list({ profileName: input.profileName, targetType: 'profile-runtime', limit: 20 }),
ctx.orchestrator.listRuntimeStates([input.profileName]).catch(() => []),
]);
return {
profileName: input.profileName,
status: profile.status,
observation,
runtime: processes[0] ?? null,
incidents,
};
}),
getResetDefaults: adminProcedure getResetDefaults: adminProcedure
.input(z.object({ profileName: z.string().min(1) })) .input(z.object({ profileName: z.string().min(1) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -20,7 +20,7 @@ import {
type GameCancellationHistoryMode, type GameCancellationHistoryMode,
type GameCancellationResult, type GameCancellationResult,
} from '@sammo-ts/game-engine/scenario/gameCancellation.js'; } from '@sammo-ts/game-engine/scenario/gameCancellation.js';
import { gatewayProfileCapabilities } from '@sammo-ts/common'; import { gatewayProfileCapabilities, type ProfileRuntimeDiagnostics } from '@sammo-ts/common';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
createRedisConnector, createRedisConnector,
@@ -177,6 +177,7 @@ export interface GatewayOrchestratorHandle {
}>; }>;
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>; listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>; listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
inspectRuntime?(profileName: string): Promise<ProfileRuntimeDiagnostics>;
transitionProfileClock( transitionProfileClock(
profileName: string, profileName: string,
action: 'SUSPEND' | 'RESUME', action: 'SUSPEND' | 'RESUME',
@@ -1164,6 +1165,94 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return snapshots; return snapshots;
} }
async inspectRuntime(profileName: string): Promise<ProfileRuntimeDiagnostics> {
const processes = await this.processManager.list().catch(() => null);
const empty: ProfileRuntimeDiagnostics = {
profileName,
checkedAt: new Date().toISOString(),
database: 'UNINITIALIZED',
processObservation: processes ? 'AVAILABLE' : 'UNAVAILABLE',
processes: (processes ?? [])
.filter((process) => process.name.startsWith(`sammo:${profileName}:`))
.map((process) => ({
name: process.name,
status: process.status,
restartCount: process.restartCount ?? 0,
exitCode: process.exitCode ?? null,
})),
lease: null,
clock: null,
};
const profile = await this.repository.getProfile(profileName);
if (!profile || profile.currentScenario === null) return empty;
const connector = createGamePostgresConnector({
url: this.resolveProfileDatabaseUrl(profile),
maxConnections: 1,
connectionTimeoutMillis: 3000,
});
try {
await connector.connect();
return await connector.prisma.$transaction(
async (db) => {
await db.$executeRaw`SET LOCAL statement_timeout = '3000ms'`;
const [time] = await db.$queryRaw<
Array<{ now: Date }>
>`SELECT clock_timestamp() AT TIME ZONE 'UTC' AS now`;
const lease = await db.turnDaemonLease.findUnique({ where: { profile: profileName } });
const clock = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
clockPhase: true,
clockRevision: true,
clockTick: true,
lastTurnTick: true,
currentYear: true,
currentMonth: true,
clockWallAnchor: true,
clockRecoveryStartWallAt: true,
clockRecoveryEndTick: true,
},
});
const now = time!.now;
return {
...empty,
checkedAt: now.toISOString(),
database: 'AVAILABLE' as const,
lease: lease
? {
ownerId: lease.ownerId,
fencingEpoch: lease.fencingEpoch.toString(),
heartbeatAt: lease.heartbeatAt.toISOString(),
leaseUntil: lease.leaseUntil.toISOString(),
heartbeatAgeMs: Math.max(0, now.getTime() - lease.heartbeatAt.getTime()),
valid: lease.leaseUntil > now,
clockReady: lease.clockReady,
}
: null,
clock: clock
? {
phase: clock.clockPhase,
revision: clock.clockRevision.toString(),
tick: clock.clockTick?.toString() ?? null,
lastTurnTick: clock.lastTurnTick?.toString() ?? null,
year: clock.currentYear,
month: clock.currentMonth,
wallAnchor: clock.clockWallAnchor?.toISOString() ?? null,
recoveryStartWallAt: clock.clockRecoveryStartWallAt?.toISOString() ?? null,
recoveryEndTick: clock.clockRecoveryEndTick?.toString() ?? null,
}
: null,
};
},
{ timeout: 5000, maxWait: 3000 }
);
} catch {
return { ...empty, checkedAt: new Date().toISOString(), database: 'UNAVAILABLE' };
} finally {
await connector.disconnect().catch(() => undefined);
}
}
async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> { async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> {
const allowedAutorunOptions = new Set<RuntimeAutorunOption>([ const allowedAutorunOptions = new Set<RuntimeAutorunOption>([
'develop', 'develop',
@@ -123,6 +123,12 @@ export class Pm2ProcessManager implements ProcessManager {
cwd: item.pm2_env?.pm_cwd ?? undefined, cwd: item.pm2_env?.pm_cwd ?? undefined,
script: item.pm2_env?.pm_exec_path ?? undefined, script: item.pm2_env?.pm_exec_path ?? undefined,
restartCount: item.pm2_env?.restart_time ?? 0, restartCount: item.pm2_env?.restart_time ?? 0,
exitCode:
item.pm2_env &&
'exit_code' in item.pm2_env &&
typeof item.pm2_env.exit_code === 'number'
? item.pm2_env.exit_code
: undefined,
})) ?? []; })) ?? [];
resolve(normalized); resolve(normalized);
}); });
@@ -145,16 +151,13 @@ export class Pm2ProcessManager implements ProcessManager {
reject(new Error(`PM2 process name already exists: ${definition.name}`)); reject(new Error(`PM2 process name already exists: ${definition.name}`));
return; return;
} }
pm2.start( pm2.start(buildPm2StartOptions(definition), (error) => {
buildPm2StartOptions(definition), if (error) {
(error) => { reject(error);
if (error) { return;
reject(error);
return;
}
resolve();
} }
); resolve();
});
}); });
}) })
); );
@@ -5,6 +5,7 @@ export interface ManagedProcessInfo {
cwd?: string; cwd?: string;
script?: string; script?: string;
restartCount?: number; restartCount?: number;
exitCode?: number;
} }
export interface ProcessDefinition { export interface ProcessDefinition {
@@ -435,6 +435,35 @@ describe('admin profile navigation API', () => {
}); });
}); });
describe('runtime diagnostics authorization', () => {
it('allows the scoped administrator and rejects another profile scope', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.runtime:che:2'], firstUserIsAdmin: false }
);
await expect(harness.caller.admin.profiles.diagnostics({ profileName: 'che:2' })).resolves.toMatchObject({
profileName: 'che:2',
incidents: [],
});
await expect(harness.caller.admin.profiles.diagnostics({ profileName: 'kwe:2' })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects users without profile administration permission', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: [], firstUserIsAdmin: false }
);
await expect(harness.caller.admin.profiles.diagnostics({ profileName: 'che:2' })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
describe('admin scenario catalog API', () => { describe('admin scenario catalog API', () => {
it('marks scenario zero as the current selectable scenario', async () => { it('marks scenario zero as the current selectable scenario', async () => {
const harness = await buildCaller( const harness = await buildCaller(
@@ -174,6 +174,36 @@ const postTrpc = async (
}; };
describe('admin security over HTTP transport', () => { describe('admin security over HTTP transport', () => {
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
const harness = await createHarness(['admin.profiles.runtime:che:default']);
const input = { profileName: 'che:default' };
expect((await postTrpc(harness.baseUrl, 'admin.profiles.diagnostics', input)).response.status).toBe(401);
expect(
(await postTrpc(harness.baseUrl, 'admin.profiles.diagnostics', input, harness.adminSessionToken)).response
.status
).toBe(200);
expect(
(
await postTrpc(
harness.baseUrl,
'admin.profiles.diagnostics',
{ profileName: 'kwe:default' },
harness.adminSessionToken
)
).response.status
).toBe(403);
expect(
(
await postTrpc(
harness.baseUrl,
'admin.profiles.diagnostics',
{ profileName: '' },
harness.adminSessionToken
)
).response.status
).toBe(400);
});
it('accepts query input from a POST JSON body but still rejects a mutation sent as GET', async () => { it('accepts query input from a POST JSON body but still rejects a mutation sent as GET', async () => {
const harness = await createHarness(); const harness = await createHarness();
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const world = await connector.prisma.worldState.findFirstOrThrow(); const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({ expect(world).toMatchObject({
scenarioCode: '1010', scenarioCode: '1010',
clockWallAnchor: new Date('2036-03-03T02:11:00.000Z'), clockWallAnchor: new Date('2036-03-03T02:10:30.000Z'),
clockBaseTime: new Date('2036-03-03T02:10:30.000Z'),
clockPhase: 'PREOPEN', clockPhase: 'PREOPEN',
meta: { meta: {
firstGameIdx: 0, firstGameIdx: 0,

Some files were not shown because too many files have changed in this diff Show More