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>