Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
957a599608 | ||
|
|
73e1743a3d | ||
|
|
3a5d4e7833 | ||
|
|
3f4e5095b5 | ||
|
|
a00f46dfc8 | ||
|
|
9c73085353 | ||
|
|
604b951eff | ||
|
|
09f4e60df4 | ||
|
|
d191960ea1 | ||
|
|
c1fd79da19 | ||
|
|
d8c9a63915 | ||
|
|
c39527bdd7 | ||
|
|
d57fd3e553 | ||
|
|
bdb4e22028 | ||
|
|
b2be86a9a2 | ||
|
|
69f461612d | ||
|
|
9d5447dd3f | ||
|
|
27049dae1f | ||
|
|
09dfe96ff1 | ||
|
|
b14d8d52f6 | ||
|
|
4d64978cb6 |
@@ -1,5 +1,6 @@
|
||||
import type { GameApiContext, WorldStateRow } from '../context.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
||||
|
||||
export type MapCityCompact = [number, number, number, number, number, number];
|
||||
@@ -16,6 +17,7 @@ export type BaseMapResult = {
|
||||
initialLevel: number;
|
||||
increaseYears: number;
|
||||
};
|
||||
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
|
||||
cityList: MapCityCompact[];
|
||||
nationList: MapNationCompact[];
|
||||
};
|
||||
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
|
||||
const BASE_MAP_TTL_SECONDS = 30;
|
||||
const PUBLIC_MAP_TTL_SECONDS = 600;
|
||||
|
||||
const resolveStartYear = (worldState: WorldStateRow): number => {
|
||||
const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||
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> => {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
@@ -154,7 +178,10 @@ const loadBaseMap = async (
|
||||
}
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as BaseMapResult;
|
||||
const parsed = JSON.parse(cached) as BaseMapResult;
|
||||
if (parsed.uniqueItemLimit) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Ignore cache parse errors.
|
||||
}
|
||||
@@ -207,6 +234,7 @@ const loadBaseMap = async (
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
techLevelLimit: resolveTechLevelLimit(worldState),
|
||||
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
|
||||
cityList,
|
||||
nationList,
|
||||
};
|
||||
|
||||
@@ -57,10 +57,17 @@ const formatMessageTime = (value: Date): string => {
|
||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||
const payload = parsePayload(row.message);
|
||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
||||
const actionUnavailable =
|
||||
actionStatus !== null &&
|
||||
(actionStatus !== 'PENDING' ||
|
||||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
|
||||
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
|
||||
const actionState =
|
||||
actionStatus === null
|
||||
? null
|
||||
: actionStatus === 'PENDING'
|
||||
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
|
||||
? 'expired'
|
||||
: 'pending'
|
||||
: actionStatus === 'RESOLVED'
|
||||
? 'resolved'
|
||||
: 'unavailable';
|
||||
return {
|
||||
id: row.id,
|
||||
msgType: row.type,
|
||||
@@ -68,8 +75,8 @@ const toMessageView = (row: MessageRow, currentGameTick: bigint | null): Message
|
||||
dest: row.type === 'public' ? null : payload.dest,
|
||||
text: payload.text,
|
||||
option:
|
||||
actionUnavailable && payload.option && typeof payload.option === 'object'
|
||||
? { ...payload.option, used: true, invalid: true }
|
||||
actionState !== null && payload.option && typeof payload.option === 'object'
|
||||
? { ...payload.option, actionState, used: actionState !== 'pending' }
|
||||
: (payload.option ?? null),
|
||||
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
||||
};
|
||||
|
||||
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
|
||||
color: '#000000',
|
||||
};
|
||||
|
||||
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
|
||||
|
||||
export const resolveNationInfo = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number
|
||||
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
|
||||
|
||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`,
|
||||
icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import { z } from 'zod';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
} from '@sammo-ts/infra';
|
||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import {
|
||||
isWarTraitKey,
|
||||
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 inheritTotalPoint = ctx.auth?.user.id
|
||||
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
||||
@@ -376,6 +377,7 @@ export const joinRouter = router({
|
||||
npcGeneralCount,
|
||||
},
|
||||
inherit: {
|
||||
enabled: history?.status !== 'COMPLETED',
|
||||
totalPoint: inheritTotalPoint,
|
||||
costs: {
|
||||
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
||||
@@ -595,10 +597,10 @@ export const joinRouter = router({
|
||||
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({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
||||
message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const worldState = await transaction.worldState.findFirst();
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { TournamentState } from '../../tournament/types.js';
|
||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.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 { loadCurrentGameTime } from '../../services/gameClock.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);
|
||||
};
|
||||
|
||||
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
const adminProcedure = engineAuthedProcedure
|
||||
.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
||||
}
|
||||
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>(
|
||||
ctx: {
|
||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
||||
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
|
||||
profile: { name: string };
|
||||
tournamentMutationLockHeld?: boolean;
|
||||
},
|
||||
store: TournamentStore,
|
||||
operation: () => Promise<T>
|
||||
@@ -69,7 +79,9 @@ const withTournamentClockMutation = async <T>(
|
||||
deadlineGeneration: fence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
};
|
||||
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
|
||||
return store.withClockContext(clockContext, () =>
|
||||
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
|
||||
);
|
||||
};
|
||||
|
||||
const withTournamentBetClockMutation = async <T>(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
assertReservedTurnActionAvailable,
|
||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||
buildEquipmentTradeItemOptions,
|
||||
loadEquipmentTradeItemOrder,
|
||||
parseReservedTurnArgs,
|
||||
TURN_COMMAND_NATION_COLORS,
|
||||
type TurnCommandInputOptions,
|
||||
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
traits,
|
||||
moduleBundle,
|
||||
map,
|
||||
itemOrder,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -330,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
loadBattleSimTraitOptions(),
|
||||
moduleBundlePromise,
|
||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||
loadEquipmentTradeItemOrder(worldState.scenarioCode),
|
||||
]);
|
||||
|
||||
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])),
|
||||
});
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
itemOrder,
|
||||
configConst: asRecord(asRecord(worldState.config).const),
|
||||
itemModules: moduleBundle.itemModules,
|
||||
currentSecurity: city?.security ?? 0,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
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 { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
@@ -102,10 +102,7 @@ export const hasPollEnded = (
|
||||
time: CurrentGameTime
|
||||
): boolean =>
|
||||
Boolean(poll.closed_at) ||
|
||||
Boolean(
|
||||
poll.end_at &&
|
||||
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
|
||||
);
|
||||
Boolean(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 => {
|
||||
if (!date) return null;
|
||||
@@ -290,7 +287,9 @@ export const voteRouter = router({
|
||||
userCnt,
|
||||
};
|
||||
}),
|
||||
submitVote: authedProcedure
|
||||
// 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
|
||||
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
|
||||
submitVote: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
voteId: z.number().int().positive(),
|
||||
@@ -366,7 +365,6 @@ export const voteRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
||||
}
|
||||
|
||||
ctx.changeJournal?.mark('front.general', general.id);
|
||||
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
||||
}),
|
||||
addComment: authedProcedure
|
||||
|
||||
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { z } from 'zod';
|
||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||
|
||||
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||
|
||||
@@ -120,8 +121,29 @@ const plainLegacyInfo = (value: string): string =>
|
||||
.replace(/\s+/gu, ' ')
|
||||
.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: {
|
||||
configConst: Record<string, unknown>;
|
||||
itemOrder?: readonly string[];
|
||||
itemModules: readonly EquipmentTradeItemModule[];
|
||||
currentSecurity: number;
|
||||
generalGold: number;
|
||||
@@ -148,8 +170,11 @@ export const buildEquipmentTradeItemOptions = (options: {
|
||||
];
|
||||
}
|
||||
|
||||
for (const item of options.itemModules) {
|
||||
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
|
||||
// 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
|
||||
const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
|
||||
for (const key of orderedKeys) {
|
||||
const item = catalog.get(key);
|
||||
if (!item?.buyable || !purchasableItemKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const cost = item.cost ?? 0;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||
buildEquipmentTradeItemOptions,
|
||||
loadEquipmentTradeItemOrder,
|
||||
buildTurnCommandInputFields,
|
||||
parseReservedTurnArgs,
|
||||
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 () => {
|
||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
|
||||
@@ -245,12 +298,28 @@ describe('turn command argument input', () => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||
configConst: {},
|
||||
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
||||
itemModules: [
|
||||
buildShopItem('event_전투특기_격노', '격노의 비급'),
|
||||
buildShopItem('che_계략_향낭', '향낭'),
|
||||
buildShopItem('che_계략_이추', '이추'),
|
||||
buildShopItem('che_훈련_청주', '청주'),
|
||||
buildShopItem('che_사기_탁주', '탁주'),
|
||||
buildShopItem('che_저격_수극', '수극'),
|
||||
buildShopItem('che_치료_환약', '환약'),
|
||||
],
|
||||
currentSecurity: 5000,
|
||||
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 · 환약 · 설명');
|
||||
});
|
||||
|
||||
|
||||
@@ -229,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
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');
|
||||
}, 60_000);
|
||||
|
||||
@@ -559,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
||||
);
|
||||
}, 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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const db = buildDatabase('realtime', 'RUNNING');
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -128,64 +128,69 @@ describe('messages router missing-flow compatibility', () => {
|
||||
expect(result.canRespondDiplomacy).toBe(false);
|
||||
});
|
||||
|
||||
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
||||
const diplomacyRow = {
|
||||
id: 19,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9002,
|
||||
dest: 9001,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '외교관',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
it.each([null, 'RESOLVED'])(
|
||||
'redacts recent and old diplomacy content below secret permission 3 (%s)',
|
||||
async (actionStatus) => {
|
||||
const diplomacyRow = {
|
||||
id: 19,
|
||||
action_status: actionStatus,
|
||||
expires_game_tick: null,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9002,
|
||||
dest: 9001,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '외교관',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
},
|
||||
text: '보이면 안 되는 외교 본문',
|
||||
option: { action: 'noAggression' },
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
};
|
||||
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: queryRaw,
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
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 old = await caller.messages.getOld({
|
||||
generalId: general.id,
|
||||
type: 'diplomacy',
|
||||
to: 20,
|
||||
});
|
||||
const recent = await caller.messages.getRecent({ generalId: general.id });
|
||||
const old = await caller.messages.getOld({
|
||||
generalId: general.id,
|
||||
type: 'diplomacy',
|
||||
to: 20,
|
||||
});
|
||||
|
||||
expect(recent.permission).toBe(2);
|
||||
expect(recent.diplomacy[0]).toMatchObject({
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { action: 'noAggression', permissionRedacted: true },
|
||||
});
|
||||
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||
expect(old.diplomacy[0]).toMatchObject({
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { action: 'noAggression', permissionRedacted: true },
|
||||
});
|
||||
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||
});
|
||||
expect(recent.permission).toBe(2);
|
||||
expect(recent.diplomacy[0]).toMatchObject({
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { action: 'noAggression', permissionRedacted: true },
|
||||
});
|
||||
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||
expect(old.diplomacy[0]).toMatchObject({
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { action: 'noAggression', permissionRedacted: true },
|
||||
});
|
||||
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||
}
|
||||
);
|
||||
|
||||
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||
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(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
|
||||
}, 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 { resolveNationPermission } from '../src/router/nation/shared.js';
|
||||
|
||||
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
||||
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,
|
||||
});
|
||||
}, 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 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 { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
|
||||
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 () => {
|
||||
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||
|
||||
@@ -216,6 +216,40 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
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', () => {
|
||||
const now = new Date('2026-07-26T00:00:00Z');
|
||||
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(
|
||||
false
|
||||
);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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';
|
||||
|
||||
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
|
||||
db: { $queryRaw: queryRaw },
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
|
||||
'sammo:map:base:hwe:scenario_2400:pg12'
|
||||
);
|
||||
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
|
||||
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
|
||||
'sammo:map:public:hwe:scenario_2400:pg12'
|
||||
);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
||||
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([]);
|
||||
});
|
||||
|
||||
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
|
||||
const first = await loadWorldMap(ctx, { generalId: 7, 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(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
|
||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
|
||||
expect(shared).not.toHaveProperty('shownByGeneralList');
|
||||
expect(shared).not.toHaveProperty('myCity');
|
||||
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 () => {
|
||||
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
|
||||
redis,
|
||||
db: {
|
||||
$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;
|
||||
|
||||
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
|
||||
nationId: options.bidder.nationId,
|
||||
nationName: options.nation?.name ?? '재야',
|
||||
color: options.nation?.color ?? '#000000',
|
||||
icon: options.bidder.picture ?? '',
|
||||
icon: resolveMessageTargetIcon(options.bidder),
|
||||
},
|
||||
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
||||
time: new Date(options.time.getTime()),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
@@ -124,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
|
||||
nationId: options.bidder.nationId,
|
||||
nationName: options.nation?.name ?? '재야',
|
||||
color: options.nation?.color ?? '#000000',
|
||||
icon: options.bidder.picture ?? '',
|
||||
icon: resolveMessageTargetIcon(options.bidder),
|
||||
},
|
||||
text: options.text,
|
||||
time: new Date(options.time.getTime()),
|
||||
|
||||
@@ -169,6 +169,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
AND (
|
||||
${gameplayAllowed}
|
||||
OR "event_type" = 'getStatus'
|
||||
OR (
|
||||
${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'}
|
||||
AND "event_type" IN (
|
||||
'joinCreateGeneral', 'npcPossessGeneral',
|
||||
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
|
||||
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
|
||||
}
|
||||
|
||||
export class TurnDaemonLeaseLostError extends Error {
|
||||
constructor(profile: string) {
|
||||
super(`Turn daemon lease was lost for profile "${profile}".`);
|
||||
constructor(profile: string, reason?: string) {
|
||||
super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
|
||||
this.name = 'TurnDaemonLeaseLostError';
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export class DatabaseTurnDaemonLease {
|
||||
private expiryTimer: NodeJS.Timeout | null = null;
|
||||
private renewalInFlight = false;
|
||||
private lost = false;
|
||||
private lossReason: string | undefined;
|
||||
|
||||
private constructor(
|
||||
db: GamePrismaClient,
|
||||
@@ -116,6 +117,7 @@ export class DatabaseTurnDaemonLease {
|
||||
fencingEpoch: BigInt(row.fencing_epoch),
|
||||
};
|
||||
this.lost = false;
|
||||
this.lossReason = undefined;
|
||||
this.scheduleExpiryWatchdog(requestStartedAt);
|
||||
if (this.heartbeatEnabled) {
|
||||
this.startHeartbeat();
|
||||
@@ -139,6 +141,10 @@ export class DatabaseTurnDaemonLease {
|
||||
return this.lost;
|
||||
}
|
||||
|
||||
getLossError(): TurnDaemonLeaseLostError {
|
||||
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
|
||||
}
|
||||
|
||||
async renew(): Promise<boolean> {
|
||||
const token = this.token;
|
||||
if (!token || this.lost || this.renewalInFlight) {
|
||||
@@ -160,7 +166,7 @@ export class DatabaseTurnDaemonLease {
|
||||
RETURNING "profile", "owner_id", "fencing_epoch"
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
this.markLost();
|
||||
this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
|
||||
return false;
|
||||
}
|
||||
if (this.lost) {
|
||||
@@ -176,7 +182,7 @@ export class DatabaseTurnDaemonLease {
|
||||
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
|
||||
const token = this.token;
|
||||
if (!token || this.lost) {
|
||||
throw new TurnDaemonLeaseLostError(this.profile);
|
||||
throw this.getLossError();
|
||||
}
|
||||
const db = transaction ?? this.db;
|
||||
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
||||
@@ -190,8 +196,8 @@ export class DatabaseTurnDaemonLease {
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
this.markLost();
|
||||
throw new TurnDaemonLeaseLostError(this.profile);
|
||||
this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
|
||||
throw this.getLossError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +234,10 @@ export class DatabaseTurnDaemonLease {
|
||||
}
|
||||
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
void this.renew().catch(() => {
|
||||
this.markLost();
|
||||
void this.renew().catch((error: unknown) => {
|
||||
this.markLost(
|
||||
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
|
||||
);
|
||||
});
|
||||
}, intervalMs);
|
||||
this.heartbeatTimer.unref();
|
||||
@@ -248,7 +256,9 @@ export class DatabaseTurnDaemonLease {
|
||||
this.stopExpiryWatchdog();
|
||||
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
|
||||
this.expiryTimer = setTimeout(() => {
|
||||
this.markLost();
|
||||
this.markLost(
|
||||
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
|
||||
);
|
||||
}, remainingMs);
|
||||
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.stopHeartbeat();
|
||||
this.stopExpiryWatchdog();
|
||||
|
||||
@@ -90,7 +90,20 @@ export class TurnDaemonLifecycle {
|
||||
|
||||
start(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -242,20 +242,23 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||
const sync = install?.sync ?? false;
|
||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||
const gameClockMode = options.gameClockMode ?? 'realtime';
|
||||
// A realtime season prepared before its formal opening must not consume
|
||||
// wall time while users are only allowed to edit reserved commands.
|
||||
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
|
||||
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
|
||||
const openingFloor = cutTurn(requestedOpening, turnTermMinutes);
|
||||
const initialClockWallAnchor =
|
||||
gameClockMode === 'manual'
|
||||
? requestedOpening
|
||||
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0));
|
||||
// Opening is an exact wall instant, independent of the calendar's 12-turn
|
||||
// grouping. Only the initial year/month uses the legacy calendar alignment.
|
||||
const initialClockWallAnchor = requestedOpening;
|
||||
const startState = resolveStartState(
|
||||
scenario.startYear ?? null,
|
||||
gameClockMode === 'manual' ? now : requestedOpening,
|
||||
turnTermMinutes,
|
||||
sync
|
||||
);
|
||||
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
|
||||
const initialClock = new GameClock({
|
||||
baseTime: startState.startTime,
|
||||
baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
|
||||
tick: 0,
|
||||
mode: gameClockMode,
|
||||
wallAnchor: initialClockWallAnchor,
|
||||
@@ -324,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
// monthly pre-handler recalculates the same value at each boundary.
|
||||
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
||||
starttime: formatDateTime(startState.startTime),
|
||||
turntime: formatDateTime(now),
|
||||
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
||||
opentime: formatDateTime(initialClockWallAnchor),
|
||||
lastTurnTime: formatDateTime(now),
|
||||
lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
||||
};
|
||||
|
||||
const firstGameIdx =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
|
||||
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
|
||||
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
|
||||
@@ -253,7 +254,8 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
}))
|
||||
.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({
|
||||
where: {
|
||||
userId_key: { userId: entry.userId!, key: 'tournament' },
|
||||
|
||||
@@ -192,6 +192,13 @@ const respondToScout = async (options: {
|
||||
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 sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
||||
if (response) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
|
||||
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
|
||||
import { createTurnDaemonRuntime } from './turnDaemon.js';
|
||||
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
|
||||
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
||||
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||
|
||||
export interface TurnDaemonCliOptions {
|
||||
profile?: string;
|
||||
@@ -89,6 +91,27 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
|
||||
pauseGateIntervalMs,
|
||||
adminActionIntervalMs,
|
||||
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({
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
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 {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
persistMessageEnvelope,
|
||||
writeReadModelChangeJournal,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
@@ -302,6 +311,47 @@ export const applyNextClockProjection = async (options: {
|
||||
throw new Error('Clock projection final RUNNING transition fence failed.');
|
||||
}
|
||||
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({
|
||||
where: { id: outbox.id },
|
||||
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
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,
|
||||
* 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();
|
||||
// 설문 완료 여부는 투표한 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, 'city.content', changes.cityIds);
|
||||
markIds(journal, 'nation.content', changes.nationIds);
|
||||
@@ -602,7 +616,8 @@ const persistNationBettingOpen = async (
|
||||
|
||||
const persistNationBettingFinish = async (
|
||||
prisma: GamePrisma.TransactionClient,
|
||||
finish: PendingNationBettingFinish
|
||||
finish: PendingNationBettingFinish,
|
||||
recordsFinalized: boolean
|
||||
): Promise<void> => {
|
||||
await prisma.$queryRaw`
|
||||
SELECT id
|
||||
@@ -652,7 +667,7 @@ const persistNationBettingFinish = async (
|
||||
})),
|
||||
});
|
||||
|
||||
for (const reward of rewards) {
|
||||
for (const reward of recordsFinalized ? [] : rewards) {
|
||||
if (!reward.userId) {
|
||||
continue;
|
||||
}
|
||||
@@ -1451,11 +1466,12 @@ export const createDatabaseTurnHooks = async (
|
||||
`);
|
||||
}
|
||||
|
||||
const recordsFinalized = await areSeasonRecordsFinalized(prisma, asRecord(state.meta).serverId);
|
||||
for (const betting of pendingNationBettingOpens) {
|
||||
await persistNationBettingOpen(prisma, betting);
|
||||
}
|
||||
for (const finish of pendingNationBettingFinishes) {
|
||||
await persistNationBettingFinish(prisma, finish);
|
||||
await persistNationBettingFinish(prisma, finish, recordsFinalized);
|
||||
}
|
||||
|
||||
const meta = asRecord(state.meta);
|
||||
@@ -1464,7 +1480,7 @@ export const createDatabaseTurnHooks = async (
|
||||
const persistInheritancePointAdjustments = async (
|
||||
entries: typeof inheritancePointAdjustments
|
||||
): Promise<void> => {
|
||||
if (entries.length === 0) {
|
||||
if (recordsFinalized || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
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> => {
|
||||
if (entries.length === 0) {
|
||||
if (recordsFinalized || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
await prisma.inheritanceLog.createMany({
|
||||
@@ -1899,6 +1915,37 @@ export const createDatabaseTurnHooks = async (
|
||||
{ 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) {
|
||||
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
||||
}
|
||||
@@ -1978,7 +2025,7 @@ export const createDatabaseTurnHooks = async (
|
||||
if (worldReadModelSignature !== worldReadModelBaseline) {
|
||||
readModelChanges.worldChanged = true;
|
||||
}
|
||||
const journal = createReadModelChangeJournal(readModelChanges);
|
||||
const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
|
||||
if (hasDashboardSourceMutation(changes, readModelChanges)) {
|
||||
journal.mark('dashboard.global');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
export interface GatewayProfileGateOptions {
|
||||
@@ -8,6 +9,7 @@ export interface GatewayProfileGateOptions {
|
||||
gatewayDatabaseUrl?: string;
|
||||
profileName: string;
|
||||
cacheMs?: number;
|
||||
incidentContext?: () => Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
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> => {
|
||||
const connector = createGatewayPostgresConnector({
|
||||
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
|
||||
connectionTimeoutMillis: 3000,
|
||||
});
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
@@ -54,19 +57,44 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
||||
return cachedPause;
|
||||
},
|
||||
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 {
|
||||
await prisma.gatewayProfile.updateMany({
|
||||
where: {
|
||||
profileName: options.profileName,
|
||||
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
|
||||
},
|
||||
data: {
|
||||
status: 'PAUSED',
|
||||
lastError: message,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.gatewayProfile.updateMany({
|
||||
where: {
|
||||
profileName: options.profileName,
|
||||
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
|
||||
OR: [{ status: { not: 'PAUSED' } }, { lastError: { not: message } }, { lastError: null }],
|
||||
},
|
||||
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 {
|
||||
if (failure) console.error('[turn-daemon] failed to persist runtime incident', failure);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||
import {
|
||||
asRecord,
|
||||
HALL_OF_FAME_TYPES,
|
||||
@@ -403,10 +404,15 @@ export const persistGeneralLifecycleEvents = async (
|
||||
data: { refreshScore: 0 },
|
||||
});
|
||||
|
||||
const recordsFinalized = await areSeasonRecordsFinalized(prisma, worldMeta.serverId);
|
||||
for (const event of events) {
|
||||
if (event.outcome === 'detached' || event.outcome === 'deleted') {
|
||||
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') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import type {
|
||||
TurnSchedule,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
|
||||
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
GameClock,
|
||||
assertGameplayCommitAllowed,
|
||||
inferClockPhase,
|
||||
JosaUtil,
|
||||
type GameClockMode,
|
||||
type GameClockPhase,
|
||||
type TurnRecoveryWindow,
|
||||
@@ -78,6 +79,7 @@ export interface GeneralTurnResult {
|
||||
troopIds?: number[];
|
||||
};
|
||||
destroyedNationIds?: number[];
|
||||
successorlessNationId?: number;
|
||||
lifecycleEvent?: GeneralLifecycleEvent;
|
||||
}
|
||||
|
||||
@@ -2028,6 +2030,11 @@ export class InMemoryTurnWorld {
|
||||
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) {
|
||||
this.removeGeneral(currentGeneral.id);
|
||||
}
|
||||
@@ -2227,6 +2234,64 @@ export class InMemoryTurnWorld {
|
||||
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 {
|
||||
const nation = this.nations.get(nationId);
|
||||
if (!nation) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
@@ -613,14 +614,20 @@ export const createGeneralFromJoin = async (options: {
|
||||
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
||||
const inheritConstants = resolveInheritConstants(worldState);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const recordsFinalized = await areSeasonRecordsFinalized(db, worldMeta.serverId);
|
||||
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
||||
const currentInheritancePoint = await applyInheritanceUser(
|
||||
db,
|
||||
input.userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth
|
||||
);
|
||||
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
|
||||
if (recordsFinalized && inheritRequiredPoint > 0) {
|
||||
fail('BAD_REQUEST', '통일 이후에는 유산 포인트를 사용하는 생성 옵션을 적용할 수 없습니다.');
|
||||
}
|
||||
const currentInheritancePoint = recordsFinalized
|
||||
? (await db.inheritancePoint.findMany({ where: { userId: input.userId }, select: { value: true } })).reduce(
|
||||
(sum, row) => sum + row.value,
|
||||
0
|
||||
)
|
||||
: await applyInheritanceUser(db, input.userId, worldState.currentYear, worldState.currentMonth);
|
||||
if (!recordsFinalized) {
|
||||
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
|
||||
}
|
||||
if (currentInheritancePoint < inheritRequiredPoint) {
|
||||
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
||||
}
|
||||
@@ -743,7 +750,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
? input.ownerIconRevision
|
||||
: undefined;
|
||||
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;
|
||||
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
|
||||
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
@@ -127,7 +128,7 @@ export const createOpenNationBettingHandler = (options: {
|
||||
nationId: general.nationId,
|
||||
nationName: nation?.name ?? '재야',
|
||||
color: nation?.color ?? '#000000',
|
||||
icon: general.picture ?? '',
|
||||
icon: resolveMessageTargetIcon(general),
|
||||
},
|
||||
text,
|
||||
time: now,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { immediateRecoveryLimitSeconds } from '@sammo-ts/common';
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
import {
|
||||
readClockDatabaseWall,
|
||||
@@ -27,8 +28,12 @@ export const prepareRealtimeRecovery = async (
|
||||
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
|
||||
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()}`;
|
||||
await startClockSuspension({
|
||||
db,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||
import type {
|
||||
ActionContextBase,
|
||||
ActionContextBuilder,
|
||||
@@ -2121,7 +2122,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
nationId: currentGeneral.nationId,
|
||||
nationName: currentNation?.name ?? '재야',
|
||||
color: currentNation?.color ?? '#000000',
|
||||
icon: currentGeneral.picture ?? '',
|
||||
icon: resolveMessageTargetIcon(currentGeneral),
|
||||
};
|
||||
messages.push({
|
||||
msgType: 'public',
|
||||
@@ -2252,6 +2253,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
? 'retired'
|
||||
: 'active';
|
||||
let deleteGeneral = false;
|
||||
let successorlessNationId: number | undefined;
|
||||
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
||||
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
||||
if (currentGeneral.meta.killturn <= 0) {
|
||||
@@ -2351,6 +2353,10 @@ export const createReservedTurnHandler = async (options: {
|
||||
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Ref nextRuler()는 후계자가 없으면 군주 삭제 전에
|
||||
// deleteNation($general, true)로 국가 전체를 정산한다.
|
||||
successorlessNationId = currentNation.id;
|
||||
}
|
||||
}
|
||||
if (currentGeneral.troopId === currentGeneral.id) {
|
||||
@@ -2424,6 +2430,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
: undefined),
|
||||
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
|
||||
...(successorlessNationId !== undefined ? { successorlessNationId } : {}),
|
||||
lifecycleEvent: {
|
||||
generalId: currentGeneral.id,
|
||||
outcome: lifecycleOutcome,
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -888,6 +888,19 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
|
||||
profileName: options.profileName,
|
||||
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;
|
||||
if (gatewayGate) {
|
||||
@@ -1060,7 +1073,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
hooks,
|
||||
pauseGate: async () => {
|
||||
if (turnDaemonLease?.isLost()) {
|
||||
return true;
|
||||
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
|
||||
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
|
||||
throw turnDaemonLease.getLossError();
|
||||
}
|
||||
const gatewayPaused = (await pauseGate?.()) ?? false;
|
||||
const phase = world.getGameClockState().phase;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
@@ -156,7 +157,7 @@ export const createUnificationHandler = (options: {
|
||||
nationId: winner.id,
|
||||
nationName: winner.name,
|
||||
color: winner.color,
|
||||
icon: recipient.picture ?? '',
|
||||
icon: resolveMessageTargetIcon(recipient),
|
||||
},
|
||||
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
|
||||
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 {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
@@ -95,6 +97,8 @@ const claimGeneration = async (
|
||||
}
|
||||
return 'ALREADY_APPLIED';
|
||||
}
|
||||
// 이전 버전/이관 기수에 generation row가 없어도 통일 기록을 다시 정산하지 않는다.
|
||||
if (await areSeasonRecordsFinalized(transaction, input.serverId)) return 'ALREADY_APPLIED';
|
||||
await transaction.unificationFinalization.create({
|
||||
data: {
|
||||
generationKey: input.generationKey,
|
||||
@@ -243,7 +247,7 @@ const cancelPendingUniqueAuctions = async (
|
||||
nationId: bidder.nationId,
|
||||
nationName: nation?.name ?? '재야',
|
||||
color: nation?.color ?? '#000000',
|
||||
icon: bidder.picture ?? '',
|
||||
icon: resolveMessageTargetIcon(bidder),
|
||||
},
|
||||
text: `${planned.auctionId}번 ${planned.title}가 취소되었습니다.`,
|
||||
time: input.completedAt,
|
||||
|
||||
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [actor],
|
||||
cities: [],
|
||||
nations: [],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '촉',
|
||||
color: '#000000',
|
||||
level: 1,
|
||||
capitalCityId: 2,
|
||||
chiefGeneralId: 8,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
typeCode: 'che_중립',
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
|
||||
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 () => {
|
||||
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||
const world = buildWorld();
|
||||
|
||||
@@ -29,6 +29,8 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
const clean = async (): Promise<void> => {
|
||||
await redis.client.flushDb();
|
||||
await db.$transaction([
|
||||
db.readModelOutbox.deleteMany(),
|
||||
db.readModelRevision.deleteMany(),
|
||||
db.clockProjectionOutbox.deleteMany(),
|
||||
db.clockReconciliationParticipant.deleteMany(),
|
||||
db.clockSuspension.deleteMany(),
|
||||
@@ -65,6 +67,66 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
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) => {
|
||||
const profile = 'recovery-startup';
|
||||
await db.worldState.create({
|
||||
@@ -115,7 +177,7 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
expect(pending.clockPhase).toBe('RECONCILING');
|
||||
const recoveredWindow = readTurnRecovery(pending)!;
|
||||
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);
|
||||
await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
|
||||
await lease.markClockReady();
|
||||
@@ -140,7 +202,7 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
it.each([4, 12, 13, 23, 24])(
|
||||
'persists recovery for %i turns and reloads the same normal boundary',
|
||||
async (turns) => {
|
||||
const now = new Date();
|
||||
const now = new Date(Date.now() + 3_600_000);
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'turn-recovery',
|
||||
@@ -214,6 +276,102 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
|
||||
expect(retry.recovery).toEqual(plan.recovery);
|
||||
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 () => {
|
||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||
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 () => {
|
||||
const existingIncidents = await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } });
|
||||
const gate = await createGatewayProfileGate({
|
||||
databaseUrl: databaseUrl!,
|
||||
gatewayDatabaseUrl: databaseUrl!,
|
||||
@@ -127,6 +128,19 @@ integration('gateway runtime action consumer', () => {
|
||||
status: 'PAUSED',
|
||||
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({
|
||||
where: { profileName },
|
||||
@@ -137,6 +151,9 @@ integration('gateway runtime action consumer', () => {
|
||||
status: 'STOPPED',
|
||||
lastError: null,
|
||||
});
|
||||
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
|
||||
existingIncidents + 1
|
||||
);
|
||||
} finally {
|
||||
await gate.close();
|
||||
}
|
||||
|
||||
@@ -337,6 +337,62 @@ describe('legacy general turn lifecycle', () => {
|
||||
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 () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
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: 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 upsert = vi.fn(async () => undefined);
|
||||
const prisma = {
|
||||
gameHistory: { findUnique: vi.fn(async () => ({ status: 'OPEN' })) },
|
||||
generalAccessLog: {
|
||||
updateMany: 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),
|
||||
},
|
||||
gameHistory: {
|
||||
findUnique: vi.fn(async () => ({ status: 'OPEN' })),
|
||||
count: vi.fn(async () => 99),
|
||||
},
|
||||
hallOfFame: {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { SystemClock } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
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 { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.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 integration = describe.skipIf(!databaseUrl);
|
||||
const worldId = 991_731;
|
||||
const generalId = 991_731;
|
||||
const cityId = 991_731;
|
||||
const existingNationId = 991_730;
|
||||
const generalId = 731;
|
||||
const cityId = 731;
|
||||
const existingNationId = 730;
|
||||
const requestId = 'integration:engine:immediate-action-uprising';
|
||||
const occupiedUniqueItem = 'che_무기_12_칠성검';
|
||||
|
||||
@@ -179,7 +181,8 @@ integration('immediate general action persistence', () => {
|
||||
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.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
@@ -288,13 +291,193 @@ integration('immediate general action persistence', () => {
|
||||
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.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||
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 () => {
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
|
||||
@@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
|
||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '재야장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const recruiter = buildGeneral({
|
||||
id: 9,
|
||||
userId: 'user-9',
|
||||
name: '등용장수',
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
});
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||
};
|
||||
const fixture = buildImmediateActionWorld({
|
||||
general: recipient,
|
||||
additionalGenerals: [recruiter],
|
||||
cities: [
|
||||
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||
] as TurnWorldSnapshot['cities'],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '등용국',
|
||||
color: '#222222',
|
||||
typeCode: 'che_중립',
|
||||
level: 1,
|
||||
capitalCityId: 2,
|
||||
chiefGeneralId: recruiter.id,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
power: 0,
|
||||
meta: { gennum: 1 },
|
||||
it.each([
|
||||
{ recruiterNationId: 2, ruler: false },
|
||||
{ recruiterNationId: 0, ruler: false },
|
||||
{ recruiterNationId: 3, ruler: false },
|
||||
{ recruiterNationId: 2, ruler: true },
|
||||
])(
|
||||
'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
|
||||
async ({ recruiterNationId, ruler }) => {
|
||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||
const recipient = buildGeneral({
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '재야장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
officerLevel: 0,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
const recruiter = buildGeneral({
|
||||
id: 9,
|
||||
userId: 'user-9',
|
||||
name: '등용장수',
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
});
|
||||
const map = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||
};
|
||||
const fixture = buildImmediateActionWorld({
|
||||
general: recipient,
|
||||
additionalGenerals: [recruiter],
|
||||
cities: [
|
||||
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||
] as TurnWorldSnapshot['cities'],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '등용국',
|
||||
color: '#222222',
|
||||
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(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||
experience: recruiter.experience + 100,
|
||||
dedication: recruiter.dedication + 100,
|
||||
});
|
||||
const actionLogs = fixture.world
|
||||
.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,
|
||||
]);
|
||||
});
|
||||
fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
|
||||
if (ruler) {
|
||||
fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
|
||||
const before = fixture.world.captureState();
|
||||
await expect(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
|
||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
|
||||
expect(fixture.world.captureState()).toEqual(before);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(
|
||||
executor.execute({
|
||||
actionKey: 'che_등용수락',
|
||||
generalId: recipient.id,
|
||||
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 2,
|
||||
officerLevel: 1,
|
||||
lastTurn: originalLastTurn,
|
||||
});
|
||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||
experience: recruiter.experience + 100,
|
||||
dedication: recruiter.dedication + 100,
|
||||
});
|
||||
const actionLogs = fixture.world
|
||||
.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 () => {
|
||||
const recipient = buildGeneral({
|
||||
|
||||
@@ -105,6 +105,8 @@ describe('NPC 일반 내정 턴', () => {
|
||||
{
|
||||
id: 1,
|
||||
name: 'NPC_무장',
|
||||
picture: '롤시나리오/다이애나.png',
|
||||
imageServer: 0,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
@@ -297,7 +299,12 @@ describe('NPC 일반 내정 턴', () => {
|
||||
expect.objectContaining({
|
||||
msgType: 'public',
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
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 { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
||||
await db.$executeRawUnsafe(`
|
||||
ALTER TABLE read_model_outbox
|
||||
ADD CONSTRAINT ${rollbackConstraint}
|
||||
CHECK ((payload->>'version')::integer <> 1)
|
||||
CHECK ((payload->>'version')::integer <> 1) NOT VALID
|
||||
`);
|
||||
world.updateWorldMeta({ durableFixture: 'must-rollback' });
|
||||
world.pushLog({
|
||||
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
||||
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
|
||||
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
const changes = {
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GameClock } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
||||
@@ -94,6 +95,99 @@ const canRun = await canConnectToDatabase(databaseUrl);
|
||||
const describeDb = describe.runIf(canRun);
|
||||
|
||||
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 () => {
|
||||
const readPersistedItemContract = async (targetScenarioId: number) => {
|
||||
const { applied } = await seedScenarioToDatabase({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ManualClock } from '@sammo-ts/common';
|
||||
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||
|
||||
import {
|
||||
InMemoryControlQueue,
|
||||
@@ -14,6 +15,39 @@ import {
|
||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||
|
||||
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)(
|
||||
'does not dispatch an explicit run while the clock phase is %s',
|
||||
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 () => {
|
||||
const wallNow = new Date('2026-08-23T01:35:00.000Z');
|
||||
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(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({
|
||||
data: {
|
||||
serverId,
|
||||
@@ -597,6 +610,7 @@ integration('unification finalization transaction', () => {
|
||||
},
|
||||
});
|
||||
expect(yearbook.globalHistory).toEqual(expect.arrayContaining([expect.stringContaining('【통일】')]));
|
||||
await assertLateInheritanceIgnored();
|
||||
expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0);
|
||||
|
||||
await hooks.hooks.flushChanges?.(runResult);
|
||||
@@ -772,6 +786,8 @@ integration('unification finalization transaction', () => {
|
||||
await db.clockProjectionOutbox.findFirstOrThrow({ where: { suspensionId: suspension.id } })
|
||||
).toMatchObject({ status: 'PENDING', targetRevision: 2n });
|
||||
|
||||
await assertLateInheritanceIgnored();
|
||||
|
||||
if (process.env.REDIS_URL) {
|
||||
const redis = createRedisConnector({ url: process.env.REDIS_URL });
|
||||
await redis.connect();
|
||||
|
||||
@@ -176,6 +176,19 @@ describe('persistUnificationFinalization', () => {
|
||||
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 () => {
|
||||
const inheritanceUpsert = vi.fn().mockResolvedValue({});
|
||||
const inheritanceResultCreate = vi.fn().mockResolvedValue({});
|
||||
@@ -216,7 +229,11 @@ describe('persistUnificationFinalization', () => {
|
||||
{ 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: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: hallCreate,
|
||||
|
||||
@@ -840,12 +840,23 @@ const install = async (
|
||||
page: Page,
|
||||
rejectGeneral = false,
|
||||
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 currentGeneralContext = {
|
||||
...generalContext,
|
||||
general: { ...generalContext.general, id: generalId },
|
||||
general: {
|
||||
...generalContext.general,
|
||||
id: generalId,
|
||||
...(recoveryClock ? { turnTime: '2026-09-10T01:20:00Z' } : {}),
|
||||
},
|
||||
};
|
||||
const generalTurns = turns(30);
|
||||
const nationTurns = turns(12);
|
||||
@@ -929,7 +940,8 @@ const install = async (
|
||||
myGeneral: { id: generalId, name: '장수' },
|
||||
year: 200,
|
||||
month: 1,
|
||||
turnTerm: 10,
|
||||
turnTerm: recoveryClock ? 60 : 10,
|
||||
...recoveryClock,
|
||||
userCnt: 1,
|
||||
maxUserCnt: 100,
|
||||
npcCnt: 0,
|
||||
@@ -959,7 +971,19 @@ const install = async (
|
||||
});
|
||||
}
|
||||
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')
|
||||
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
|
||||
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 === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
|
||||
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') {
|
||||
requests.push(body);
|
||||
if (rejectGeneral) return errorResponse(name, '대상 도시를 선택할 수 없습니다.');
|
||||
@@ -1019,6 +1057,80 @@ const install = async (
|
||||
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 }) => {
|
||||
await install(page);
|
||||
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();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
type FixtureState = {
|
||||
finalized?: boolean;
|
||||
mapRequests: number;
|
||||
generalRequests: number;
|
||||
created?: boolean;
|
||||
@@ -69,6 +70,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
npcGeneralCount: 1,
|
||||
},
|
||||
inherit: {
|
||||
enabled: !state.finalized,
|
||||
totalPoint: 30,
|
||||
costs: {
|
||||
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}/?$`));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
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 runtimeNavigation = JSON.parse(
|
||||
@@ -63,6 +65,7 @@ type NavigationFixture = {
|
||||
draftCommandTable?: boolean;
|
||||
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
|
||||
refCommandCategories?: boolean;
|
||||
uniqueItemLimit?: { count: number; until: { year: number; month: number } | null };
|
||||
currentYear?: number;
|
||||
currentMonth?: number;
|
||||
mapName?: string;
|
||||
@@ -552,7 +555,7 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
penalties: {},
|
||||
});
|
||||
|
||||
const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
const installFixture = async (page: Page | BrowserContext, state: NavigationFixture) => {
|
||||
await page.addInitScript(
|
||||
({ profile }) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_navigation');
|
||||
@@ -623,6 +626,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
}
|
||||
const results = operations.map((operation, index) => {
|
||||
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') {
|
||||
return response({
|
||||
myGeneral: { id: 7, name: '메뉴검증장수' },
|
||||
@@ -749,6 +754,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
}
|
||||
if (operation === 'world.getMap') {
|
||||
return response({
|
||||
uniqueItemLimit: state.uniqueItemLimit,
|
||||
result: true,
|
||||
version: 0,
|
||||
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 ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -3473,8 +3563,9 @@ test('main map year exposes the Ref restriction and technology limit on desktop
|
||||
nationLevel: 3,
|
||||
stage: 6,
|
||||
npcMode: 1,
|
||||
currentYear: 182,
|
||||
currentYear: 181,
|
||||
currentMonth: 1,
|
||||
uniqueItemLimit: { count: 1, until: { year: 182, month: 12 } },
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
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 title = mapPanel.locator('.map-title');
|
||||
const tooltip = mapPanel.getByRole('tooltip');
|
||||
await expect(title).toHaveText(/182年 1月/u);
|
||||
await expect(title).toHaveCSS('color', 'rgb(255, 255, 0)');
|
||||
await expect(title).toHaveText(/181年 1月/u);
|
||||
await expect(title).toHaveCSS('color', 'rgb(255, 165, 0)');
|
||||
await expect(title).toHaveAttribute('tabindex', '0');
|
||||
await expect(tooltip).toBeHidden();
|
||||
await title.hover();
|
||||
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 panelRect = panel.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;
|
||||
};
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
const desktopGeometry = await assertTitleTooltip('desktop');
|
||||
await testInfo.attach('main-map-year-tooltip-desktop.png', {
|
||||
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 }),
|
||||
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', {
|
||||
body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`),
|
||||
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 }) => {
|
||||
@@ -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: '당기기' }),
|
||||
{ radius: '5.25px 0px 0px 5.25px' },
|
||||
],
|
||||
[
|
||||
'미루기',
|
||||
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',
|
||||
});
|
||||
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();
|
||||
@@ -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).toHaveCSS(
|
||||
'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');
|
||||
}
|
||||
@@ -5076,6 +5193,80 @@ for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
] 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 }) => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
configConst: {},
|
||||
@@ -6085,8 +6276,8 @@ for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => {
|
||||
const start = new Date('2026-09-06T07:59:50Z');
|
||||
test(`turn recovery waits then accelerates and returns to normal speed (${viewport.width}px)`, async ({ page }) => {
|
||||
const start = new Date('2026-09-06T07:59:45Z');
|
||||
await page.clock.install({ time: start });
|
||||
await page.setViewportSize(viewport);
|
||||
const state: NavigationFixture = {
|
||||
@@ -6100,15 +6291,18 @@ for (const viewport of [
|
||||
serverTime: '2026-09-06T07:59:40Z',
|
||||
serverWallTime: start.toISOString(),
|
||||
clockMode: 'realtime',
|
||||
clockRunning: true,
|
||||
clockRunning: false,
|
||||
clockStartsAt: '2026-09-06T07:59:50Z',
|
||||
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 page.goto('./');
|
||||
await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const status = page.locator('.execution-status:visible');
|
||||
await expect(status).not.toContainText('복구 2배속');
|
||||
await page.clock.runFor(5_000);
|
||||
await expect(status).toContainText('복구 2배속');
|
||||
const root = process.env.TURN_RECOVERY_ARTIFACT_DIR;
|
||||
const measure = () =>
|
||||
@@ -6126,7 +6320,7 @@ for (const viewport of [
|
||||
await mkdir(root, { recursive: 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).toContainText('17:00');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
|
||||
import { RouterView } from 'vue-router';
|
||||
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
|
||||
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
||||
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
||||
|
||||
useDeploymentVersionNotice();
|
||||
useClockDisplayRefresh();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
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 type {
|
||||
CommandMapData,
|
||||
@@ -50,7 +54,7 @@ const reserveBulk = (entries: CommandPatternEntry[], complete?: ReservationCompl
|
||||
:mobile="props.mobile"
|
||||
:title="props.officerLevelText"
|
||||
:name="props.name"
|
||||
:current-time="props.rows[0]?.time"
|
||||
:current-time="currentTime"
|
||||
:map-data="props.mapData"
|
||||
:map-layout="props.mapLayout"
|
||||
@reserve-bulk="reserveBulk"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<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 CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
||||
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
||||
@@ -382,7 +384,18 @@ const clickOutsideMenu = (event: Event) => {
|
||||
><span>{{ props.title }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<summary>반복</summary>
|
||||
<div class="menu-items">
|
||||
@@ -701,38 +714,38 @@ const clickOutsideMenu = (event: Event) => {
|
||||
</div>
|
||||
|
||||
<div v-if="!props.compact" class="bottom-actions">
|
||||
<details class="legacy-menu bottom-shift-menu">
|
||||
<summary class="legacy-button legacy-button--secondary" role="button">당기기</summary>
|
||||
<div class="menu-items">
|
||||
<button
|
||||
v-for="amount in props.maxShiftTurn"
|
||||
:key="amount"
|
||||
type="button"
|
||||
@click="
|
||||
emit('shift', -amount);
|
||||
clickOutsideMenu($event);
|
||||
"
|
||||
<div v-for="direction in [-1, 1]" :key="direction" class="bottom-shift-control">
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary shift-main"
|
||||
type="button"
|
||||
:title="direction < 0 ? '1턴 당기기' : '1턴 미루기'"
|
||||
@click="emit('shift', direction)"
|
||||
>
|
||||
{{ direction < 0 ? '당기기' : '미루기' }}
|
||||
</button>
|
||||
<details class="legacy-menu bottom-shift-menu">
|
||||
<summary
|
||||
class="legacy-button legacy-button--secondary"
|
||||
role="button"
|
||||
:aria-label="direction < 0 ? '당길 턴 수 선택' : '미룰 턴 수 선택'"
|
||||
>
|
||||
{{ amount }}턴
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<details class="legacy-menu bottom-shift-menu">
|
||||
<summary class="legacy-button legacy-button--secondary" role="button">미루기</summary>
|
||||
<div class="menu-items">
|
||||
<button
|
||||
v-for="amount in props.maxShiftTurn"
|
||||
:key="amount"
|
||||
type="button"
|
||||
@click="
|
||||
emit('shift', amount);
|
||||
clickOutsideMenu($event);
|
||||
"
|
||||
>
|
||||
{{ amount }}턴
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="menu-items">
|
||||
<button
|
||||
v-for="amount in props.maxShiftTurn"
|
||||
:key="amount"
|
||||
type="button"
|
||||
@click="
|
||||
emit('shift', direction * amount);
|
||||
clickOutsideMenu($event);
|
||||
"
|
||||
>
|
||||
{{ amount }}턴
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
|
||||
{{ expanded ? '접기' : '펼치기' }}
|
||||
</button>
|
||||
@@ -881,10 +894,17 @@ const clickOutsideMenu = (event: Event) => {
|
||||
place-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.clock {
|
||||
.control-pad > .clock {
|
||||
background: #345c85;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.clock:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
.control-pad > .clock--real {
|
||||
background: #386b45;
|
||||
}
|
||||
.legacy-menu {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -1098,8 +1118,29 @@ const clickOutsideMenu = (event: Event) => {
|
||||
gap: 4px;
|
||||
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 {
|
||||
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 {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<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 ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||
import { generalTurnEditorModeStorageKey } from '../command/commandQueue';
|
||||
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection';
|
||||
|
||||
import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig';
|
||||
import type {
|
||||
CommandMapData,
|
||||
@@ -14,6 +15,9 @@ import type {
|
||||
ReservedCommandRow,
|
||||
} from '../command/types';
|
||||
|
||||
const { projectTime, time } = useClockDisplay();
|
||||
const currentServerTime = computed(() => (time.value ? formatLocalTimeSeconds(time.value) : '--:--:--'));
|
||||
|
||||
type ReservationCompletion = (success: boolean) => void;
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -69,7 +73,7 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
||||
const term = props.turnTermMinutes ?? 0;
|
||||
return (props.reservedGeneralTurns ?? []).map((turn, 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 {
|
||||
...turn,
|
||||
args: turn.args ?? {},
|
||||
@@ -100,62 +104,9 @@ const autonomousUntil = computed(() => {
|
||||
base && Number.isFinite(base.getTime())
|
||||
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
|
||||
: null;
|
||||
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가';
|
||||
const currentTimeLabel = expiresAt ? formatLocalDateTime(projectTime(expiresAt)) : '현재시각 확인 불가';
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplay } from '../../composables/useClockDisplay';
|
||||
const { projectTime } = useClockDisplay();
|
||||
import { computed } from 'vue';
|
||||
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
@@ -257,7 +259,7 @@ const specialText = computed(() => {
|
||||
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
||||
<span data-general-turn-time>{{
|
||||
props.general.turnTime ? formatLocalTimeSeconds(props.general.turnTime) : '-'
|
||||
props.general.turnTime ? formatLocalTimeSeconds(projectTime(props.general.turnTime)) : '-'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
millisecondsUntilNextMinute,
|
||||
projectServerClock,
|
||||
sampleServerClock,
|
||||
type SampledServerClock,
|
||||
} from '../../utils/serverClockProjection';
|
||||
import { receiveClockSample, useClockDisplay } from '../../composables/useClockDisplay';
|
||||
|
||||
const props = defineProps<{
|
||||
tournamentStage: number;
|
||||
@@ -33,106 +28,60 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
|
||||
const currentServerTime = ref('기록 없음');
|
||||
const hasServerClock = ref(false);
|
||||
const recovering = ref(false);
|
||||
const turnEngineStopped = computed(() => props.turnEngineRunning === false);
|
||||
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean');
|
||||
const { time, accelerated: recovering, mode, label, toggle, engineRunning } = useClockDisplay();
|
||||
const currentServerTime = computed(() =>
|
||||
formatServerDateTime(time.value, { format: 'monthDayTime', fallback: '기록 없음' })
|
||||
);
|
||||
const hasServerClock = computed(() => time.value !== null);
|
||||
const turnEngineStopped = computed(() => engineRunning.value === false);
|
||||
const turnEngineStatusUnknown = computed(() => typeof engineRunning.value !== 'boolean');
|
||||
const serverClockTitle = computed(() => {
|
||||
if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.';
|
||||
if (turnEngineStopped.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(
|
||||
() =>
|
||||
[
|
||||
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();
|
||||
},
|
||||
() => [
|
||||
props.serverTime,
|
||||
props.serverWallTime,
|
||||
props.clockMode,
|
||||
props.clockRunning,
|
||||
props.clockStartsAt,
|
||||
props.clockRecovery,
|
||||
],
|
||||
() => receiveClockSample(props),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(() => props.turnEngineRunning, updateServerClock);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
||||
<div class="activity-status" aria-label="현재 시각, 토너먼트와 설문 진행 현황">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!recovering || turnEngineStopped"
|
||||
class="status-row execution-status"
|
||||
:class="{
|
||||
'execution-status--game': recovering && mode === 'game',
|
||||
'execution-status--real': recovering && mode === 'real',
|
||||
'execution-status--empty': !hasServerClock,
|
||||
'execution-status--stopped': hasServerClock && turnEngineStopped,
|
||||
'execution-status--unknown': hasServerClock && turnEngineStatusUnknown,
|
||||
}"
|
||||
:title="serverClockTitle"
|
||||
@click="toggle"
|
||||
>
|
||||
현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span>
|
||||
</div>
|
||||
현재 시각: {{ currentServerTime
|
||||
}}<span v-if="recovering"> · 2배속 · {{ mode === 'real' ? '실제 시간' : '게임 시간' }}</span>
|
||||
</button>
|
||||
<div class="status-row tournament-status">
|
||||
<RouterLink to="/tournament">
|
||||
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<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 }}
|
||||
</RouterLink>
|
||||
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
|
||||
@@ -208,9 +157,31 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.execution-status {
|
||||
background: transparent;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
color: magenta;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface MapSummary {
|
||||
initialLevel: number;
|
||||
increaseYears: number;
|
||||
};
|
||||
uniqueItemLimit?: { count: number; until: { year: number; month: number } | null };
|
||||
cityList: [number, number, number, number, number, number][];
|
||||
nationList: [number, string, string, number][];
|
||||
myCity?: number | null;
|
||||
@@ -335,6 +336,11 @@ const titleTooltipLines = computed(() => {
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,26 @@ const destination = computed<MessageTarget>(
|
||||
|
||||
const invalid = computed(() => props.message.option?.invalid === 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 nationDirection = computed(() => {
|
||||
if (props.message.src.nationId === destination.value.nationId) {
|
||||
@@ -275,10 +295,11 @@ onBeforeUnmount(() => {
|
||||
]"
|
||||
>
|
||||
<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 v-if="hasAction && !invalid" class="message-response">
|
||||
<div v-if="hasAction && !invalid && !permissionRedacted && !actionUnavailable" class="message-response">
|
||||
<button
|
||||
class="prompt-yes legacy-button legacy-button--primary"
|
||||
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 { defineStore } from 'pinia';
|
||||
import {
|
||||
@@ -130,6 +131,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const tournamentType = ref<TournamentType | null>(null);
|
||||
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
||||
const privateMessageNotice = ref<PrivateMessageNotice | null>(null);
|
||||
const diplomacyMessageNotice = ref<PrivateMessageNotice | null>(null);
|
||||
let dismissedDiplomacyMessageId = 0;
|
||||
let dismissedPrivateMessageId = 0;
|
||||
let lastGeneralRecordId = 0;
|
||||
let lastWorldHistoryId = 0;
|
||||
@@ -351,6 +354,40 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
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 merged = new Map(current.map((entry) => [entry.id, entry]));
|
||||
for (const entry of incoming) {
|
||||
@@ -370,6 +407,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
surveyNotice.value = null;
|
||||
privateMessageNotice.value = null;
|
||||
dismissedPrivateMessageId = 0;
|
||||
diplomacyMessageNotice.value = null;
|
||||
dismissedDiplomacyMessageId = 0;
|
||||
};
|
||||
|
||||
const applyRecentRecords = (records: Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>) => {
|
||||
@@ -448,10 +487,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (patch.messages !== undefined) {
|
||||
messages.value = structurallyShare(messages.value, patch.messages);
|
||||
reconcilePrivateMessageNotice(messages.value);
|
||||
reconcileDiplomacyMessageNotice(messages.value);
|
||||
} else if (patch.contextSnapshot !== undefined || patch.general !== undefined) {
|
||||
// Main data is fetched concurrently, so the message bundle can arrive
|
||||
// before the authenticated general context needed to identify senders.
|
||||
reconcilePrivateMessageNotice(messages.value);
|
||||
reconcileDiplomacyMessageNotice(messages.value);
|
||||
}
|
||||
if (patch.messageContacts !== undefined) {
|
||||
messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts);
|
||||
@@ -518,6 +559,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
|
||||
const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => {
|
||||
if (turnEngineRunning === undefined || !lobbyInfo.value) return;
|
||||
receiveClockEngineState(turnEngineRunning);
|
||||
lobbyInfo.value = structurallyShare(lobbyInfo.value, {
|
||||
...lobbyInfo.value,
|
||||
turnEngineRunning,
|
||||
@@ -688,6 +730,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
worldMap.value = structurallyShare(worldMap.value, map);
|
||||
messages.value = structurallyShare(messages.value, messageData);
|
||||
reconcilePrivateMessageNotice(messages.value);
|
||||
reconcileDiplomacyMessageNotice(messages.value);
|
||||
messageContacts.value = structurallyShare(messageContacts.value, contacts);
|
||||
reservedGeneralTurns.value = structurallyShare<unknown>(
|
||||
reservedGeneralTurns.value,
|
||||
@@ -960,6 +1003,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
},
|
||||
};
|
||||
reconcilePrivateMessageNotice(messages.value);
|
||||
reconcileDiplomacyMessageNotice(messages.value);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -974,6 +1018,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
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 id = generalId.value;
|
||||
if (!id) {
|
||||
@@ -1399,6 +1449,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
tournamentType,
|
||||
surveyNotice,
|
||||
privateMessageNotice,
|
||||
diplomacyMessageNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxGroups,
|
||||
@@ -1409,6 +1460,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
stopRealtime,
|
||||
dismissSurveyNotice,
|
||||
dismissPrivateMessageNotice,
|
||||
dismissDiplomacyMessageNotice,
|
||||
acknowledgeDiplomacyMessageNotice,
|
||||
acknowledgePrivateMessageNotice,
|
||||
loadMainData,
|
||||
refreshMessages,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DEFAULT_USER_ICON_PUBLIC_URL,
|
||||
externalizeLegacyImageUrl,
|
||||
} from './imageAssets.ts';
|
||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig.ts';
|
||||
|
||||
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
|
||||
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)) {
|
||||
return normalized;
|
||||
}
|
||||
return `${gameFrontendRuntimeConfig.appBasePath}${normalized.replace(/^\/+/u, '')}`;
|
||||
// 기존 NPC 대사와 알림에는 URL 대신 picture 상대 경로가 저장되어 있다.
|
||||
return resolveGeneralIconUrl({ picture: normalized, imageServer: 0 });
|
||||
};
|
||||
|
||||
export const useDefaultGeneralIcon = (event: Event): void => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ServerClockProjectionInput = {
|
||||
export type SampledServerClock = {
|
||||
serverTimeMs: number;
|
||||
sampledClientTimeMs: number;
|
||||
serverWallTimeMs?: number;
|
||||
clockMode: 'realtime' | 'manual';
|
||||
startDelayMs: number | null;
|
||||
recoveryStartDelayMs?: number;
|
||||
@@ -49,6 +50,7 @@ export const sampleServerClock = (
|
||||
return {
|
||||
serverTimeMs,
|
||||
sampledClientTimeMs,
|
||||
...(wallSample !== null ? { serverWallTimeMs: wallSample } : {}),
|
||||
clockMode: input.clockMode ?? 'realtime',
|
||||
startDelayMs,
|
||||
...(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;
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
|
||||
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 type { AppRouter } from '@sammo-ts/game-api';
|
||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||
@@ -25,6 +28,24 @@ const getGameToken = (): string | null => {
|
||||
|
||||
export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
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({
|
||||
url: gameFrontendRuntimeConfig.gameApiUrl,
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||
const { formatTime: formatGameTime } = useClockDisplay();
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
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 displayCode = (value: string | null | undefined): string =>
|
||||
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
|
||||
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
||||
return formatServerDateTime(value, {
|
||||
const cutDateTime = (value: string | null | undefined, showSecond = false, gameTime = true): string => {
|
||||
return (gameTime ? formatGameTime : formatServerDateTime)(value, {
|
||||
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
|
||||
fallback: '-',
|
||||
});
|
||||
@@ -413,7 +415,7 @@ onMounted(() => {
|
||||
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
|
||||
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
|
||||
<span class="tnum">{{ formatNumber(bid.amount) }}</span>
|
||||
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time>
|
||||
<time class="tnum">{{ cutDateTime(bid.eventAt, false, false) }}</time>
|
||||
</div>
|
||||
<template v-if="uniqueDetail.auction.status === 'OPEN'">
|
||||
<h3 class="subsection-title bg1">입찰하기</h3>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||
const { formatTime: formatGameTime } = useClockDisplay();
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
@@ -130,7 +132,7 @@ const selectedGeneral = computed(() => {
|
||||
|
||||
const formatGeneralLabel = (general: GeneralEntry): string => {
|
||||
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') {
|
||||
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
@@ -53,7 +54,7 @@ const ratio = (id: number) => {
|
||||
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
|
||||
};
|
||||
const openingTime = computed(() =>
|
||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
);
|
||||
const selectedRatio = computed(() => {
|
||||
const targetId = selectedTarget.value?.id;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||
const { formatTime: formatGameTime } = useClockDisplay();
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { addMinutes } from 'date-fns';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import ChiefTurnCard from '../components/chief/ChiefTurnCard.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;
|
||||
const timeLabel = turnDate
|
||||
? turnTermMinutes >= 5
|
||||
? formatServerDateTime(turnDate, { format: 'hourMinute' })
|
||||
: formatServerDateTime(turnDate, { format: 'minuteSecond' })
|
||||
? formatGameTime(turnDate, { format: 'hourMinute' })
|
||||
: formatGameTime(turnDate, { format: 'minuteSecond' })
|
||||
: '--:--';
|
||||
const actionLabel =
|
||||
formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ??
|
||||
|
||||
@@ -10,7 +10,8 @@ import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
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 { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
|
||||
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
||||
@@ -808,6 +809,10 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</summary>
|
||||
<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 class="inherit-summary">
|
||||
<div>보유 포인트: {{ inheritTotalPoint }}</div>
|
||||
|
||||
@@ -89,6 +89,7 @@ const {
|
||||
tournamentType,
|
||||
surveyNotice,
|
||||
privateMessageNotice,
|
||||
diplomacyMessageNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxGroups,
|
||||
@@ -168,6 +169,7 @@ const formatRecord = (entry: { text: string; createdAt?: string | Date }, append
|
||||
};
|
||||
|
||||
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let diplomacyMessageNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let privateMessageNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(surveyNotice, (notice) => {
|
||||
if (surveyNoticeTimer) {
|
||||
@@ -187,6 +189,15 @@ watch(privateMessageNotice, (notice) => {
|
||||
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(() => {
|
||||
if (surveyNoticeTimer) {
|
||||
clearTimeout(surveyNoticeTimer);
|
||||
@@ -194,6 +205,7 @@ onUnmounted(() => {
|
||||
if (privateMessageNoticeTimer) {
|
||||
clearTimeout(privateMessageNoticeTimer);
|
||||
}
|
||||
if (diplomacyMessageNoticeTimer) clearTimeout(diplomacyMessageNoticeTimer);
|
||||
dashboard.stopRealtime();
|
||||
window.removeEventListener('storage', handleMobilePanelStorage);
|
||||
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' });
|
||||
};
|
||||
|
||||
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']>) => {
|
||||
if (action === 'show-version') versionDialog.value?.showModal();
|
||||
};
|
||||
@@ -321,7 +340,7 @@ watch(
|
||||
<strong>설문조사 안내</strong>
|
||||
<button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button>
|
||||
</div>
|
||||
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink>
|
||||
<RouterLink to="/survey" target="_blank" rel="noopener noreferrer">새로운 설문조사가 있습니다.</RouterLink>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
@@ -349,6 +368,32 @@ watch(
|
||||
</div>
|
||||
</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">
|
||||
<template v-for="(panelId, panelIndex) in mobilePanelOrder" :key="panelId">
|
||||
<div v-if="panelId === 'commands'" class="mobile-panel" data-mobile-panel-id="commands">
|
||||
@@ -871,6 +916,10 @@ button {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.diplomacy-message-notice-stacked {
|
||||
top: 180px;
|
||||
}
|
||||
|
||||
.private-message-notice-title {
|
||||
display: flex;
|
||||
min-height: 35px;
|
||||
@@ -1133,14 +1182,18 @@ button {
|
||||
|
||||
.survey-notice {
|
||||
z-index: 90;
|
||||
bottom: 16px;
|
||||
bottom: 61px;
|
||||
}
|
||||
|
||||
.private-message-notice {
|
||||
z-index: 90;
|
||||
z-index: 1080;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.diplomacy-message-notice-stacked {
|
||||
top: 180px;
|
||||
}
|
||||
|
||||
.layout-mobile [data-main-target='world-history'] {
|
||||
height: 359px;
|
||||
min-height: 0;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
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 { useSessionStore } from '../stores/session';
|
||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||
const { mode: clockDisplayMode } = useClockDisplay();
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
@@ -148,6 +150,18 @@ onBeforeUnmount(() => {
|
||||
</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">
|
||||
<span>
|
||||
모바일 메인 레이아웃<br />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -700,7 +701,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
</td>
|
||||
<td>{{ general.killTurn }}</td>
|
||||
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||
<td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useClockDisplay } from '../composables/useClockDisplay';
|
||||
const { formatTime: formatGameTime } = useClockDisplay();
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -495,7 +497,7 @@ const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue
|
||||
case 'reservedCommand':
|
||||
return commandText(general, false);
|
||||
case 'turntime':
|
||||
return formatServerDateTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' });
|
||||
return formatGameTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' });
|
||||
case 'recent_war':
|
||||
return formatServerDateTime(details(general).recentWar, { format: 'minuteSecond', fallback: '-' });
|
||||
case 'years_1':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
|
||||
import type { CommandTable } from '../components/command/types';
|
||||
@@ -332,7 +333,7 @@ onMounted(load);
|
||||
>
|
||||
</td>
|
||||
<td>{{ general.killTurn }}</td>
|
||||
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||
<td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
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 { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -25,7 +24,7 @@ const myComment = ref('');
|
||||
const newVoteTitle = ref('');
|
||||
const newVoteOptionsText = ref('');
|
||||
const newVoteMultipleOptions = ref(1);
|
||||
const router = useRouter();
|
||||
const closeWindow = (): void => window.close();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
@@ -209,7 +208,7 @@ onMounted(() => {
|
||||
<button
|
||||
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
|
||||
type="button"
|
||||
@click="router.push('/')"
|
||||
@click="closeWindow"
|
||||
>
|
||||
창 닫기
|
||||
</button>
|
||||
@@ -409,7 +408,7 @@ onMounted(() => {
|
||||
<button
|
||||
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
|
||||
type="button"
|
||||
@click="router.push('/')"
|
||||
@click="closeWindow"
|
||||
>
|
||||
창 닫기
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
@@ -55,7 +56,7 @@ const matchesAt = (stage: number) =>
|
||||
.sort((a, b) => a.roundIndex - b.roundIndex);
|
||||
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
||||
const openingTime = computed(() =>
|
||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
);
|
||||
const isParticipant = computed(() =>
|
||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { useRouter } from 'vue-router';
|
||||
|
||||
@@ -170,7 +171,7 @@ const hideMemberPopup = () => {
|
||||
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
||||
|
||||
const formatTurn = (turnTime: string | null): string => {
|
||||
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
|
||||
return formatGameTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
|
||||
};
|
||||
|
||||
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', () => {
|
||||
assert.equal(
|
||||
resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
millisecondsUntilNextMinute,
|
||||
projectServerClock,
|
||||
projectRecoveryTime,
|
||||
sampleServerClock,
|
||||
} 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, 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);
|
||||
});
|
||||
|
||||
@@ -2078,6 +2078,26 @@ export const adminRouter = 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
|
||||
.input(z.object({ profileName: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type GameCancellationHistoryMode,
|
||||
type GameCancellationResult,
|
||||
} from '@sammo-ts/game-engine/scenario/gameCancellation.js';
|
||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import { gatewayProfileCapabilities, type ProfileRuntimeDiagnostics } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
@@ -177,6 +177,7 @@ export interface GatewayOrchestratorHandle {
|
||||
}>;
|
||||
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
|
||||
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
|
||||
inspectRuntime?(profileName: string): Promise<ProfileRuntimeDiagnostics>;
|
||||
transitionProfileClock(
|
||||
profileName: string,
|
||||
action: 'SUSPEND' | 'RESUME',
|
||||
@@ -1164,6 +1165,94 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
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[]> {
|
||||
const allowedAutorunOptions = new Set<RuntimeAutorunOption>([
|
||||
'develop',
|
||||
|
||||
@@ -123,6 +123,12 @@ export class Pm2ProcessManager implements ProcessManager {
|
||||
cwd: item.pm2_env?.pm_cwd ?? undefined,
|
||||
script: item.pm2_env?.pm_exec_path ?? undefined,
|
||||
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);
|
||||
});
|
||||
@@ -145,16 +151,13 @@ export class Pm2ProcessManager implements ProcessManager {
|
||||
reject(new Error(`PM2 process name already exists: ${definition.name}`));
|
||||
return;
|
||||
}
|
||||
pm2.start(
|
||||
buildPm2StartOptions(definition),
|
||||
(error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
pm2.start(buildPm2StartOptions(definition), (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ManagedProcessInfo {
|
||||
cwd?: string;
|
||||
script?: string;
|
||||
restartCount?: number;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('marks scenario zero as the current selectable scenario', async () => {
|
||||
const harness = await buildCaller(
|
||||
|
||||
@@ -174,6 +174,36 @@ const postTrpc = async (
|
||||
};
|
||||
|
||||
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 () => {
|
||||
const harness = await createHarness();
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
|
||||
const world = await connector.prisma.worldState.findFirstOrThrow();
|
||||
expect(world).toMatchObject({
|
||||
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',
|
||||
meta: {
|
||||
firstGameIdx: 0,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user