Merge branch 'main' into feature/nation-general-secret-parity

This commit is contained in:
2026-07-26 05:16:26 +00:00
23 changed files with 2764 additions and 679 deletions
+3
View File
@@ -30,6 +30,9 @@ export * from './auction/types.js';
export * from './auction/keys.js';
export * from './auction/scheduler.js';
export * from './auction/worker.js';
export * from './tournament/keys.js';
export * from './tournament/store.js';
export * from './tournament/types.js';
export * from './tournament/worker.js';
// Types for TRPC consumer
+87 -24
View File
@@ -3,7 +3,14 @@ import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic';
import {
ItemLoader,
isItemKey,
loadWarTraitModules,
WarTraitLoader,
WAR_TRAIT_KEYS,
isWarTraitKey,
} from '@sammo-ts/logic';
import type { InheritBuffType } from '@sammo-ts/logic';
import {
appendInheritanceLog,
@@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'success',
'fail',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
@@ -34,8 +41,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가',
fail: '내정 실패율 감소',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패율 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
};
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
for (const entries of Object.values(asRecord(configuredItems))) {
for (const [key, amount] of Object.entries(asRecord(entries))) {
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
enabledKeys.push(key);
}
}
}
const loader = new ItemLoader();
const items = await Promise.all(
[...new Set(enabledKeys)].map(async (key) => {
const item = await loader.load(key);
return {
key,
name: item.name,
rawName: item.rawName,
info: item.info ?? '',
};
})
);
return items.sort((left, right) => left.name.localeCompare(right.name, 'ko'));
};
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState || typeof worldState !== 'object') {
@@ -199,6 +237,9 @@ export const inheritRouter = router({
special2Code: true,
meta: true,
turnTime: true,
leadership: true,
strength: true,
intel: true,
},
});
@@ -219,7 +260,7 @@ export const inheritRouter = router({
const inheritConst = resolveInheritConstants(worldState);
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => {
acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0)));
acc[key] = readBuffLevel(buffState, key);
return acc;
}, {});
@@ -240,11 +281,14 @@ export const inheritRouter = router({
info: trait.info ?? '',
}));
const others = await ctx.db.general.findMany({
where: { id: { not: general.id }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
});
const [others, availableUnique] = await Promise.all([
ctx.db.general.findMany({
where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
}),
loadAvailableUniqueItems(worldState),
]);
return {
items,
@@ -260,10 +304,16 @@ export const inheritRouter = router({
resetTurnTime: resetTurnLevel,
},
availableSpecialWar: warSpecials,
availableUnique,
availableTargetGenerals: others,
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
isUnited,
currentSpecialWar: general.special2Code ?? 'None',
currentStat: {
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
},
};
}),
getLogs: authedProcedure
@@ -285,7 +335,7 @@ export const inheritRouter = router({
},
orderBy: { id: 'desc' },
take: 30,
select: { id: true, year: true, month: true, text: true },
select: { id: true, year: true, month: true, text: true, createdAt: true },
});
return logs;
}),
@@ -318,7 +368,7 @@ export const inheritRouter = router({
}
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0)));
const prevLevel = readBuffLevel(buff, input.type);
if (input.level === prevLevel) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
}
@@ -417,7 +467,12 @@ export const inheritRouter = router({
},
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint);
await setInheritancePoint(
ctx.db,
userId,
'previous',
currentPoint - inheritConst.inheritSpecificSpecialPoint
);
await appendInheritanceLog(
ctx.db,
userId,
@@ -460,7 +515,8 @@ export const inheritRouter = router({
}
const meta = asRecord(general.meta);
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
const prevList =
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
prevList.push(general.special2Code);
await patchGeneral(ctx, general.id, {
@@ -473,7 +529,13 @@ export const inheritRouter = router({
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${cost} 포인트로 전투 특기 초기화`
);
return { ok: true };
}),
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
@@ -624,9 +686,7 @@ export const inheritRouter = router({
const finalBonus =
bonusSum === 0
? buildRandomBonus(
new LiteHashDRBG(
`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`
),
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
[input.leadership, input.strength, input.intel]
)
: (bonus as [number, number, number]);
@@ -674,9 +734,7 @@ export const inheritRouter = router({
if (seasonValue !== null) {
const userState = await readUserStateMeta(ctx.db, userId);
const resetSeasons = readResetSeasons(userState);
const nextSeasons = resetSeasons.includes(seasonValue)
? resetSeasons
: [...resetSeasons, seasonValue];
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
await writeUserStateMeta(ctx.db, userId, {
...userState,
last_stat_reset: nextSeasons,
@@ -709,7 +767,10 @@ export const inheritRouter = router({
}
const meta = asRecord(general.meta);
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
});
}
await patchGeneral(ctx, general.id, {
@@ -803,7 +864,9 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
}
const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId;
const rawOwnerName = asRecord(target.meta).ownerName;
const ownerName =
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
await appendInheritanceLog(
+78 -1
View File
@@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
const isWorldAdmin = (roles: readonly string[]): boolean =>
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
};
const unitSetName = (world: WorldStateRow | null, fallback: string): string => {
const config = asRecord(world?.config);
const environment = asRecord(config.environment ?? config.map);
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
};
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
const loadCrewTypeNames = (name: string): Promise<Map<number, string>> => {
const cached = crewTypeNameCache.get(name);
if (cached) return cached;
const pending = loadUnitSetDefinitionByName(name)
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
.catch(() => new Map<number, string>());
crewTypeNameCache.set(name, pending);
return pending;
};
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
if (officerLevel === 12) return nationLevel * 2;
if (officerLevel >= 5) return nationLevel;
return 0;
};
const toWorldStateSnapshot = (row: WorldStateRow) => ({
scenarioCode: row.scenarioCode,
currentYear: row.currentYear,
@@ -122,6 +146,8 @@ export const worldRouter = router({
if (me.officerLevel > 0 && me.nationId > 0) {
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
nationGenerals.forEach((general) => selectable.add(general.cityId));
}
if ((nation?.level ?? 0) > 0) {
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
}
if (admin) cities.forEach((city) => selectable.add(city.id));
@@ -150,6 +176,8 @@ export const worldRouter = router({
turnMap.set(turn.generalId, list);
}
const nationMap = new Map(nations.map((item) => [item.id, item]));
const selectedNation = nationMap.get(selected.nationId);
const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id));
const officers = await ctx.db.general.findMany({
where: { officerLevel: { in: [2, 3, 4] } },
select: { name: true, officerLevel: true, meta: true },
@@ -175,14 +203,59 @@ export const worldRouter = router({
intelligence: general.intel,
injury: general.injury,
officerLevel: general.officerLevel,
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
defenceTrain: ours ? defenceTrain(general.meta) : null,
crewTypeId: ours ? general.crewTypeId : null,
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
crew: ours || full ? general.crew : null,
train: ours ? general.train : null,
atmos: ours ? general.atmos : null,
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
};
});
const forceSummary = mappedGenerals.reduce(
(summary, general) => {
if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) {
summary.enemyGenerals += 1;
if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew;
if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1;
return summary;
}
if (me.nationId <= 0 || general.nationId !== me.nationId) return summary;
summary.ownGenerals += 1;
summary.ownCrew += general.crew ?? 0;
if ((general.crew ?? 0) <= 0) return summary;
summary.ownArmedGenerals += 1;
const readiness = Math.min(general.train ?? -1, general.atmos ?? -1);
if (readiness >= 90) {
summary.ready90Crew += general.crew ?? 0;
summary.ready90Generals += 1;
}
if (readiness >= 60) {
summary.ready60Crew += general.crew ?? 0;
summary.ready60Generals += 1;
}
if (general.defenceTrain !== null && readiness >= general.defenceTrain) {
summary.defenceReadyCrew += general.crew ?? 0;
summary.defenceReadyGenerals += 1;
}
return summary;
},
{
enemyCrew: 0,
enemyArmedGenerals: 0,
enemyGenerals: 0,
ownCrew: 0,
ownArmedGenerals: 0,
ownGenerals: 0,
ready90Crew: 0,
ready90Generals: 0,
ready60Crew: 0,
ready60Generals: 0,
defenceReadyCrew: 0,
defenceReadyGenerals: 0,
}
);
return {
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
options: [...selectable]
@@ -194,6 +267,7 @@ export const worldRouter = router({
id: selected.id,
name: selected.name,
nationId: selected.nationId,
nationColor: selectedNation?.color ?? '#000000',
level: selected.level,
region: selected.region,
population: redact(selected.population),
@@ -217,8 +291,11 @@ export const worldRouter = router({
},
},
generals: mappedGenerals,
forceSummary,
lastExecute:
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
typeof asRecord(world?.meta).turntime === 'string'
? String(asRecord(world?.meta).turntime).slice(5, 19)
: '',
};
}),
getState: procedure.query(async ({ ctx }) => {
+98 -56
View File
@@ -1,15 +1,15 @@
import { createTournamentRng } from '@sammo-ts/common';
import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common';
import { resolveTournamentBattle } from '@sammo-ts/logic';
import {
createGamePostgresConnector,
createRedisConnector,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
type GamePrismaClient,
} from '@sammo-ts/infra';
import { resolveGameApiConfigFromEnv } from '../config.js';
import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js';
@@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: {
return null;
}
let settledState: TournamentState | null = null;
let settledState = state;
let changed = false;
if (!state.rewardSettled) {
const requireSuccessfulResult = (
result: TurnDaemonCommandResult | null,
expectedType: TurnDaemonCommandResult['type']
): void => {
if (!result) {
throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`);
}
if (result.type !== expectedType) {
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
}
if (!result.ok) {
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
}
};
if (!settledState.rewardSettled) {
const matches = await store.getMatches();
const rewardPayload = buildTournamentRewardPayload(matches);
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentReward',
requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`,
tournamentType: state.type,
winnerId: rewardPayload.winnerId,
runnerUpId: rewardPayload.runnerUpId,
@@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: {
top8: rewardPayload.top8,
top4: rewardPayload.top4,
});
requireSuccessfulResult(result, 'tournamentReward');
settledState = {
...(settledState ?? state),
...settledState,
rewardSettled: true,
};
changed = true;
await store.setState(settledState);
}
if (state.bettingId && !state.bettingSettled) {
if (settledState.bettingId && !settledState.bettingSettled) {
const bettingEntries = await store.getBettingEntries();
if (bettingEntries.length > 0) {
const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries);
const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
if (payoutInfo.payouts.length > 0) {
if (payoutInfo.refundAll) {
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentRefund',
bettingId: state.bettingId,
requestId: `tournament:${settledState.bettingId}:betting-refund`,
bettingId: settledState.bettingId,
refunds: payoutInfo.payouts,
reason: 'no_winner',
});
requireSuccessfulResult(result, 'tournamentRefund');
} else {
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentBettingPayout',
bettingId: state.bettingId,
requestId: `tournament:${settledState.bettingId}:betting-payout`,
bettingId: settledState.bettingId,
payouts: payoutInfo.payouts,
reason: 'winner_payout',
});
requireSuccessfulResult(result, 'tournamentBettingPayout');
}
}
}
settledState = {
...(settledState ?? state),
...settledState,
bettingSettled: true,
};
}
if (settledState) {
changed = true;
await store.setState(settledState);
}
return settledState;
return changed ? settledState : null;
};
const needsSettlement = (state: TournamentState): boolean =>
state.stage === 0 &&
Boolean(state.winnerId) &&
(!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled));
export const processTournamentTick = async (options: {
store: TournamentStore;
prisma: GamePrismaClient;
daemonTransport: TurnDaemonTransport;
now?: () => number;
}): Promise<TournamentState | null> => {
const { store, prisma, daemonTransport } = options;
const now = options.now ?? Date.now;
let processedState: TournamentState | null = null;
await store.withMutationLock(async () => {
const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
return;
}
const nextAt = new Date(state.nextAt).getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
return;
}
if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return;
}
const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport);
}
processedState =
(await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
})) ?? nextState;
});
return processedState;
};
export const runTournamentWorker = async (): Promise<void> => {
@@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise<void> => {
await redis.connect();
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
const daemonTransport = new RedisTurnDaemonTransport(redis.client, {
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
const handleExit = async () => {
await redis.disconnect();
@@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise<void> => {
while (true) {
const state = await store.getState();
if (!state || !state.auto) {
if (!state || (!state.auto && !needsSettlement(state))) {
await sleepMs(config.tournamentPollMs);
continue;
}
const nextAt = new Date(state.nextAt).getTime();
const now = Date.now();
if (Number.isFinite(nextAt) && nextAt > now) {
if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
continue;
}
try {
await store.withMutationLock(async () => {
const lockedState = await store.getState();
if (!lockedState || !lockedState.auto) {
return;
}
const lockedNextAt = new Date(lockedState.nextAt).getTime();
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
return;
}
const worldState = await postgres.prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = lockedState;
if (isBattleStage(lockedState.stage)) {
nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(lockedState.stage)) {
nextState = await applyPreBattleStage(
store,
postgres.prisma,
lockedState,
String(baseSeed),
daemonTransport
);
}
await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
});
await processTournamentTick({
store,
prisma: postgres.prisma,
daemonTransport,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise<void> => {
},
},
});
const currentState = (await store.getState()) ?? state;
const nextState: TournamentState = {
...state,
auto: false,
...currentState,
lastError: message,
lastErrorAt: now,
};
+3 -2
View File
@@ -520,8 +520,9 @@ export const buildBettingPayouts = (
const winners = entries.filter((entry) => entry.targetId === winnerId);
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
if (winnersTotal <= 0) {
const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount }));
return { payouts: refunds, total, refundAll: true };
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
// but returns no rewards when nobody selected the winner.
return { payouts: [], total, refundAll: false };
}
const ratio = total / winnersTotal;
const payouts = winners.map((entry) => ({
+55 -13
View File
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
updatedAt: now,
...overrides,
});
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: 'session',
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
sanctions: {},
});
const city = (id: number, nationId: number) => ({
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
meta: {},
});
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
const context = (
options: {
me?: GeneralRow;
roles?: string[];
userId?: string;
nationMeta?: Record<string, unknown>;
nationLevel?: number;
stationCityId?: number;
} = {}
) => {
const me = options.me ?? general();
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
const db = {
general: {
findFirst: vi.fn(async () => me),
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
where.userId === me.userId ? me : null
),
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
if (args.where?.nationId === 1 && args.select?.cityId)
return [
{ cityId: me.cityId },
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
];
if (args.where?.cityId === 2) return [foreign];
if (args.where?.cityId === 3) return [foreign];
if (args.where?.officerLevel) return [];
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
id: 1,
name: '아국',
color: '#008000',
level: 1,
level: options.nationLevel ?? 1,
capitalCityId: 1,
meta: options.nationMeta ?? {},
})),
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(options.roles),
auth: auth(options.roles, options.userId),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
expect(result.generals).toEqual([]);
});
it('derives the actor from the session user instead of accepting another user general', async () => {
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
expect(result.options.map((entry) => entry.id)).toContain(2);
expect(result.visibility.full).toBe(true);
});
it('does not grant a spy city while the nation has no active level', async () => {
const result = await appRouter
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
.world.getCurrentCity({ cityId: 2 });
expect(result.options.map((entry) => entry.id)).not.toContain(2);
expect(result.visibility.full).toBe(false);
});
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
const result = await appRouter
.createCaller(context({ me: general({ cityId: 80 }) }))
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
expect(result.visibility.full).toBe(true);
expect(result.city.population).toBe(1000);
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
expect(result.forceSummary).toMatchObject({
enemyCrew: 777,
enemyArmedGenerals: 1,
enemyGenerals: 1,
});
});
it('allows administrative roles to inspect all city and general fields', async () => {
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
});
it.each(['admin', 'superuser', 'admin.superuser'])(
'allows the %s role to inspect all city and general fields',
async (role) => {
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
}
);
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 45,
intel: 85,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'che_선봉',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
});
const worldState = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 4,
tickSeconds: 3600,
config: {
const: {
availableSpecialWar: ['che_선봉'],
allItems: {
weapon: {
che_무기_12_칠성검: 1,
che_무기_01_단도: 0,
},
},
},
},
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
target?: GeneralRow | null;
inheritancePoint?: number;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const target =
options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
type: command.type,
ok: true,
generalId: command.generalId,
}));
const pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
const db = {
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
worldState: {
findFirst: vi.fn(async () => worldState),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany,
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
target?.id === where.id ? target : null
),
},
inheritancePoint: {
upsert: pointUpsert,
},
inheritanceLog: {
create: logCreate,
findMany: vi.fn(async () => []),
},
inheritanceUserState: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async () => ({})),
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, pointUpsert, logCreate, findMany };
};
describe('inherit router actor and permission boundaries', () => {
it('rejects unauthenticated status and mutations', async () => {
const fixture = buildContext({ auth: null });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
const fixture = buildContext({});
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
expect(status.availableUnique).toEqual([
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
]);
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
expect(fixture.findMany).toHaveBeenCalledWith({
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
});
});
it('does not dispatch or charge when the authenticated user owns no general', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
});
it('mutates only the authenticated user general and inheritance balance', async () => {
const fixture = buildContext({ inheritancePoint: 1000 });
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'patchGeneral',
generalId: 7,
patch: expect.objectContaining({
meta: expect.objectContaining({
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({ inheritancePoint: 1500 });
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).resolves.toEqual({
ok: true,
ownerName: '위유저',
targetName: '조조',
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
});
+111 -3
View File
@@ -12,6 +12,7 @@ import type {
TournamentState,
} from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis {
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker (in-memory)', () => {
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
expect(
buildBettingPayouts(10, [
{ generalId: 1, targetId: 11, amount: 100 },
{ generalId: 2, targetId: 12, amount: 200 },
])
).toEqual({ payouts: [], total: 300, refundAll: false });
});
it('locks 64 applicants into eight groups of eight', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
const sent: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async (command) => {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
sent.push(command);
return 'ok';
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: command.payouts.length,
missing: 0,
totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0),
};
}
return null;
},
requestCommand: async () => null,
requestStatus: async () => null,
};
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
}
expect(await store.getState()).toMatchObject({
rewardSettled: true,
bettingSettled: true,
});
});
it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry'));
const state = createTournamentState({
stage: 0,
auto: false,
winnerId: 10,
bettingId: 124,
rewardSettled: false,
bettingSettled: false,
});
await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]);
await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]);
await store.setState(state);
let payoutAttempts = 0;
const commands: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
payoutAttempts += 1;
if (payoutAttempts === 1) {
return {
type: 'tournamentBettingPayout',
ok: false,
bettingId: command.bettingId,
reason: '일시적 실패',
};
}
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: 1,
missing: 0,
totalPayout: 100,
};
}
return null;
},
requestStatus: async () => null,
};
await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow(
'일시적 실패'
);
const afterFailure = await store.getState();
expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false });
await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! });
expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true });
expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1);
expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2);
expect(
commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId)
).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']);
});
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {
+192 -11
View File
@@ -1,6 +1,28 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const response = (data: unknown) => ({ result: { data } });
const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR;
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const readImage = async (relativePath: string): Promise<Buffer> => {
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
for (const root of imageRoots) {
try {
return await readFile(resolve(root, relativePath));
} catch {
// Product checkout and feature worktrees have different image-root parents.
}
}
throw new Error(`Fixture image not found: ${relativePath}`);
};
const imageContentType = (relativePath: string) => {
if (relativePath.endsWith('.png')) return 'image/png';
if (relativePath.endsWith('.gif')) return 'image/gif';
return 'image/jpeg';
};
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const city = {
@@ -46,19 +68,68 @@ const layout = {
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
};
const generalContext = {
general: {
id: 1,
name: '장수',
nationId: 1,
cityId: 1,
officerLevel: 1,
npcState: 0,
troopId: 0,
picture: null,
imageServer: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1000,
rice: 1000,
crew: 500,
train: 90,
atmos: 90,
injury: 0,
experience: 0,
dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
},
city,
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
settings: {},
penalties: {},
};
const emptyMessages = {
private: [],
national: [],
public: [],
diplomacy: [],
sequence: -1,
hasMore: { private: false, national: false, public: false, diplomacy: false },
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
canRespondDiplomacy: false,
};
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_info');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/image/**', async (route) => {
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
await route.fulfill({
status: 200,
contentType: imageContentType(relativePath),
body: await readImage(relativePath),
});
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMap') return response(map);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral') return response([]);
if (operation === 'messages.getRecent') return response(emptyMessages);
if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'nation.getNationInfo')
return response({
nation: {
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
id: 1,
name: '업',
nationId: 1,
nationColor: '#008000',
level: 8,
region: 1,
population: mode === 'wanderer' ? null : 150000,
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
intelligence: 50,
injury: 0,
officerLevel: 1,
leadershipBonus: 0,
defenceTrain: 80,
crewTypeId: 1,
crewTypeName: '보병',
crew: 500,
train: 90,
atmos: 90,
turns: ['징병'],
},
],
forceSummary: {
enemyCrew: 0,
enemyArmedGenerals: 0,
enemyGenerals: 0,
ownCrew: mode === 'wanderer' ? 0 : 500,
ownArmedGenerals: mode === 'wanderer' ? 0 : 1,
ownGenerals: mode === 'wanderer' ? 0 : 1,
ready90Crew: mode === 'wanderer' ? 0 : 500,
ready90Generals: mode === 'wanderer' ? 0 : 1,
ready60Crew: mode === 'wanderer' ? 0 : 500,
ready60Generals: mode === 'wanderer' ? 0 : 1,
defenceReadyCrew: mode === 'wanderer' ? 0 : 500,
defenceReadyGenerals: mode === 'wanderer' ? 0 : 1,
},
lastExecute: '2026-07-26',
});
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => {
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
for (const [path, selector] of [
['nation/info', '.legacy-info-page'],
['nation/cities', '.nation-cities-page'],
['global-info', '.global-page'],
['current-city', '.city-page'],
for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
] as const) {
await go(page, path);
await expect(page.locator(selector)).toBeVisible();
@@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
});
expect(box.width).toBe(1000);
expect(box.x).toBe(100);
expect(box.fontSize).toBe('14px');
expect(box.fontFamily).toContain('Pretendard');
expect(box.fontSize).toBe(fontSize);
expect(box.fontFamily).toContain(fontFamily);
expect(
await page
.locator('table')
.first()
.evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe('collapse');
).toBe(borderCollapse);
}
});
@@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
await install(page, 'admin');
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수');
await expect(page.locator('.generals')).toContainText('90');
const legacyGeometry = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector(selector)?.getBoundingClientRect();
return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
};
const icon = document.querySelector<HTMLImageElement>('.general-icon');
return {
selector: rect('#citySelector'),
stats: rect('.stats'),
generals: rect('.generals'),
titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign,
icon: icon
? {
...rect('.general-icon'),
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
}
: null,
};
});
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
expect(legacyGeometry.titleAlign).toBe('start');
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
const computedDom = await page.evaluate(() => {
const measure = (selector: string) => {
const element = document.querySelector(selector);
if (!element) return null;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderCollapse: style.borderCollapse,
padding: style.padding,
textAlign: style.textAlign,
},
};
};
const icon = document.querySelector<HTMLImageElement>('.general-icon');
return {
body: measure('body'),
page: measure('.city-page'),
selector: measure('#citySelector'),
stats: measure('.stats'),
generals: measure('.generals'),
title: measure('.city-title'),
firstIcon: icon
? {
...measure('.general-icon'),
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
}
: null,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
});
await writeFile(
resolve(artifactRoot, 'core-current-city-computed-dom.json'),
`${JSON.stringify(computedDom, null, 2)}\n`
);
await page.screenshot({
path: resolve(artifactRoot, 'core-current-city-desktop.png'),
fullPage: true,
animations: 'disabled',
});
}
});
test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, '');
const cityLink = page.locator('.map-city').first();
await expect(cityLink).toBeVisible();
await cityLink.hover();
await expect(cityLink).toHaveCSS('cursor', 'pointer');
await cityLink.click();
await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/);
await expect(page.locator('.stats')).toContainText('업');
});
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
</script>
<template>
<div
<RouterLink
class="map-city"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:class="[
`state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
>
<div
class="city-dot"
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
<span v-if="props.city.isCapital" class="capital" />
</div>
<div
v-if="props.city.state > 0"
class="city-state"
:class="`state-${props.city.stateClass}`"
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
:style="{
width: `${stateSize}px`,
height: `${stateSize}px`,
left: `${stateOffset}px`,
top: `${stateOffset}px`,
}"
/>
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</div>
</RouterLink>
</template>
<style scoped>
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
}
.city-dot {
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
</script>
<template>
<div
<RouterLink
class="city-base"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</div>
</RouterLink>
</template>
<style scoped>
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.9);
cursor: pointer;
text-decoration: none;
}
.city-bg {
+309 -85
View File
@@ -1,32 +1,110 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number];
const route = useRoute();
const router = useRouter();
const data = ref<Result | null>(null);
const error = ref('');
const selected = ref<number>();
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
let loadSequence = 0;
const parseCityId = (): number | undefined => {
const raw = route.query.cityId ?? route.query.citylist;
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
const cityId = Number(value);
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
};
const load = async (cityId?: number) => {
const sequence = ++loadSequence;
try {
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
selected.value = data.value.city.id;
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
if (sequence !== loadSequence) return;
data.value = result;
selected.value = result.city.id;
error.value = '';
} catch (cause) {
if (sequence !== loadSequence) return;
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
}
};
watch(
() => [route.query.cityId, route.query.citylist],
() => void load(parseCityId()),
{ immediate: true }
);
const selectCity = async () => {
if (!selected.value) return;
await router.push({ name: 'current-city', query: { cityId: selected.value } });
};
const city = computed(() => data.value?.city);
onMounted(() => void load());
const summary = computed(() => data.value?.forceSummary);
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
const populationRate = computed(() => {
if (!city.value || city.value.population === null) return '?';
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
});
const contrastColors = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const cityTitleStyle = computed(() => {
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
return {
backgroundColor,
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
};
});
const woundedStat = (value: number, injury: number) =>
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
const defenceTrainText = (value: number | null) => {
if (value === null) return '?';
if (value === 999) return '×';
if (value >= 90) return '☆';
if (value >= 80) return '◎';
if (value >= 60) return '○';
return '△';
};
const generalImage = (general: General) => {
const picture = general.picture ?? 'default.jpg';
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
</script>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0 center">
<table class="legacy-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
<td> <br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
@@ -34,32 +112,57 @@ onMounted(() => void load());
<tbody>
<tr>
<td>
도시선택 :
<select v-model.number="selected" @change="load(selected)">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
<form @submit.prevent="selectCity">
<div>
도시선택 :
<select id="citySelector" v-model.number="selected" @change="selectCity">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
</div>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</form>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-if="data && city">
<table class="legacy-table legacy-bg2 stats">
<table class="legacy-table legacy-bg0 back-row">
<tbody>
<tr>
<td colspan="11" class="city-title">
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg2 stats">
<colgroup>
<col class="label-col" />
<col class="first-value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
</colgroup>
<tbody>
<tr>
<td colspan="11" class="city-title" :style="cityTitleStyle">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }}
</td>
<td class="city-title">{{ data.lastExecute }}</td>
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
</tr>
<tr>
<th>주민</th>
@@ -79,15 +182,9 @@ onMounted(() => void load());
<th>민심</th>
<td>{{ show(city.trust) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<td>{{ city.trade ?? '- ' }}%</td>
<th>인구</th>
<td>
{{
city.population === null
? '?'
: ((city.population / city.populationMax) * 100).toFixed(2)
}}%
</td>
<td>{{ populationRate }}%</td>
<th>태수</th>
<td>{{ city.officers[4] }}</td>
<th>군사</th>
@@ -95,19 +192,60 @@ onMounted(() => void load());
<th>종사</th>
<td>{{ city.officers[2] }}</td>
</tr>
<tr v-if="summary">
<th>도시명</th>
<td>{{ city.name }}</td>
<th>적군</th>
<td>
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
show(summary.enemyGenerals)
}})
</td>
<th>병장()</th>
<td>
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
show(summary.ownGenerals)
}})
</td>
<th>90병장</th>
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
<th>60병장</th>
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
<th>수비</th>
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="11">
{{
data.visibility.detailed
? data.generals.map((g) => g.name).join(', ') || '-'
: '알 수 없음'
}}
<td colspan="11" class="general-names">
<template v-if="data.visibility.detailed">
<template v-if="data.generals.length">
<template v-for="(general, index) in data.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
><template v-if="index < data.generals.length - 1">, </template>
</template>
</template>
<template v-else>-</template>
</template>
<span v-else class="unknown"> 없음</span>
</td>
</tr>
</tbody>
</table>
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
<colgroup>
<col style="width: 64px" />
<col style="width: 128px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 78px" />
<col style="width: 28px" />
<col style="width: 78px" />
<col style="width: 78px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 280px" />
</colgroup>
<thead>
<tr>
<th> </th>
@@ -125,42 +263,53 @@ onMounted(() => void load());
</tr>
</thead>
<tbody>
<tr v-for="general in data.generals" :key="general.id">
<td>
<img
v-if="general.picture"
width="64"
height="64"
:src="`/image/general/${general.picture}`"
/>
<tr
v-for="general in data.generals"
:key="general.id"
:data-is-our-general="general.train !== null"
:data-general-wounded="general.injury"
>
<td class="icon-cell">
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
</td>
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.leadership, general.injury)
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.strength, general.injury) }}
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.intelligence, general.injury) }}
</td>
<td>{{ general.name }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ general.defenceTrain ?? '?' }}</td>
<td>{{ general.crewTypeId ?? '?' }}</td>
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
<td>{{ general.crewTypeName ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns">
{{
general.turns.length
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
: general.npcState > 1
? 'NPC 장수'
: `${general.nationName}】 장수`
}}
<template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
>{{ index + 1 }} : {{ turn }}</span
>
</template>
<template v-else-if="general.npcState > 1">NPC 장수</template>
<template v-else-if="general.nationId !== data.me.nationId">
{{ general.nationId === 0 ? ' ' : `${general.nationName} 장수` }}
</template>
</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 center footer">
<table class="legacy-table legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
@@ -170,60 +319,138 @@ onMounted(() => void load());
<style scoped>
.city-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
margin: 8px auto 0;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 2px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
border: 0;
padding: 1px;
font-weight: 400;
}
.center {
.center,
.selector,
.stats td,
.stats th,
.generals th,
.generals td:not(:last-child) {
text-align: center;
}
.selector {
text-align: center;
margin-top: 0;
}
.selector select {
display: inline-block;
min-width: 400px;
height: 19px;
padding: 0;
border: 1px solid #767676;
background: #6b6b6b;
color: #fff;
font-family: Arial, sans-serif;
font-size: 13.3333px;
}
.selector {
transform: translateY(-2px);
}
.selector p {
margin: 1em 0;
}
.back-row {
margin-top: 14px;
}
.stats {
margin-top: 14px;
margin-top: 0;
table-layout: fixed;
}
.label-col {
width: 48px;
}
.value-col {
width: 108px;
}
.first-value-col {
width: 112px;
}
.stats th,
.generals th {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
text-align: center;
}
.stats td {
text-align: center;
}
.city-title {
text-align: center;
}
.generals {
margin-top: 14px;
.stats {
height: 136px;
}
.generals td {
text-align: center;
.general-names {
text-align: left !important;
}
.unknown {
color: gray;
}
.generals {
width: 1024px;
margin: 18px 0 0 50%;
table-layout: fixed;
transform: translateX(-50%);
}
.generals td:last-child {
text-align: left;
padding-left: 1em;
}
.icon-cell {
height: 64px;
padding: 0 !important;
}
.generals tbody tr {
height: 72px;
}
.general-icon {
display: block;
width: 64px;
min-width: 64px;
height: 64px;
object-fit: fill;
}
.turns {
font-size: x-small;
}
.turn-line {
display: block;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.footer {
margin-top: 14px;
}
.back-link {
display: inline-block;
border: 1px solid #6c757d;
border-radius: 0.2rem;
background: #6c757d;
color: #fff;
padding: 0.25rem 0.5rem;
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1;
text-decoration: none;
}
.back-link:hover,
.back-link:focus,
.back-link:active {
border-color: #565e64;
background: #5c636a;
color: #fff;
}
.error {
text-align: center;
color: #ff7373;
@@ -233,8 +460,5 @@ onMounted(() => void load());
width: 1000px;
transform-origin: top left;
}
.selector select {
min-width: 300px;
}
}
</style>
+545 -448
View File
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
@@ -12,8 +10,8 @@ type BuffKey =
| 'warAvoidRatio'
| 'warCriticalRatio'
| 'warMagicTrialProb'
| 'success'
| 'fail'
| 'domesticSuccessProb'
| 'domesticFailProb'
| 'warAvoidRatioOppose'
| 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose';
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'success',
'fail',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가',
fail: '내정 실패 감소',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패 확률 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
const pointLabels: Record<string, string> = {
previous: '보유',
lived_month: '생존 턴',
max_domestic_critical: '내정 최고치',
active_action: '활동',
combat: '전투',
sabotage: '계략',
dex: '숙련',
unifier: '통일 보상',
max_domestic_critical: '최대 연속 내정 성공',
active_action: '능동 행동 수',
combat: '전투 횟수',
sabotage: '계략 성공 횟수',
dex: '숙련',
unifier: '천통 기여',
tournament: '토너먼트',
betting: '베팅',
max_belong: '최대 충성',
betting: '베팅 당첨',
max_belong: '최대 임관년 수',
};
const pointOrder = [
'previous',
'lived_month',
'max_belong',
'max_domestic_critical',
'active_action',
'combat',
@@ -65,9 +64,33 @@ const pointOrder = [
'unifier',
'tournament',
'betting',
'max_belong',
] as const;
const pointHelp: Record<string, string> = {
previous: '이전에 물려받은 포인트입니다.',
lived_month: '살아남은 기간입니다. (1개월 단위)',
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
combat: '전투 횟수입니다.',
sabotage: '계략 성공 횟수입니다.',
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
tournament: '토너먼트 입상 포인트입니다.',
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
};
const buffHelp: Record<BuffKey, string> = {
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
};
const loading = ref(true);
const error = ref<string | null>(null);
const status = ref<InheritStatus | null>(null);
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
warAvoidRatio: 1,
warCriticalRatio: 1,
warMagicTrialProb: 1,
success: 1,
fail: 1,
domesticSuccessProb: 1,
domesticFailProb: 1,
warAvoidRatioOppose: 1,
warCriticalRatioOppose: 1,
warMagicTrialProbOppose: 1,
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
const resetStatCost = computed(() =>
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
);
const resetStatErrors = computed(() => {
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
}));
});
const previousPoint = computed(() => status.value?.items.previous ?? 0);
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
const specialNameMap = computed(() => {
const map = new Map<string, string>();
for (const entry of status.value?.availableSpecialWar ?? []) {
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
return map;
});
const currentSpecialName = computed(() => {
if (!status.value) {
return '-';
}
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
});
const buffCost = (key: BuffKey, target: number): number => {
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
const current = status.value?.buffLevels[key] ?? 0;
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
};
const buffTargetOptions = (key: BuffKey): number[] => {
const current = status.value?.buffLevels[key] ?? 0;
const start = Math.min(5, Math.max(1, current + 1));
const result: number[] = [];
for (let level = start; level <= 5; level += 1) {
result.push(level);
}
return result.length > 0 ? result : [5];
};
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
return 'unknown_error';
};
const applyResetBalanced = () => {
const rules = statRules.value;
if (!rules) {
return;
}
const base = Math.floor(rules.total / 3);
resetStatForm.leadership = rules.total - base * 2;
resetStatForm.strength = base;
resetStatForm.intel = base;
};
const syncSelections = () => {
if (!status.value) {
return;
@@ -235,6 +233,14 @@ const syncSelections = () => {
if (!uniqueForm.amount) {
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
}
if (!uniqueForm.itemId) {
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
}
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
resetStatForm.leadership = status.value.currentStat.leadership;
resetStatForm.strength = status.value.currentStat.strength;
resetStatForm.intel = status.value.currentStat.intel;
}
};
const loadStatus = async () => {
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
const openUniqueAuction = async () => {
if (!uniqueForm.itemId.trim()) {
actionError.value = '유니크 아이템 ID를 입력해주세요.';
actionError.value = '유니크를 선택해주세요.';
return;
}
const amount = Math.max(0, Math.floor(uniqueForm.amount));
@@ -412,12 +418,6 @@ const checkOwner = async () => {
});
};
watch(statRules, (rules) => {
if (rules) {
applyResetBalanced();
}
});
onMounted(() => {
void loadStatus();
void loadJoinConfig();
@@ -426,464 +426,561 @@ onMounted(() => {
</script>
<template>
<main class="inherit-page">
<header class="inherit-header">
<div>
<h1 class="inherit-title">유산 포인트</h1>
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
</div>
<div class="inherit-actions">
<button class="ghost" @click="loadStatus">새로고침</button>
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
</div>
</header>
<header class="top-back-bar legacy-bg0">
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
<strong>유산 관리</strong>
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
</header>
<div v-if="error" class="inherit-error">{{ error }}</div>
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
<main id="container" class="inherit-page legacy-bg0">
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
<div v-if="loading" class="loading-state">불러오는 중...</div>
<div v-if="loading">
<SkeletonLines :lines="4" />
</div>
<template v-else-if="status">
<section id="inheritance_list" class="point-grid">
<article id="inherit_sum" class="inherit-item">
<label for="inherit_sum_value"> 포인트</label>
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
<small>다음 플레이에서 사용할 있는 포인트입니다.</small>
</article>
<article id="inherit_previous" class="inherit-item">
<label for="inherit_previous_value">기존 포인트</label>
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
<small>이전에 물려받은 포인트입니다.</small>
</article>
<article id="inherit_new" class="inherit-item">
<label for="inherit_new_value">신규 포인트</label>
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
<small>이번 플레이에서 얻은 포인트입니다.</small>
</article>
<div class="divider"></div>
<article
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
:id="`inherit_${entry.key}`"
:key="entry.key"
class="inherit-item"
>
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
<input
:id="`inherit_${entry.key}_value`"
:value="Math.floor(entry.value).toLocaleString()"
readonly
/>
<small>{{ pointHelp[entry.key] }}</small>
</article>
</section>
<section v-else class="inherit-grid">
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
<div v-else class="summary-panel">
<div class="summary-head">
<div class="summary-total"> {{ status.totalPoint }} 포인트</div>
<div class="summary-state" :class="{ united: status.isUnited }">
{{ status.isUnited ? '통일 완료' : '진행 중' }}
</div>
</div>
<div class="summary-list">
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
<span>{{ entry.label }}</span>
<span>{{ entry.value }}</span>
</div>
</div>
<div class="summary-footer">
<div>현재 전투 특기: {{ currentSpecialName }}</div>
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}</div>
<div> 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}</div>
</div>
</div>
</PanelCard>
<section id="inheritance_store">
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
<div v-else class="buff-list">
<div v-for="key in buffKeys" :key="key" class="buff-row">
<div class="buff-info">
<div class="buff-name">{{ buffLabels[key] }}</div>
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
</div>
<div class="buff-action">
<select v-model.number="buffTargets[key]" class="form-input">
<option
v-for="level in buffTargetOptions(key)"
:key="level"
:value="level"
>
{{ level }} 단계
<div class="action-grid leading-actions">
<article class="shop-item">
<div class="control-row">
<label for="next-special">다음 전투 특기 선택</label>
<select id="next-special" v-model="nextSpecialKey">
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
{{ entry.name }}
</option>
</select>
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
</div>
<small
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
></small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy"
@click="reserveSpecialWar"
>
구입
</button>
</article>
<article class="shop-item">
<div class="control-row">
<label for="specific-unique">유니크 경매</label>
<select id="specific-unique" v-model="uniqueForm.itemId">
<option disabled value="">유니크 선택</option>
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
{{ item.name }}
</option>
</select>
</div>
<div class="control-row">
<label for="specific-unique-amount">입찰 포인트</label>
<input
id="specific-unique-amount"
v-model.number="uniqueForm.amount"
type="number"
:min="status.inheritConst.inheritItemUniqueMinPoint"
:max="previousPoint"
/>
</div>
<small
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24 동안 진행됩니다.<br />{{
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
}}</small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy"
@click="openUniqueAuction"
>
경매 시작
</button>
</article>
</div>
<div class="divider"></div>
<div class="action-grid">
<article class="shop-item simple-item">
<div class="control-row">
<span>랜덤 초기화</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
구입
</button>
</div>
<small
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
>
</article>
<article class="shop-item simple-item">
<div class="control-row">
<span>랜덤 유니크 획득</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
구입
</button>
</div>
<small
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
></small
>
</article>
<article class="shop-item simple-item">
<div class="control-row">
<span>즉시 전투 특기 초기화</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
구입
</button>
</div>
<small
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
></small
>
</article>
</div>
<div class="divider"></div>
<div class="buff-grid">
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
<div class="control-row">
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
<input
:id="`buff-${key}`"
v-model.number="buffTargets[key]"
type="number"
:min="status.buffLevels[key] ?? 0"
max="5"
/>
</div>
<small
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
>
<div class="dual-buttons">
<button
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
class="legacy-button secondary"
:disabled="actionBusy"
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
>
리셋
</button>
<button
class="legacy-button"
:disabled="isUnited || actionBusy"
@click="buyHiddenBuff(key)"
>
구입
</button>
</div>
</div>
</article>
</div>
</PanelCard>
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<label class="form-field">
<span>다음 전투 특기</span>
<select v-model="nextSpecialKey" class="form-input">
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
{{ special.name }}
<div class="divider"></div>
<div class="action-grid bottom-actions">
<article class="shop-item">
<div class="control-row">
<label for="owner-target">장수 소유자 확인</label>
<select id="owner-target" v-model="ownerTargetId">
<option disabled value="">장수 선택</option>
<option
v-for="general in status.availableTargetGenerals"
:key="general.id"
:value="String(general.id)"
>
{{ general.name }}
</option>
</select>
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
</div>
<div class="action-row">
<div>
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
<div class="muted">
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
({{ status.resetLevels.resetSpecialWar }})
</div>
<small
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
></small
>
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
소유자 찾기
</button>
<p v-if="ownerResult" class="owner-result">
{{ ownerResult.targetName }} 소유자: {{ ownerResult.ownerName }}
</p>
</article>
<article class="shop-item stat-reset">
<div class="stat-layout">
<span>능력치 초기화</span>
<div>
<strong>기본 능력치</strong>
<label
>
<input
v-model.number="resetStatForm.leadership"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<label
>
<input
v-model.number="resetStatForm.strength"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<label
>
<input
v-model.number="resetStatForm.intel"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<strong>추가 능력치</strong>
<label
> <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
/></label>
<label
> <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
/></label>
<label
> <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
/></label>
</div>
</div>
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
</div>
<small
>시즌 1회에 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
@click="resetStats"
>
능력치 초기화
</button>
</article>
</div>
</PanelCard>
</section>
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
<div v-if="!status" class="muted"> 시간 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<div class="muted">
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }})
</div>
<button :disabled="isUnited || actionBusy" @click="resetTurnTime"> 시간 변경</button>
</div>
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
<section class="inherit-logs">
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
<span>{{ entry.text }}</span>
</div>
</PanelCard>
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
<div v-else class="stat-panel">
<div class="stat-grid">
<label class="form-field">
<span>통솔</span>
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
</label>
<label class="form-field">
<span>무력</span>
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
</label>
<label class="form-field">
<span>지력</span>
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
</label>
</div>
<div class="stat-grid">
<label class="form-field">
<span>보너스 통솔</span>
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
</label>
<label class="form-field">
<span>보너스 무력</span>
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
</label>
<label class="form-field">
<span>보너스 지력</span>
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
</label>
</div>
<div class="stat-summary">
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
<div>보너스 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
<div v-if="resetStatErrors.length" class="stat-errors">
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
</div>
</div>
<div class="action-row">
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
</div>
</div>
</PanelCard>
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
</div>
<div class="action-row">
<label class="form-field">
<span>유니크 아이템 ID</span>
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
</label>
<label class="form-field">
<span>입찰 포인트</span>
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
</div>
</div>
</PanelCard>
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<label class="form-field">
<span>대상 장수</span>
<select v-model="ownerTargetId" class="form-input">
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
{{ general.name }}
</option>
</select>
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
<div v-if="ownerResult" class="muted">
{{ ownerResult.targetName }} 소유자: {{ ownerResult.ownerName }}
</div>
</div>
</PanelCard>
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
<template #actions>
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
</template>
<div v-if="logLoading && logs.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
<div v-else class="log-list">
<div v-for="entry in logs" :key="entry.id" class="log-entry">
<span class="log-date">{{ entry.year }} {{ entry.month }}</span>
<span>{{ entry.text }}</span>
</div>
</div>
<div class="log-footer">
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()"> 보기</button>
</div>
</PanelCard>
</section>
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
가져오기
</button>
</section>
</template>
</main>
</template>
<style scoped>
.inherit-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.inherit-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
}
.inherit-title {
font-size: 1.6rem;
font-weight: 600;
}
.inherit-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.inherit-actions {
display: flex;
gap: 8px;
}
.inherit-error {
border: 1px solid rgba(240, 90, 90, 0.6);
padding: 8px 10px;
color: rgba(240, 150, 150, 0.9);
}
.inherit-message {
border: 1px solid rgba(120, 190, 120, 0.5);
padding: 8px 10px;
color: rgba(180, 230, 180, 0.9);
}
.inherit-grid {
.top-back-bar {
width: min(100%, 1000px);
min-height: 38px;
margin: 0 auto;
border: 1px solid #888;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.summary-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.summary-head {
display: flex;
grid-template-columns: 100px 1fr 100px;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: center;
padding: 3px 6px;
box-sizing: border-box;
}
.summary-total {
font-size: 1rem;
font-weight: 600;
}
.summary-state {
font-size: 0.75rem;
.top-button {
padding: 4px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
text-decoration: none;
}
.summary-state.united {
border-color: rgba(240, 120, 120, 0.6);
color: rgba(240, 150, 150, 0.9);
.inherit-page {
width: min(100%, 1000px);
margin: 0 auto;
border: 1px solid #888;
border-top: 0;
overflow: hidden;
box-sizing: border-box;
padding: 0 8px 10px;
color: #fff;
font:
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
}
.summary-list {
display: flex;
flex-direction: column;
gap: 4px;
.notice,
.loading-state,
.log-empty {
padding: 10px;
text-align: center;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
.notice.error {
color: #ffb0b0;
}
.summary-footer {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
.notice.success {
color: #b6efb6;
}
.buff-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.buff-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
}
.buff-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.buff-name {
font-weight: 600;
font-size: 0.85rem;
}
.buff-level {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.7);
}
.buff-action {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.buff-cost {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.action-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.stat-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-grid {
.point-grid,
.action-grid,
.buff-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-summary {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
display: flex;
flex-direction: column;
gap: 4px;
.inherit-item,
.shop-item {
padding: 8px 16px;
box-sizing: border-box;
min-width: 0;
}
.stat-errors {
color: rgba(240, 150, 150, 0.9);
.inherit-item {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
align-items: start;
gap: 6px;
}
.form-field {
.inherit-item label {
text-align: right;
padding: 7px 8px 0 0;
}
.inherit-item input,
.shop-item input,
.shop-item select {
width: 100%;
min-width: 0;
border: 1px solid #6c757d;
border-radius: 4px;
background: #212529;
color: #fff;
padding: 6px 8px;
box-sizing: border-box;
}
.inherit-item small,
.shop-item small {
grid-column: 1 / -1;
min-height: 34px;
text-align: right;
color: #aeb2b6;
}
.inherit-item small {
min-height: 36px;
}
.divider {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.22);
margin: 4px 2px;
}
.section-title {
font-size: 14px;
font-weight: 400;
text-align: center;
margin: 0 -8px;
padding: 2px;
}
.leading-actions .shop-item:first-child {
grid-column: 2;
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 8px;
min-height: 36px;
}
.control-row > label,
.control-row > span {
text-align: right;
}
.shop-item {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
}
.form-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(10, 10, 10, 0.8);
padding: 6px 8px;
color: inherit;
.shop-item .buy-button {
width: 50%;
margin-left: auto;
}
button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
background: rgba(16, 16, 16, 0.6);
color: inherit;
.simple-item small {
min-height: 55px;
}
button.ghost {
background: transparent;
.buff-item small {
min-height: 72px;
}
.log-list {
display: flex;
flex-direction: column;
.dual-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
}
.legacy-button.secondary {
border-color: #51585e;
background: #5c636a;
}
.bottom-actions .shop-item:first-child {
grid-column: 1;
}
.stat-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
text-align: right;
}
.log-entry {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8rem;
.stat-layout > div {
display: grid;
gap: 3px;
}
.log-date {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
.stat-layout label {
display: grid;
grid-template-columns: 22px 1fr;
align-items: center;
}
.log-footer {
margin-top: 8px;
.stat-layout strong {
text-align: left;
font-size: 12px;
}
.muted {
color: rgba(232, 221, 196, 0.6);
font-size: 0.75rem;
.owner-result {
margin: 0;
color: #fff;
text-align: right;
}
.inherit-logs {
margin: 8px 0 0;
}
.log-row {
display: grid;
grid-template-columns: minmax(150px, 20ch) 1fr;
gap: 8px;
padding: 3px 8px;
}
.log-row small {
color: #aeb2b6;
text-align: right;
font-variant-numeric: tabular-nums;
}
.more-button {
width: 100%;
margin-top: 6px;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
@media (max-width: 767px) {
.point-grid,
.action-grid,
.buff-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.leading-actions .shop-item:first-child {
grid-column: 1;
}
}
@media (max-width: 575px) {
.top-back-bar,
.inherit-page {
width: 500px;
max-width: 100%;
}
.point-grid,
.action-grid,
.buff-grid {
grid-template-columns: 1fr;
}
.divider {
grid-column: 1;
}
.inherit-item,
.shop-item {
padding-left: 16px;
padding-right: 16px;
}
.log-row {
grid-template-columns: 1fr;
}
.log-row small {
text-align: left;
}
}
</style>
@@ -30,6 +30,11 @@ turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴
`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기
초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을
ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다.
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
비교한다.
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
않아 동적 호환 판정에서 제외한다.
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로
올리지 않는다.
@@ -210,6 +210,12 @@ Three post-required cooldown cases project the legacy `next_execute` KV and
core general meta into the same world-level cooldown record. They cover the
stored `current + 60 - preReq` value, rejection one turn before availability,
and successful execution exactly at the boundary.
Four missing-target cases cover nonexistent general IDs for gift and
employment plus nonexistent city IDs for spying and movement. Both engines
reject the requested command, execute rest without command RNG, and produce
the same semantic state delta.
Requests that omit the required argument object remain unverified because the
reference runner did not terminate within the bounded comparison run.
This is not yet a claim that every command-specific
constraint, clamp and persistence boundary has been dynamically
compared.
+26 -16
View File
@@ -43,22 +43,24 @@ storage, route guards, and image loading.
## Enforced contracts
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
@@ -96,6 +98,14 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs
records desktop/500px computed DOM and screenshots from the PHP service without
changing its product code.
The current-city reference can be collected independently with
`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires
`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and
`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file
and is never written to the artifact. The matching core fixture is
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
For a review run that also writes full-page screenshots, create an ignored
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
suite. The ordinary CI run does not write screenshots after successful tests.
+10 -11
View File
@@ -7,8 +7,8 @@ export type InheritBuffType =
| 'warAvoidRatio'
| 'warCriticalRatio'
| 'warMagicTrialProb'
| 'success'
| 'fail'
| 'domesticSuccessProb'
| 'domesticFailProb'
| 'warAvoidRatioOppose'
| 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose';
@@ -25,7 +25,8 @@ const DOMESTIC_TARGETS = new Set<TriggerDomesticActionType>([
]);
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
const raw = buff[key];
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
const raw = buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : undefined);
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
return 0;
}
@@ -40,7 +41,9 @@ const parseInheritBuff = (value: unknown): Record<string, unknown> => {
return asRecord(value);
};
const resolveBuffRecord = (context: { general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } } }): Record<string, unknown> => {
const resolveBuffRecord = (context: {
general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } };
}): Record<string, unknown> => {
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
if (Object.keys(fromTrigger).length > 0) {
return fromTrigger;
@@ -58,11 +61,11 @@ const applyDomesticBuff = (
return value;
}
if (varType === 'success') {
const level = readBuffLevel(buff, 'success');
const level = readBuffLevel(buff, 'domesticSuccessProb');
return value + level * 0.01;
}
if (varType === 'fail') {
const level = readBuffLevel(buff, 'fail');
const level = readBuffLevel(buff, 'domesticFailProb');
return value - level * 0.01;
}
return value;
@@ -84,11 +87,7 @@ const applyWarBuff = (buff: Record<string, unknown>, statName: WarStatName, valu
return value;
};
const applyOpposeWarBuff = (
buff: Record<string, unknown>,
statName: WarStatName,
value: number | [number, number]
) => {
const applyOpposeWarBuff = (buff: Record<string, unknown>, statName: WarStatName, value: number | [number, number]) => {
if (typeof value !== 'number') {
return value;
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { General } from '../src/domain/entities.js';
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
id: 1,
name: 'Tester',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 0,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 0,
rice: 0,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 20,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
});
describe('inheritance buff legacy keys', () => {
it('applies the canonical legacy domestic buff names', () => {
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
const context = {
general: buildGeneral({
domesticSuccessProb: 3,
domesticFailProb: 2,
}),
};
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
});
it('continues to read the earlier core success and fail aliases', () => {
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
const context = {
general: buildGeneral({
success: 2,
fail: 1,
}),
};
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
});
});
@@ -0,0 +1,238 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const imageRoot = resolve(repositoryRoot, '../../image');
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
const response = (data: unknown) => ({ result: { data } });
const operations = (route: Route): string[] => {
const pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installImages = async (page: Page): Promise<void> => {
await page.route('**/image/**', async (route) => {
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
for (const candidate of [
resolve(imageRoot, relative),
resolve(imageRoot, 'game', relative),
resolve(imageRoot, 'icons', '22.jpg'),
]) {
try {
const body = await readFile(candidate);
await route.fulfill({
status: 200,
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
body,
});
return;
} catch {
// 다음 공개 image root 후보를 확인한다.
}
}
await route.abort('failed');
});
};
const statusFixture = {
items: {
previous: 12_000,
lived_month: 240,
max_domestic_critical: 80,
active_action: 35,
combat: 150,
sabotage: 60,
dex: 42,
unifier: 0,
tournament: 30,
betting: 20,
max_belong: 8,
},
totalPoint: 12_665,
inheritConst: {
minMonthToAllowInheritItem: 4,
inheritBornSpecialPoint: 6000,
inheritBornTurntimePoint: 2500,
inheritBornCityPoint: 1000,
inheritBornStatPoint: 1000,
inheritItemUniqueMinPoint: 5000,
inheritItemRandomPoint: 3000,
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
inheritSpecificSpecialPoint: 4000,
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
inheritCheckOwnerPoint: 1000,
},
buffLevels: {
warAvoidRatio: 0,
warCriticalRatio: 1,
warMagicTrialProb: 0,
domesticSuccessProb: 0,
domesticFailProb: 0,
warAvoidRatioOppose: 0,
warCriticalRatioOppose: 0,
warMagicTrialProbOppose: 0,
},
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
availableUnique: [
{
key: 'che_무기_12_칠성검',
name: '칠성검(+12)',
rawName: '칠성검',
info: '무력을 올려주는 유니크 무기입니다.',
},
],
availableTargetGenerals: [{ id: 8, name: '조조' }],
turnTimeZones: ['00:00'],
isUnited: false,
currentSpecialWar: 'che_선봉',
currentStat: { leadership: 70, strength: 45, intel: 85 },
};
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
let buffMutationCount = 0;
await installImages(page);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
window.localStorage.setItem('sammo-game-profile', 'che');
});
await page.route('**/che/api/trpc/**', async (route) => {
const names = operations(route);
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
});
return;
}
const result = names.map((name) => {
if (name === 'inherit.getStatus') return response(statusFixture);
if (name === 'lobby.info') {
return response({
profile: { id: 'che', scenario: 'default', name: '체섭' },
world: { year: 200, month: 4 },
myGeneral: { id: 7, name: '유비', nationId: 1 },
});
}
if (name === 'inherit.getLogs') {
return response([
{
id: 2,
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
createdAt: '2026-07-26T00:00:00.000Z',
},
]);
}
if (name === 'join.getConfig') {
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
}
if (name === 'inherit.buyHiddenBuff') {
buffMutationCount += 1;
return response({ ok: true, remainPoint: 11_800 });
}
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(result),
});
});
return { buffMutationCount: () => buffMutationCount };
};
test.describe('inheritance management legacy parity', () => {
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
await installFixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(gameUrl);
await expect(page.locator('#container')).toBeVisible();
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
const desktop = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: box.x, width: box.width };
};
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
return {
container: rect('#container'),
firstPoint: rect('#inherit_sum'),
fontFamily: container.fontFamily,
fontSize: container.fontSize,
backgroundImage: container.backgroundImage,
titleBackgroundImage: title.backgroundImage,
buttonBackground: button.backgroundColor,
};
});
expect(desktop.container.width).toBe(1000);
expect(desktop.container.x).toBe(140);
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
expect(desktop.fontFamily).toContain('Pretendard');
expect(desktop.fontSize).toBe('14px');
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
const buyButton = page.locator('.buy-button').first();
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
await buyButton.hover();
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(afterHover).not.toBe(beforeHover);
await buyButton.focus();
await expect(buyButton).toBeFocused();
if (artifactRoot) {
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
}
await page.setViewportSize({ width: 500, height: 900 });
await page.reload();
await expect(page.locator('#container')).toBeVisible();
const mobile = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
return {
containerWidth: container.width,
firstWidth: first.width,
stacked: second.y > first.y,
};
});
expect(mobile.containerWidth).toBe(500);
expect(mobile.firstWidth).toBeCloseTo(482, 0);
expect(mobile.stacked).toBe(true);
});
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
const fixture = await installFixture(page);
page.on('dialog', (dialog) => dialog.accept());
await page.goto(gameUrl);
await page.locator('#buff-warAvoidRatio').fill('1');
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
await expect.poll(fixture.buffMutationCount).toBe(1);
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
});
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
await installFixture(page, { failBuff: true });
page.on('dialog', (dialog) => dialog.accept());
await page.goto(gameUrl);
await page.locator('#buff-warAvoidRatio').fill('1');
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
await expect(page.locator('[role="alert"]')).toBeVisible();
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
});
});
@@ -13,6 +13,7 @@ export default defineConfig({
'public-gaps.spec.ts',
'instant-diplomacy-message.spec.ts',
'tournament-betting.spec.ts',
'inheritance-management.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -0,0 +1,128 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_PARITY_USER ?? 'refadmin';
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-current-city');
if (!passwordFile) {
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await loginResponse.json();
if (!loginResponse.ok() || loginResult.result !== true) {
throw new Error('Reference login failed.');
}
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first();
const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false);
let mapInteraction;
if (hasMapCity) {
mapInteraction = await mapCity.evaluate((element) => ({
available: true,
href: element.getAttribute('href'),
cursor: getComputedStyle(element).cursor,
rect: (() => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
})(),
}));
await mapCity.click();
await page.waitForLoadState('networkidle');
if (!page.url().includes('b_currentCity.php?citylist=')) {
throw new Error(`Reference map click did not open current city: ${page.url()}`);
}
} else {
mapInteraction = {
available: false,
pageUrl: page.url(),
pageTitle: await page.title(),
};
await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' });
}
const measurements = await page.evaluate(() => {
const measure = (element) => {
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderCollapse: style.borderCollapse,
padding: style.padding,
textAlign: style.textAlign,
},
};
};
const tables = [...document.querySelectorAll('table')];
const selector = document.querySelector('#citySelector');
const stats = tables.find((table) => table.textContent?.includes('90병장'));
const generals = document.querySelector('#general_list')?.closest('table');
const firstIcon = document.querySelector('.generalIcon');
const title = stats?.querySelector('tr:first-child td');
return {
body: measure(document.body),
tables: tables.map(measure),
selector: selector ? measure(selector) : null,
stats: stats ? measure(stats) : null,
generals: generals ? measure(generals) : null,
firstIcon: firstIcon
? {
...measure(firstIcon),
naturalWidth: firstIcon.naturalWidth,
naturalHeight: firstIcon.naturalHeight,
}
: null,
title: title ? measure(title) : null,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
});
await page.screenshot({
path: resolve(artifactRoot, 'reference-current-city-desktop.png'),
fullPage: true,
animations: 'disabled',
});
await writeFile(
resolve(artifactRoot, 'reference-current-city-computed-dom.json'),
`${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n`
);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`);
await context.close();
} finally {
await browser.close();
}
@@ -0,0 +1,468 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
import { createGatewayApiServer } from '@sammo-ts/gateway-api';
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
import {
buildTournamentKeys,
createGameApiServer,
DatabaseTurnDaemonTransport,
processTournamentTick,
TournamentStore,
} from '@sammo-ts/game-api';
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
import {
createGamePostgresConnector,
createGatewayPostgresConnector,
createRedisConnector,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
type GamePrisma,
} from '@sammo-ts/infra';
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const parseEnvFile = (rawText: string): Record<string, string> => {
const env: Record<string, string> = {};
for (const line of rawText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const separator = trimmed.indexOf('=');
if (separator < 0) {
continue;
}
const key = trimmed.slice(0, separator).trim();
let value = trimmed.slice(separator + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
};
const loadEnv = async (): Promise<void> => {
const values = parseEnvFile(await fs.readFile(path.join(workspaceRoot, '.env.ci'), 'utf8'));
for (const [key, value] of Object.entries(values)) {
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true';
process.env.INTEGRATION_WORLD_SEED ??= 'tournament-lifecycle-seed';
};
const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv): Promise<void> =>
new Promise((resolve, reject) => {
execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`));
return;
}
resolve();
});
});
const ensureSchema = async (schema: string): Promise<void> => {
const connector = createGatewayPostgresConnector({
url: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
});
await connector.connect();
try {
await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
} finally {
await connector.disconnect();
}
};
const truncateSchema = async (schema: string): Promise<void> => {
const connector = createGatewayPostgresConnector({
url: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
});
await connector.connect();
try {
const rows = (await connector.prisma.$queryRawUnsafe(
`SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'`
)) as Array<{ tablename: string }>;
if (rows.length === 0) {
return;
}
const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', ');
await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`);
} finally {
await connector.disconnect();
}
};
const resetServices = async (): Promise<void> => {
await ensureSchema('public');
await ensureSchema('che');
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
...process.env,
POSTGRES_SCHEMA: 'public',
});
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
...process.env,
POSTGRES_SCHEMA: 'che',
});
await truncateSchema('public');
await truncateSchema('che');
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
try {
await redis.client.flushDb();
} finally {
await redis.disconnect();
}
};
const createGatewayClient = (baseUrl: string, pathName: string, token: { value?: string }) =>
createTRPCProxyClient<GatewayAppRouter>({
links: [
httpBatchLink({
url: `${baseUrl}${pathName}`,
headers: () => (token.value ? { 'x-session-token': token.value } : {}),
}),
],
});
const createGameClient = (baseUrl: string, pathName: string, token: { value?: string }) =>
createTRPCProxyClient<GameAppRouter>({
links: [
httpBatchLink({
url: `${baseUrl}${pathName}`,
headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}),
}),
],
});
describe('actual tournament lifecycle', () => {
let gatewayServer: Awaited<ReturnType<typeof createGatewayApiServer>> | null = null;
let gameServer: Awaited<ReturnType<typeof createGameApiServer>> | null = null;
let turnDaemon: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | null = null;
let turnDaemonLoop: Promise<void> | null = null;
let gameConnector: Awaited<ReturnType<typeof createGamePostgresConnector>> | null = null;
let redisConnector: Awaited<ReturnType<typeof createRedisConnector>> | null = null;
let store: TournamentStore | null = null;
let transport: DatabaseTurnDaemonTransport | null = null;
const clients = new Map<string, ReturnType<typeof createGameClient>>();
const generalIds = new Map<string, number>();
beforeAll(async () => {
await loadEnv();
process.env.SCENARIO = '908';
process.chdir(workspaceRoot);
await resetServices();
gatewayServer = await createGatewayApiServer();
await gatewayServer.app.listen({ host: gatewayServer.config.host, port: gatewayServer.config.port });
gameServer = await createGameApiServer();
await gameServer.app.listen({ host: gameServer.config.host, port: gameServer.config.port });
const gatewayUrl = `http://localhost:${gatewayServer.config.port}`;
const gameUrl = `http://localhost:${gameServer.config.port}`;
const adminSession = { value: undefined as string | undefined };
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSession);
const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({
token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '',
username: 'admin',
password: 'admin-pass-123',
displayName: '관리자',
});
adminSession.value = bootstrap.sessionToken;
const users = [
['participant', '대회참가자'],
['bettor-a', '베팅유저A'],
['bettor-b', '베팅유저B'],
['no-general', '무장수유저'],
] as const;
for (const [username, displayName] of users) {
await gatewayClient.admin.users.createLocal.mutate({
username,
password: `${username}-pass`,
displayName,
});
}
await gatewayClient.admin.profiles.upsert.mutate({
profile: 'che',
scenario: '908',
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:908',
install: {
scenarioId: 908,
turnTermMinutes: 1,
sync: false,
fiction: 0,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 0,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
},
});
for (const [username, displayName] of users) {
const login = await gatewayClient.auth.login.mutate({
username,
password: `${username}-pass`,
});
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:908',
});
const accessRef = { value: undefined as string | undefined };
const client = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef);
const access = await client.auth.exchangeGatewayToken.mutate({ gatewayToken: gatewayToken.gameToken });
accessRef.value = access.accessToken;
clients.set(username, client);
if (username !== 'no-general') {
const created = await client.join.createGeneral.mutate({
name: displayName,
leadership: 55,
strength: 55,
intel: 55,
character: 'Random',
});
generalIds.set(username, created.generalId);
}
}
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl });
await gameConnector.connect();
await gameConnector.prisma.general.updateMany({
where: { id: { in: [...generalIds.values()] } },
data: { gold: 10_000 },
});
const maxGeneralId = (await gameConnector.prisma.general.aggregate({ _max: { id: true } }))._max.id ?? 0;
const npcTemplate = await gameConnector.prisma.general.findFirstOrThrow({
where: { id: { in: [...generalIds.values()] } },
});
const {
id: _templateId,
userId: _templateUserId,
name: _templateName,
createdAt: _templateCreatedAt,
updatedAt: _templateUpdatedAt,
...npcTemplateData
} = npcTemplate;
const templateMeta =
typeof npcTemplate.meta === 'object' && npcTemplate.meta !== null && !Array.isArray(npcTemplate.meta)
? npcTemplate.meta
: {};
await gameConnector.prisma.general.createMany({
data: Array.from({ length: 48 }, (_, index): GamePrisma.GeneralCreateManyInput => ({
...npcTemplateData,
id: maxGeneralId + index + 1,
userId: null,
name: `대회NPC${index + 1}`,
npcState: 2,
leadership: 40 + (index % 31),
strength: 40 + ((index * 3) % 31),
intel: 40 + ((index * 7) % 31),
gold: 10_000,
lastTurn: npcTemplate.lastTurn as GamePrisma.InputJsonValue,
meta: { ...templateMeta, explevel: 20 },
penalty: npcTemplate.penalty as GamePrisma.InputJsonValue,
})),
});
redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
await redisConnector.connect();
store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908'));
transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000);
turnDaemon = await createTurnDaemonRuntime({
profile: 'che',
profileName: 'che:908',
databaseUrl: gameDatabaseUrl,
gatewayDatabaseUrl,
redisUrl: resolveRedisConfigFromEnv().url,
});
for (let attempt = 0; attempt < 36; attempt += 1) {
const current = turnDaemon.world.getState().lastTurnTime;
const next = new Date(current.getTime());
next.setUTCMonth(next.getUTCMonth() + 1);
await turnDaemon.world.advanceMonth(next);
if ((await store.getState())?.stage === 1) {
break;
}
}
expect(await store.getState()).toMatchObject({ stage: 1, auto: true });
turnDaemonLoop = turnDaemon.lifecycle.start();
const status = await transport.requestStatus(10_000);
expect(status).not.toBeNull();
}, 120_000);
afterAll(async () => {
if (turnDaemon) {
await turnDaemon.lifecycle.stop('tournament-lifecycle-test');
await turnDaemon.close();
await turnDaemonLoop;
}
await redisConnector?.disconnect();
await gameConnector?.disconnect();
await gameServer?.app.close();
await gatewayServer?.app.close();
}, 30_000);
it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => {
if (!store || !transport || !gameConnector) {
throw new Error('integration runtime is not ready');
}
const participant = clients.get('participant')!;
const bettorA = clients.get('bettor-a')!;
const bettorB = clients.get('bettor-b')!;
const noGeneral = clients.get('no-general')!;
await expect(noGeneral.tournament.getState.query()).rejects.toThrow();
await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true });
await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true });
for (let steps = 0; steps < 2000; steps += 1) {
const state = await store.getState();
if (!state) {
throw new Error('tournament state disappeared');
}
if (state.stage === 6) {
break;
}
await store.setState({
...state,
nextAt: new Date(Date.now() - 1_000).toISOString(),
});
await processTournamentTick({
store,
prisma: gameConnector.prisma,
daemonTransport: transport,
});
}
const bettingState = await store.getState();
expect(bettingState).toMatchObject({ stage: 6, auto: true });
const matches = await store.getMatches();
const candidates = Array.from(
new Set(
matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId])
)
);
expect(candidates).toHaveLength(16);
const idleDeadline = Date.now() + 60_000;
while (Date.now() < idleDeadline) {
const pending = await gameConnector.prisma.inputEvent.count({
where: { status: { in: ['PENDING', 'PROCESSING'] } },
});
if (pending === 0) {
break;
}
await sleep(100);
}
expect(
await gameConnector.prisma.inputEvent.count({
where: { status: { in: ['PENDING', 'PROCESSING'] } },
})
).toBe(0);
for (const targetId of candidates) {
await bettorA.tournament.placeBet.mutate({ targetId, amount: 10 });
}
await bettorB.tournament.placeBet.mutate({ targetId: candidates[0]!, amount: 100 });
const bets = await store.getBettingEntries();
const bettorAId = generalIds.get('bettor-a')!;
const bettorBId = generalIds.get('bettor-b')!;
expect(bets.filter((entry) => entry.generalId === bettorAId)).toHaveLength(16);
expect(bets.some((entry) => entry.generalId === bettorBId && entry.targetId === candidates[0])).toBe(true);
expect(bets.every((entry) => entry.generalId !== generalIds.get('participant'))).toBe(true);
const bettorBeforeSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
where: { id: bettorAId },
select: { gold: true, meta: true },
});
await store.setState({
...bettingState!,
bettingCloseAt: new Date(Date.now() - 1_000).toISOString(),
nextAt: new Date(Date.now() - 1_000).toISOString(),
});
for (let steps = 0; steps < 100; steps += 1) {
const state = await store.getState();
if (!state) {
throw new Error('tournament state disappeared');
}
if (state.stage === 0 && state.rewardSettled && state.bettingSettled) {
break;
}
if (state.stage > 0) {
await store.setState({
...state,
bettingCloseAt:
state.stage === 6 ? new Date(Date.now() - 1_000).toISOString() : state.bettingCloseAt,
nextAt: new Date(Date.now() - 1_000).toISOString(),
});
}
await processTournamentTick({
store,
prisma: gameConnector.prisma,
daemonTransport: transport,
});
}
const finalState = await store.getState();
expect(finalState).toMatchObject({
stage: 0,
auto: false,
rewardSettled: true,
bettingSettled: true,
});
expect(finalState?.winnerId).toBeTypeOf('number');
const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
where: { id: bettorAId },
select: { gold: true, meta: true },
});
expect(bettorAfterSettlement.gold).toBeGreaterThan(bettorBeforeSettlement.gold);
expect(bettorAfterSettlement.meta).toMatchObject({
betgold: 160,
betwin: 1,
});
expect(Number((bettorAfterSettlement.meta as Record<string, unknown>).betwingold)).toBeGreaterThan(0);
const settlementEvents = await gameConnector.prisma.inputEvent.findMany({
where: { eventType: { in: ['tournamentReward', 'tournamentBettingPayout'] } },
select: { eventType: true, status: true, result: true },
});
expect(settlementEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({ eventType: 'tournamentReward', status: 'SUCCEEDED' }),
expect.objectContaining({ eventType: 'tournamentBettingPayout', status: 'SUCCEEDED' }),
])
);
expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true);
}, 120_000);
});
@@ -1058,6 +1058,57 @@ integration('general command post-required cooldown boundary matrix', () => {
}, 120_000);
});
const missingTargetCases: Array<{ name: string; action: string; args: Record<string, unknown> }> = [
{
name: 'gift to a missing general',
action: 'che_증여',
args: { isGold: true, amount: 100, destGeneralID: 999 },
},
{
name: 'spy on a missing city',
action: 'che_첩보',
args: { destCityID: 999 },
},
{
name: 'move to a missing city',
action: 'che_이동',
args: { destCityID: 999 },
},
{
name: 'employ a missing general',
action: 'che_등용',
args: { destGeneralID: 999 },
},
];
integration('general command missing-target fallback matrix', () => {
it.each(missingTargetCases)(
'$name rejects the missing target and falls back without command RNG',
async ({ action, args }) => {
const request = buildRequest(action, args);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: false });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: '휴식',
usedFallback: true,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
type GeneralConstraintCase = {
name: string;
action: string;