feat: add NpcControlView component for managing NPC policies and priorities

This commit is contained in:
2026-01-18 14:59:06 +00:00
parent e2accc31a7
commit bb618a9ade
11 changed files with 2858 additions and 0 deletions
+2
View File
@@ -9,6 +9,7 @@ import { inheritRouter } from './router/inherit/index.js';
import { lobbyRouter } from './router/lobby/index.js';
import { messagesRouter } from './router/messages/index.js';
import { nationRouter } from './router/nation/index.js';
import { npcRouter } from './router/npc/index.js';
import { publicRouter } from './router/public/index.js';
import { troopRouter } from './router/troop/index.js';
import { turnDaemonRouter } from './router/turnDaemon/index.js';
@@ -29,6 +30,7 @@ export const appRouter = router({
troop: troopRouter,
general: generalRouter,
nation: nationRouter,
npc: npcRouter,
turnDaemon: turnDaemonRouter,
});
+85
View File
@@ -23,7 +23,9 @@ import {
import type { WorldStateRow } from '../../context.js';
import { authedProcedure, router } from '../../trpc.js';
import { MAX_NATION_TURNS, listNationTurns } from '../../turns/reservedTurns.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
type PermissionKind = 'normal' | 'ambassador' | 'auditor';
@@ -869,6 +871,89 @@ export const nationRouter = router({
},
};
}),
getChiefCenter: authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, worldState, nationGenerals] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: {
id: true,
name: true,
level: true,
meta: true,
},
}),
ctx.db.worldState.findFirst(),
ctx.db.general.findMany({
where: { nationId: me.nationId, officerLevel: { gte: 5 } },
select: {
id: true,
name: true,
officerLevel: true,
npcState: true,
turnTime: true,
},
}),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: me.nationId,
officerLevel: me.officerLevel,
meta: me.meta,
penalty: me.penalty,
},
nation.meta
);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5];
const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general]));
const turnsByLevel = await Promise.all(
chiefLevels.map((level) => listNationTurns(ctx.db, nation.id, level))
);
const chiefs = chiefLevels.map((level, idx) => {
const entry = generalByLevel.get(level);
return {
officerLevel: level,
name: entry?.name ?? null,
npcState: entry?.npcState ?? null,
turnTime: entry?.turnTime ? entry.turnTime.toISOString() : null,
turns: turnsByLevel[idx],
};
});
return {
me: {
id: me.id,
officerLevel: me.officerLevel,
nationId: me.nationId,
},
nation: {
id: nation.id,
name: nation.name,
level: nation.level,
},
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
turnTermMinutes: Math.max(1, Math.round(worldState.tickSeconds / 60)),
maxTurns: MAX_NATION_TURNS,
chiefs,
};
}),
changePermission: authedProcedure
.input(
z.object({
+793
View File
@@ -0,0 +1,793 @@
import path from 'node:path';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { authedProcedure, router } from '../../trpc.js';
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
type NationPolicy = {
reqNationGold: number;
reqNationRice: number;
CombatForce: Record<number, [number, number]>;
SupportForce: number[];
DevelopForce: number[];
reqHumanWarUrgentGold: number;
reqHumanWarUrgentRice: number;
reqHumanWarRecommandGold: number;
reqHumanWarRecommandRice: number;
reqHumanDevelGold: number;
reqHumanDevelRice: number;
reqNPCWarGold: number;
reqNPCWarRice: number;
reqNPCDevelGold: number;
reqNPCDevelRice: number;
minimumResourceActionAmount: number;
maximumResourceActionAmount: number;
minNPCWarLeadership: number;
minWarCrew: number;
minNPCRecruitCityPopulation: number;
safeRecruitCityPopulationRatio: number;
properWarTrainAtmos: number;
cureThreshold: number;
};
type SetterInfo = {
setter: string | null;
date: string | null;
};
const DEFAULT_NATION_PRIORITY = [
'불가침제의',
'선전포고',
'천도',
'유저장긴급포상',
'부대전방발령',
'유저장구출발령',
'유저장후방발령',
'부대유저장후방발령',
'유저장전방발령',
'유저장포상',
'부대구출발령',
'부대후방발령',
'NPC긴급포상',
'NPC구출발령',
'NPC후방발령',
'NPC포상',
'NPC전방발령',
'유저장내정발령',
'NPC내정발령',
'NPC몰수',
] as const;
const DEFAULT_GENERAL_PRIORITY = [
'NPC사망대비',
'귀환',
'금쌀구매',
'출병',
'긴급내정',
'전투준비',
'전방워프',
'NPC헌납',
'징병',
'후방워프',
'전쟁내정',
'소집해제',
'일반내정',
'내정워프',
] as const;
const DEFAULT_NATION_POLICY: NationPolicy = {
reqNationGold: 10000,
reqNationRice: 12000,
CombatForce: {},
SupportForce: [],
DevelopForce: [],
reqHumanWarUrgentGold: 0,
reqHumanWarUrgentRice: 0,
reqHumanWarRecommandGold: 0,
reqHumanWarRecommandRice: 0,
reqHumanDevelGold: 10000,
reqHumanDevelRice: 10000,
reqNPCWarGold: 0,
reqNPCWarRice: 0,
reqNPCDevelGold: 0,
reqNPCDevelRice: 500,
minimumResourceActionAmount: 1000,
maximumResourceActionAmount: 10000,
minNPCWarLeadership: 40,
minWarCrew: 1500,
minNPCRecruitCityPopulation: 50000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
cureThreshold: 10,
};
const NATION_POLICY_KEYS = new Set<keyof NationPolicy>(Object.keys(DEFAULT_NATION_POLICY) as Array<keyof NationPolicy>);
const INTEGER_POLICY_KEYS = [
'reqNationGold',
'reqNationRice',
'reqHumanWarUrgentGold',
'reqHumanWarUrgentRice',
'reqHumanWarRecommandGold',
'reqHumanWarRecommandRice',
'reqHumanDevelGold',
'reqHumanDevelRice',
'reqNPCWarGold',
'reqNPCWarRice',
'reqNPCDevelGold',
'reqNPCDevelRice',
'minimumResourceActionAmount',
'maximumResourceActionAmount',
'minNPCWarLeadership',
'minWarCrew',
'minNPCRecruitCityPopulation',
'properWarTrainAtmos',
'cureThreshold',
] as const;
const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const;
type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number];
type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number];
const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset');
const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const roundTo = (value: number, digits = 0): number => {
if (!Number.isFinite(value)) {
return 0;
}
const factor = Math.pow(10, Math.abs(digits));
if (digits >= 0) {
return Math.round(value * factor) / factor;
}
return Math.round(value / factor) * factor;
};
const clonePolicy = (policy: NationPolicy): NationPolicy => ({
...policy,
CombatForce: { ...policy.CombatForce },
SupportForce: [...policy.SupportForce],
DevelopForce: [...policy.DevelopForce],
});
const resolveNumberFromKeys = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = readNumber(source[key], Number.NaN);
if (Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveUnitSetName = (config: Record<string, unknown>, fallback: string): string => {
const environment = asRecord(config.environment ?? config.map);
const unitSet = environment.unitSet;
if (typeof unitSet === 'string' && unitSet.trim().length > 0) {
return unitSet;
}
return fallback;
};
const resolveScenarioStat = (config: Record<string, unknown>): { max: number; npcMax: number } => {
const stat = asRecord(config.stat);
return {
max: readNumber(stat.max, 0),
npcMax: readNumber(stat.npcMax ?? stat.npc_max, 0),
};
};
const resolveCommandEnv = (config: Record<string, unknown>): { develCost: number; defaultCrewTypeId: number } => {
const constValues = asRecord(config.const ?? config.consts);
return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
};
};
const applyPolicyValues = (base: NationPolicy, values: Record<string, unknown>): NationPolicy => {
const next = clonePolicy(base);
for (const [key, rawValue] of Object.entries(values)) {
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
continue;
}
const numericKey = key as NumericPolicyKey;
if (INTEGER_POLICY_KEYS.includes(numericKey)) {
const value = readNumber(rawValue, next[numericKey]);
if (Number.isFinite(value)) {
const safe = Math.max(0, Math.floor(value));
switch (key) {
case 'reqNationGold':
next.reqNationGold = safe;
break;
case 'reqNationRice':
next.reqNationRice = safe;
break;
case 'reqHumanWarUrgentGold':
next.reqHumanWarUrgentGold = safe;
break;
case 'reqHumanWarUrgentRice':
next.reqHumanWarUrgentRice = safe;
break;
case 'reqHumanWarRecommandGold':
next.reqHumanWarRecommandGold = safe;
break;
case 'reqHumanWarRecommandRice':
next.reqHumanWarRecommandRice = safe;
break;
case 'reqHumanDevelGold':
next.reqHumanDevelGold = safe;
break;
case 'reqHumanDevelRice':
next.reqHumanDevelRice = safe;
break;
case 'reqNPCWarGold':
next.reqNPCWarGold = safe;
break;
case 'reqNPCWarRice':
next.reqNPCWarRice = safe;
break;
case 'reqNPCDevelGold':
next.reqNPCDevelGold = safe;
break;
case 'reqNPCDevelRice':
next.reqNPCDevelRice = safe;
break;
case 'minimumResourceActionAmount':
next.minimumResourceActionAmount = safe;
break;
case 'maximumResourceActionAmount':
next.maximumResourceActionAmount = safe;
break;
case 'minNPCWarLeadership':
next.minNPCWarLeadership = safe;
break;
case 'minWarCrew':
next.minWarCrew = safe;
break;
case 'minNPCRecruitCityPopulation':
next.minNPCRecruitCityPopulation = safe;
break;
case 'properWarTrainAtmos':
next.properWarTrainAtmos = safe;
break;
case 'cureThreshold':
next.cureThreshold = safe;
break;
default:
break;
}
}
continue;
}
const floatKey = key as FloatPolicyKey;
if (FLOAT_POLICY_KEYS.includes(floatKey)) {
const value = readNumber(rawValue, next.safeRecruitCityPopulationRatio);
next.safeRecruitCityPopulationRatio = Math.max(0, value);
continue;
}
if (key === 'CombatForce' && isRecord(rawValue)) {
const nextValue: Record<number, [number, number]> = {};
for (const [rawKey, rawEntry] of Object.entries(rawValue)) {
if (!Array.isArray(rawEntry) || rawEntry.length < 2) {
continue;
}
const leaderId = Number(rawKey);
const fromCity = Number(rawEntry[0]);
const toCity = Number(rawEntry[1]);
if (!Number.isFinite(leaderId) || !Number.isFinite(fromCity) || !Number.isFinite(toCity)) {
continue;
}
nextValue[leaderId] = [fromCity, toCity];
}
next.CombatForce = nextValue;
continue;
}
if (key === 'SupportForce' && Array.isArray(rawValue)) {
next.SupportForce = rawValue.filter((entry): entry is number => typeof entry === 'number');
}
if (key === 'DevelopForce' && Array.isArray(rawValue)) {
next.DevelopForce = rawValue.filter((entry): entry is number => typeof entry === 'number');
}
}
return next;
};
const buildZeroPolicy = async (
policy: NationPolicy,
options: {
statMax: number;
statNpcMax: number;
nationTech: number;
develCost: number;
defaultCrewTypeId: number;
unitSetName: string;
}
): Promise<NationPolicy> => {
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT });
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId);
const techCost = getTechCost(nationTech);
const next = clonePolicy(policy);
if (next.reqNPCDevelGold === 0) {
next.reqNPCDevelGold = develCost * 30;
}
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
const baseRice = statNpcMax;
if (next.reqNPCWarGold === 0) {
next.reqNPCWarGold = roundTo(baseGold * 4, -2);
}
if (next.reqNPCWarRice === 0) {
next.reqNPCWarRice = roundTo(baseRice * 4, -2);
}
}
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
const baseRice = statMax;
if (next.reqHumanWarUrgentGold === 0) {
next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
}
if (next.reqHumanWarUrgentRice === 0) {
next.reqHumanWarUrgentRice = roundTo(baseRice * 6, -2);
}
}
if (next.reqHumanWarRecommandGold === 0) {
next.reqHumanWarRecommandGold = roundTo(next.reqHumanWarUrgentGold * 2, -2);
}
if (next.reqHumanWarRecommandRice === 0) {
next.reqHumanWarRecommandRice = roundTo(next.reqHumanWarUrgentRice * 2, -2);
}
return next;
};
const normalizePriority = (raw: unknown, fallback: string[]): string[] => {
if (!Array.isArray(raw)) {
return [...fallback];
}
const filtered = raw.filter((item): item is string => typeof item === 'string');
return filtered.length > 0 ? filtered : [...fallback];
};
const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'priority'): SetterInfo => {
if (kind === 'value') {
return {
setter: typeof policy.valueSetter === 'string' ? policy.valueSetter : null,
date: typeof policy.valueSetTime === 'string' ? policy.valueSetTime : null,
};
}
return {
setter: typeof policy.prioritySetter === 'string' ? policy.prioritySetter : null,
date: typeof policy.prioritySetTime === 'string' ? policy.prioritySetTime : null,
};
};
const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority));
const validateGeneralPriority = (priority: string[]): string | null => {
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
const mustHave = new Set(['출병', '일반내정']);
const orderMap = new Map<string, number>();
for (const [idx, item] of priority.entries()) {
if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) {
return `${item}은 올바른 명령이 아닙니다.`;
}
orderMap.set(item, idx);
mustHave.delete(item);
}
for (const [pre, post] of orderRequired) {
const preIdx = orderMap.get(pre);
const postIdx = orderMap.get(post);
if (preIdx === undefined || postIdx === undefined) {
continue;
}
if (preIdx > postIdx) {
return `${pre} 명령은 ${post} 명령보다 먼저여야 합니다.`;
}
}
if (mustHave.size > 0) {
return `${Array.from(mustHave)[0]}은 항상 사용해야 합니다.`;
}
return null;
};
export const npcRouter = router({
getPolicy: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const [nation, worldState] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: general.nationId },
select: {
id: true,
name: true,
level: true,
meta: true,
},
}),
ctx.db.worldState.findFirst(),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const worldMeta = asRecord(worldState.meta);
const worldNationPolicy = asRecord(worldMeta.npc_nation_policy);
const worldGeneralPolicy = asRecord(worldMeta.npc_general_policy);
const nationMeta = asRecord(nation.meta);
const nationPolicyRoot = asRecord(nationMeta.npc_nation_policy);
const nationGeneralPolicyRoot = asRecord(nationMeta.npc_general_policy);
const defaultNationPolicy = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(worldNationPolicy.values));
const currentNationPolicy = applyPolicyValues(defaultNationPolicy, asRecord(nationPolicyRoot.values));
const defaultNationPriority = normalizePriority(worldNationPolicy.priority, [...DEFAULT_NATION_PRIORITY]);
const currentNationPriority = normalizePriority(nationPolicyRoot.priority, defaultNationPriority);
const defaultGeneralPriority = normalizePriority(worldGeneralPolicy.priority, [...DEFAULT_GENERAL_PRIORITY]);
const currentGeneralPriority = normalizePriority(nationGeneralPolicyRoot.priority, defaultGeneralPriority);
const config = asRecord(worldState.config);
const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config);
const unitSetName = resolveUnitSetName(config, 'che');
const nationTech = readNumber(asRecord(nationMeta).tech, 0);
const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, {
statMax: stat.max,
statNpcMax: stat.npcMax,
nationTech,
develCost: env.develCost,
defaultCrewTypeId: env.defaultCrewTypeId,
unitSetName,
});
return {
nationId: nation.id,
nationName: nation.name,
nationLevel: nation.level,
defaultNationPolicy,
currentNationPolicy,
zeroPolicy,
defaultNationPriority,
currentNationPriority,
availableNationPriorityItems: [...DEFAULT_NATION_PRIORITY],
defaultGeneralActionPriority: defaultGeneralPriority,
currentGeneralActionPriority: currentGeneralPriority,
availableGeneralActionPriorityItems: [...DEFAULT_GENERAL_PRIORITY],
lastSetters: {
policy: resolveSetterInfo(nationPolicyRoot, 'value'),
nation: resolveSetterInfo(nationPolicyRoot, 'priority'),
general: resolveSetterInfo(nationGeneralPolicyRoot, 'priority'),
},
defaultStatMax: stat.max,
defaultStatNpcMax: stat.npcMax,
permissionLevel,
};
}),
setNationPolicy: authedProcedure
.input(z.record(z.string(), z.unknown()))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const keys = Object.keys(input);
for (const key of keys) {
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
}
const troopRows = await ctx.db.troop.findMany({
where: { nationId: general.nationId },
select: { troopLeaderId: true },
});
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
const citySet = new Set(cityRows.map((row) => row.id));
const assigned = new Set<number>();
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
for (const key of INTEGER_POLICY_KEYS) {
if (!(key in input)) {
continue;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = Math.max(0, value);
}
for (const key of FLOAT_POLICY_KEYS) {
if (!(key in input)) {
continue;
}
const value = input[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
}
nextValues[key] = Math.max(0, value);
}
if ('CombatForce' in input) {
const rawCombat = input.CombatForce;
if (!isRecord(rawCombat)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
}
const combatForce: Record<number, [number, number]> = {};
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
const leaderId = Number(rawKey);
if (!Number.isFinite(leaderId)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
}
if (!troopSet.has(leaderId)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
}
if (assigned.has(leaderId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
});
}
if (!Array.isArray(rawValue) || rawValue.length < 2) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` });
}
const fromCity = Number(rawValue[0]);
const toCity = Number(rawValue[1]);
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
});
}
combatForce[leaderId] = [fromCity, toCity];
assigned.add(leaderId);
}
nextValues.CombatForce = combatForce;
}
for (const key of ['SupportForce', 'DevelopForce'] as const) {
if (!(key in input)) {
continue;
}
const rawList = input[key];
if (!Array.isArray(rawList)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
const list: number[] = [];
for (const rawValue of rawList) {
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
}
if (!troopSet.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${rawValue}는 국가의 부대가 아닙니다.`,
});
}
if (assigned.has(rawValue)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
});
}
assigned.add(rawValue);
list.push(rawValue);
}
if (key === 'SupportForce') {
nextValues.SupportForce = list;
} else {
nextValues.DevelopForce = list;
}
}
const nextPolicyRoot = {
...policyRoot,
values: nextValues,
valueSetter: general.name,
valueSetTime: new Date().toISOString(),
};
await ctx.db.nation.update({
where: { id: nation.id },
data: {
meta: {
...nationMeta,
npc_nation_policy: nextPolicyRoot,
},
},
});
return { ok: true };
}),
setNationPriority: authedProcedure
.input(z.array(z.string()))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const unique = ensureUniquePriority(input);
for (const item of unique) {
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
}
}
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_nation_policy);
const nextPolicyRoot = {
...policyRoot,
priority: unique,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
await ctx.db.nation.update({
where: { id: nation.id },
data: {
meta: {
...nationMeta,
npc_nation_policy: nextPolicyRoot,
},
},
});
return { ok: true };
}),
setGeneralPriority: authedProcedure
.input(z.array(z.string()))
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
}
const nation = await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { id: true, meta: true },
});
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const permissionLevel = resolveSecretPermission(
{
nationId: general.nationId,
officerLevel: general.officerLevel,
meta: general.meta,
penalty: general.penalty,
},
nation.meta
);
if (permissionLevel < 3) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const unique = ensureUniquePriority(input);
const validationError = validateGeneralPriority(unique);
if (validationError) {
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
}
const nationMeta = asRecord(nation.meta);
const policyRoot = asRecord(nationMeta.npc_general_policy);
const nextPolicyRoot = {
...policyRoot,
priority: unique,
prioritySetter: general.name,
prioritySetTime: new Date().toISOString(),
};
await ctx.db.nation.update({
where: { id: nation.id },
data: {
meta: {
...nationMeta,
npc_general_policy: nextPolicyRoot,
},
},
});
return { ok: true };
}),
});
@@ -0,0 +1,92 @@
import { asRecord } from '@sammo-ts/common';
export type PermissionKind = 'normal' | 'ambassador' | 'auditor';
export interface SecretPermissionInput {
nationId: number;
officerLevel: number;
meta: unknown;
penalty: unknown;
}
const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const resolvePermissionKind = (meta: Record<string, unknown>): PermissionKind => {
const value = meta.permission;
if (value === 'ambassador' || value === 'auditor') {
return value;
}
return 'normal';
};
const resolveBelong = (meta: Record<string, unknown>): number => readNumber(meta.belong, 0);
const resolveSecretLimit = (meta: Record<string, unknown>): number =>
readNumber(meta.secretlimit ?? meta.secretLimit, 3);
const checkSecretMaxPermission = (penalty: Record<string, unknown>): number => {
if (penalty.noTopSecret) {
return 1;
}
if (penalty.noChief) {
return 1;
}
if (penalty.noAmbassador) {
return 2;
}
return 4;
};
export const resolveSecretPermission = (
input: SecretPermissionInput,
nationMeta: unknown,
checkSecretLimit = true
): number => {
if (!input.nationId) {
return -1;
}
if (input.officerLevel === 0) {
return -1;
}
const penalty = asRecord(input.penalty);
if (penalty.noChief) {
return 0;
}
const meta = asRecord(input.meta);
const permission = resolvePermissionKind(meta);
const belong = resolveBelong(meta);
const secretMax = checkSecretMaxPermission(penalty);
let secretMin = 0;
if (input.officerLevel === 12) {
secretMin = 4;
} else if (permission === 'ambassador') {
secretMin = 4;
} else if (permission === 'auditor') {
secretMin = 3;
} else if (input.officerLevel >= 5) {
secretMin = 2;
} else if (input.officerLevel > 1) {
secretMin = 1;
} else if (checkSecretLimit) {
const limit = resolveSecretLimit(asRecord(nationMeta));
if (belong >= limit) {
secretMin = 1;
}
}
return Math.min(secretMin, secretMax);
};
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getNpcColor } from '../../utils/npcColor';
type TurnRow = {
index: number;
time: string;
action: string;
isRest: boolean;
};
const props = defineProps<{
officerLevelText: string;
name: string | null;
npcState: number | null;
rows: TurnRow[];
selected?: boolean;
compact?: boolean;
isMe?: boolean;
clickable?: boolean;
}>();
const emit = defineEmits<{
(event: 'select'): void;
}>();
const nameColor = computed(() => (props.npcState !== null ? getNpcColor(props.npcState) : undefined));
const handleClick = () => {
if (props.clickable) {
emit('select');
}
};
</script>
<template>
<article
class="chief-card"
:class="{ selected: props.selected, compact: props.compact, clickable: props.clickable }"
@click="handleClick"
>
<header class="chief-header">
<div class="chief-title">
<span class="chief-level">{{ props.officerLevelText }}</span>
<span class="chief-name" :style="{ color: nameColor }">
{{ props.name ?? '-' }}
</span>
</div>
<span v-if="props.isMe" class="chief-me">ME</span>
</header>
<div class="chief-rows">
<div v-for="row in props.rows" :key="row.index" class="chief-row" :class="{ rest: row.isRest }">
<span class="row-index">#{{ row.index + 1 }}</span>
<span class="row-time">{{ row.time }}</span>
<span class="row-action">{{ row.action }}</span>
</div>
</div>
</article>
</template>
<style scoped>
.chief-card {
border: 1px solid rgba(201, 164, 90, 0.3);
background: rgba(12, 12, 12, 0.7);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chief-card.clickable {
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.chief-card.clickable:hover {
border-color: rgba(201, 164, 90, 0.6);
box-shadow: 0 0 12px rgba(201, 164, 90, 0.15);
}
.chief-card.selected {
border-color: rgba(201, 164, 90, 0.8);
box-shadow: 0 0 16px rgba(201, 164, 90, 0.2);
}
.chief-card.compact {
padding: 8px;
font-size: 0.75rem;
}
.chief-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.chief-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.chief-level {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.chief-name {
font-size: 0.95rem;
font-weight: 600;
}
.chief-me {
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 999px;
background: rgba(201, 164, 90, 0.2);
color: rgba(232, 221, 196, 0.8);
}
.chief-rows {
display: grid;
gap: 4px;
}
.chief-row {
display: grid;
grid-template-columns: 36px 56px 1fr;
gap: 6px;
padding: 4px 6px;
border: 1px solid rgba(201, 164, 90, 0.15);
font-size: 0.75rem;
}
.chief-card.compact .chief-row {
grid-template-columns: 28px 48px 1fr;
font-size: 0.7rem;
}
.chief-row.rest {
color: rgba(232, 221, 196, 0.5);
}
.row-index {
color: rgba(232, 221, 196, 0.6);
}
.row-time {
font-variant-numeric: tabular-nums;
}
.row-action {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+20
View File
@@ -7,6 +7,8 @@ import InheritView from '../views/InheritView.vue';
import NationCitiesView from '../views/NationCitiesView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import ChiefCenterView from '../views/ChiefCenterView.vue';
import NpcControlView from '../views/NpcControlView.vue';
import NotFoundView from '../views/NotFoundView.vue';
import { useSessionStore } from '../stores/session';
@@ -70,6 +72,24 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/chief-center',
name: 'chief-center',
component: ChiefCenterView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/npc-control',
name: 'npc-control',
component: NpcControlView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/login',
name: 'login',
+18
View File
@@ -0,0 +1,18 @@
export const getNpcColor = (npcState: number): string | undefined => {
if (npcState === 6) {
return 'mediumaquamarine';
}
if (npcState === 5) {
return 'darkcyan';
}
if (npcState === 4) {
return 'deepskyblue';
}
if (npcState >= 2) {
return 'cyan';
}
if (npcState === 1) {
return 'skyblue';
}
return undefined;
};
@@ -0,0 +1,67 @@
const rawHelp: Record<string, string> = {
:
'군주가 NPC이고, 타국에서 원조를 받았을 때,<br>세입(금/쌀) 대비 원조량에 따라 불가침제의를 합니다.',
:
'군주가 NPC이고, 전쟁중이 아닐 때,<br>주변국중 하나를 골라 선포합니다.<br><br>선포 시점은 다음을 참고합니다.<br>- 인구율<br>- 도시내정률<br>- NPC전투장권장 금 충족률<br>- NPC전투장권장 쌀 충족률<br><br>국력이 낮은 국가를 조금 더 선호합니다.',
: '인구가 많은 곳을 찾아 천도를 시도합니다.<br>영토의 가운데를 선호합니다.<br><br>도시 인구가 충분하다면, 굳이 천도하지는 않습니다.',
:
'금/쌀이 부족한 유저전투장에게 긴급하게 포상합니다.<br>국고가 권장량보다 적어지더라도 시도합니다.',
:
'(작동하지 않음)<br>전투 부대를 접경으로 발령합니다.<br>수도->시작점->도착점 경로를 따릅니다.',
:
'아군 영토에 있지 않은 유저장을 아군 영토로 발령합니다.<br>곧 집합하는 부대에 탑승한 경우는 제외합니다.',
:
'유저전투장 중에<br>- 병력이 충분하지 않고,<br>- 도시의 인구가 제자리 징병할 수 있을 정도로 충분하지 않고,<br>- 부대에 탑승하지 않았다면,<br>인구가 충분한 후방도시로 발령합니다.',
:
'접경에 위치한 부대에 탑승한 유저전투장 중에,<br>- 병력이 충분하지 않고,<br>- 첫 턴이 징병턴이며,<br>- 부대장 집합 턴 사이라면,<br>인구가 충분한 후방도시로 발령합니다.<br><br>부대장의 위치와 유저장의 위치가 다르다면 발령하지 않습니다.',
:
'후방에 있는 유저장이<br>- 병력을 가지고 있으며,<br>- 곧 훈련/사기진작이 완료될 것 같으면,<br>전방으로 발령합니다.<br><br>도시 관직이 많이 임명된 도시를 선호합니다.',
:
'금/쌀이 부족한 유저장에게 포상합니다.<br>유저전투장과 유저내정장은 각각 기준을 따릅니다.<br>국고 권장량을 가급적 지킵니다.',
:
'(작동하지 않음)<br>후방 부대가 위치한 도시의 인구가 충분하지 않을 경우,<br>인구가 충분한 도시로 발령합니다.',
:
'전투 부대, 후방 부대가 아닌 부대가 아군 영토에 있지 않을 때,<br>전방 도시 중 하나를 골라 발령합니다.',
NPC긴급포상:
'금/쌀이 부족한 NPC전투장에게 긴급하게 포상합니다.<br>국고가 권장량보다 "약간" 적어지더라도 시도합니다.',
NPC구출발령: '아군 영토에 있지 않은 NPC장을 아군 영토로 발령합니다.',
NPC후방발령:
'NPC전투장 중에<br>- 병력이 충분하지 않고,<br>- 도시의 인구가 제자리 징병할 수 있을 정도로 충분하지 않고,<br>- 부대에 탑승하지 않았다면,<br>인구가 충분한 후방도시로 발령합니다.',
NPC포상:
'금/쌀이 부족한 NPC에게 포상합니다.<br>NPC전투장과 NPC내정장은 각각 기준을 따릅니다.<br>국고 권장량을 가급적 지킵니다.',
NPC전방발령:
'후방에 있는 유저장이<br>- 병력을 가지고 있으며,<br>- 곧 훈련/사기진작이 완료될 것 같으면,<br>전방으로 발령합니다.<br><br>도시 관직이 많이 임명된 도시를 선호합니다.',
:
'내정중인 유저장이 위치한 도시의 내정률이 95% 이상이면<br>개발되지 않은 도시로 발령합니다.',
NPC내정발령:
'내정중인 NPC장이 위치한 도시의 내정률이 95% 이상이면<br>개발되지 않은 도시로 발령합니다.',
NPC몰수: '국고가 부족하다면 NPC에게서 몰수합니다. 내정NPC장은 국고가 부족하지 않아도 몰수합니다.',
NPC사망대비: 'NPC의 사망까지 5턴 이내인 경우, 헌납합니다.<br>헌납할 금쌀이 없다면 물자조달을 수행합니다.',
: '아국 도시에 있지 않다면 귀환합니다.',
:
'전쟁 중에 금쌀의 비율이 크게 차이난다면 금쌀을 거래하여 비슷하게 맞춥니다.<br>금쌀 비율이 적절하는지 판단하는데 살상률을 포함합니다.<br>NPC는 상인이 없어도 금쌀을 구매할 수 있습니다.<br><br>또는 금쌀 한쪽이 지나치게 적은 경우에는 내정 중에도 금쌀을 거래합니다.',
:
'충분한 병력과 충분한 훈련/사기를 가지고 있는 경우 출병합니다.<br>접경이 여럿인 경우 무작위로 선택합니다.<br><br>타국과 전쟁중인 경우 공백지로는 출병하지 않습니다.',
:
'전쟁중에 민심이 70 미만이거나,<br>인구가 제자리 징병이 가능하지 않을 정도로 적을 경우,<br>일정확률로 주민선정과 정착장려를 수행합니다.<br><br>통솔이 높을 수록 수행할 확률이 높습니다.',
: '충분한 병력을 가지고 있지만 훈련과 사기가 부족한 경우 훈련과 사기진작을 수행합니다.',
: '전투장이 충분한 병력을 가지고 있다면 전방으로 이동합니다.',
NPC헌납:
'국고가 부족한데 NPC전쟁장이 충분한 금쌀(권장량 대비 1.5배)을 가지고 있다면 일부를 헌납합니다. <br>NPC내정장은 국고가 넉넉하더라도 충분한 금쌀을 가지고 있다면 권장량만 유지하고 헌납합니다.',
:
'전쟁 중 병력을 소진하였다면 재 징병합니다.<br><br>기존에 사용한 병종군 중에서 사용가능한 병종을 랜덤하게 선택합니다.<br>고급 병종을 선택할 확률이 조금 더 높습니다.<br><br>NPC의 경우 도시의 인구가 충분하지 않다면 징병을 할 확률이 감소합니다.<br><br>유저장은 최대한 고급병종을 유지하며,<br>유저장 모병이 허용되는 경우 모병을 3회할 수 있다면 모병합니다.',
: '전쟁 중 병력을 소진하였는데 도시의 인구가 충분하지 않다면,<br>인구가 많은 도시로 이동합니다.',
:
'전쟁 중 수행하는 내정입니다.<br>정착장려, 기술연구의 확률이 좀 더 높고,<br>치안강화, 농지개간, 상업투자의 확률이 낮습니다.<br><br>내정이 가능하다 하더라도 전시임을 고려해,<br>30% 확률로 다른 턴을 수행합니다.',
: '전쟁 중이 아닌 데 병력이 남아있는 경우,<br>3/4 확률로 소집해제합니다.',
:
'도시에서 내정을 수행합니다. 낮은 내정일 수록 수행할 확률이 높습니다.<br>기술 연구는 1등급 이상 뒤쳐지지 않도록 노력합니다.',
:
'도시에서 더이상 내정을 수행할 수 없는 경우,<br>일정확률로 내정이 부족한 다른 도시로 이동합니다.',
};
const normalizeLineBreaks = (text: string): string => text.replace(/<br\s*\/?>/g, '\n');
export const npcPriorityHelp: Record<string, string> = Object.fromEntries(
Object.entries(rawHelp).map(([key, value]) => [key, normalizeLineBreaks(value)])
);
@@ -0,0 +1,686 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { addMinutes, format } from 'date-fns';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import CommandSelectForm from '../components/main/CommandSelectForm.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
type ChiefTurn = {
index: number;
action: string;
args: unknown;
};
type ChiefEntry = {
officerLevel: number;
name: string | null;
npcState: number | null;
turnTime: string | null;
turns: ChiefTurn[];
};
type ChiefCenterResponse = {
me: {
id: number;
officerLevel: number;
nationId: number;
};
nation: {
id: number;
name: string;
level: number;
};
currentYear: number;
currentMonth: number;
turnTermMinutes: number;
maxTurns: number;
chiefs: ChiefEntry[];
};
type CommandAvailability = {
key: string;
name: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
reason?: string;
};
type CommandGroup = {
category: string;
values: CommandAvailability[];
};
type CommandTable = {
general: CommandGroup[];
nation: CommandGroup[];
};
const chiefApi = trpc as unknown as {
nation: {
getChiefCenter: {
query: () => Promise<ChiefCenterResponse>;
};
};
turns: {
getCommandTable: {
query: (input: { generalId: number }) => Promise<CommandTable>;
};
};
};
type TurnRow = {
index: number;
time: string;
action: string;
isRest: boolean;
};
const loading = ref(false);
const commandLoading = ref(false);
const error = ref<string | null>(null);
const data = ref<ChiefCenterResponse | null>(null);
const commandTable = ref<CommandTable | null>(null);
const selectedChiefLevel = ref<number | null>(null);
const activeCategory = ref('');
const selectedCommandKey = ref<string | null>(null);
const isMobile = useMediaQuery('(max-width: 1024px)');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadChiefCenter = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
data.value = await chiefApi.nation.getChiefCenter.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const loadCommandTable = async (generalId: number) => {
if (commandLoading.value) {
return;
}
commandLoading.value = true;
try {
commandTable.value = await chiefApi.turns.getCommandTable.query({ generalId });
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
commandLoading.value = false;
}
};
onMounted(() => {
void loadChiefCenter();
});
watch(
() => data.value?.me.id,
(generalId) => {
if (generalId && !commandTable.value) {
void loadCommandTable(generalId);
}
}
);
watch(
() => data.value,
(snapshot) => {
if (!snapshot) {
return;
}
if (selectedChiefLevel.value !== null) {
return;
}
const preferred =
snapshot.me.officerLevel >= 5
? snapshot.me.officerLevel
: snapshot.chiefs[0]?.officerLevel ?? null;
selectedChiefLevel.value = preferred;
}
);
const statusLine = computed(() => {
if (!data.value) {
return '사령부 정보를 불러오는 중';
}
return `${data.value.currentYear}${data.value.currentMonth}월 · 턴 ${data.value.turnTermMinutes}`;
});
const commandLabelMap = computed(() => {
const map = new Map<string, string>();
if (!commandTable.value) {
return map;
}
for (const group of commandTable.value.general) {
for (const entry of group.values) {
map.set(entry.key, entry.name);
}
}
for (const group of commandTable.value.nation) {
for (const entry of group.values) {
map.set(entry.key, entry.name);
}
}
return map;
});
const chiefCommandTable = computed(() => {
if (!commandTable.value) {
return null;
}
return {
general: [],
nation: commandTable.value.nation,
};
});
const selectedCommand = computed<CommandAvailability | null>(() => {
if (!commandTable.value || !selectedCommandKey.value) {
return null;
}
for (const group of commandTable.value.nation) {
const match = group.values.find((entry) => entry.key === selectedCommandKey.value);
if (match) {
return match;
}
}
return null;
});
const canReserveSelected = computed(() => {
if (!selectedCommand.value) {
return false;
}
if (!selectedCommand.value.possible) {
return false;
}
if (selectedCommand.value.status !== 'available') {
return false;
}
return !selectedCommand.value.reqArg;
});
const selectedChief = computed<ChiefEntry | null>(() => {
const snapshot = data.value;
if (!snapshot) {
return null;
}
const targetLevel = selectedChiefLevel.value ?? snapshot.me.officerLevel;
let match: ChiefEntry | null = null;
for (const chief of snapshot.chiefs) {
if (chief.officerLevel === targetLevel) {
match = chief;
break;
}
}
return match;
});
const isEditingAllowed = computed(() => {
if (!data.value || !selectedChief.value) {
return false;
}
return data.value.me.officerLevel >= 5 && data.value.me.officerLevel === selectedChief.value.officerLevel;
});
const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
const turnTermMinutes = data.value?.turnTermMinutes ?? 0;
const labelMap = commandLabelMap.value;
const baseTime = chief.turnTime ? new Date(chief.turnTime) : null;
return chief.turns.map((turn, idx) => {
const timeLabel =
baseTime && Number.isFinite(turnTermMinutes)
? format(addMinutes(baseTime, idx * turnTermMinutes), turnTermMinutes >= 5 ? 'HH:mm' : 'mm:ss')
: '--:--';
const actionLabel = labelMap.get(turn.action) ?? turn.action;
return {
index: turn.index,
time: timeLabel,
action: actionLabel,
isRest: turn.action === '휴식',
};
});
};
const chiefViews = computed(() => {
if (!data.value) {
return [] as Array<ChiefEntry & { rows: TurnRow[]; officerLevelText: string }>;
}
return data.value.chiefs.map((chief) => ({
...chief,
officerLevelText: formatOfficerLevelText(chief.officerLevel, data.value?.nation.level),
rows: buildTurnRows(chief),
}));
});
const selectedChiefRows = computed(() => {
if (!selectedChief.value) {
return [] as TurnRow[];
}
return buildTurnRows(selectedChief.value);
});
const updateMyTurns = (turns: ChiefEntry['turns']) => {
if (!data.value) {
return;
}
const myLevel = data.value.me.officerLevel;
const entry = data.value.chiefs.find((chief) => chief.officerLevel === myLevel);
if (!entry) {
return;
}
entry.turns = turns;
};
const reserveTurn = async (turnIndex: number) => {
if (!data.value || !selectedCommand.value || !isEditingAllowed.value) {
return;
}
if (!canReserveSelected.value) {
return;
}
try {
const result = await trpc.turns.reserved.setNation.mutate({
generalId: data.value.me.id,
turnIndex,
action: selectedCommand.value.key,
args: {},
});
updateMyTurns(result.turns);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const clearTurn = async (turnIndex: number) => {
if (!data.value || !isEditingAllowed.value) {
return;
}
try {
const result = await trpc.turns.reserved.setNation.mutate({
generalId: data.value.me.id,
turnIndex,
action: '휴식',
args: {},
});
updateMyTurns(result.turns);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const shiftTurns = async (amount: number) => {
if (!data.value || !isEditingAllowed.value) {
return;
}
try {
const result = await trpc.turns.reserved.shiftNation.mutate({
generalId: data.value.me.id,
amount,
});
updateMyTurns(result.turns);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
</script>
<template>
<main class="chief-page">
<header class="page-header">
<div>
<h1 class="page-title">사령부</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<button class="ghost" @click="loadChiefCenter">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<section v-if="loading && !data" class="loading-panel">
<PanelCard title="사령부 로딩">
<SkeletonLines :lines="5" />
</PanelCard>
</section>
<section v-else-if="data && isMobile" class="layout-mobile">
<PanelCard title="선택 사령부" subtitle="터치하여 사령턴 확인">
<ChiefTurnCard
v-if="selectedChief"
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
:name="selectedChief.name"
:npc-state="selectedChief.npcState"
:rows="selectedChiefRows"
:is-me="selectedChief.officerLevel === data.me.officerLevel"
/>
<div v-else class="muted">선택된 사령이 없습니다.</div>
</PanelCard>
<PanelCard v-if="isEditingAllowed" title="사령부 편집" subtitle="선택 명령을 배치하세요">
<CommandSelectForm
:command-table="chiefCommandTable"
:loading="commandLoading || loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="selectedCommandKey = $event"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<div class="turn-actions">
<button @click="shiftTurns(-1)">앞당김</button>
<button @click="shiftTurns(1)">미루기</button>
</div>
<div class="turn-list">
<div v-for="row in selectedChiefRows" :key="row.index" class="turn-item">
<div class="turn-info">
<span class="turn-index">#{{ row.index + 1 }}</span>
<span class="turn-time">{{ row.time }}</span>
<span class="turn-action">{{ row.action }}</span>
</div>
<div class="turn-buttons">
<button :disabled="!canReserveSelected" @click="reserveTurn(row.index)">배치</button>
<button class="ghost" @click="clearTurn(row.index)">휴식</button>
</div>
</div>
</div>
</PanelCard>
<PanelCard title="전체 사령부" subtitle="8자리 전체 보기">
<div class="chief-overview">
<ChiefTurnCard
v-for="chief in chiefViews"
:key="chief.officerLevel"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:compact="true"
:selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel"
:clickable="true"
@select="selectedChiefLevel = chief.officerLevel"
/>
</div>
</PanelCard>
</section>
<section v-else-if="data" class="layout-desktop">
<div class="chief-grid">
<ChiefTurnCard
v-for="chief in chiefViews"
:key="chief.officerLevel"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel"
:clickable="true"
@select="selectedChiefLevel = chief.officerLevel"
/>
</div>
<div class="chief-side">
<PanelCard title="사령부 편집" subtitle="선택 명령을 배치하세요">
<div v-if="!isEditingAllowed" class="muted">
사령부 편집은 본인 관직에서만 가능합니다.
</div>
<div v-else>
<CommandSelectForm
:command-table="chiefCommandTable"
:loading="commandLoading || loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="selectedCommandKey = $event"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<div class="turn-actions">
<button @click="shiftTurns(-1)">앞당김</button>
<button @click="shiftTurns(1)">미루기</button>
</div>
<div class="turn-list">
<div v-for="row in selectedChiefRows" :key="row.index" class="turn-item">
<div class="turn-info">
<span class="turn-index">#{{ row.index + 1 }}</span>
<span class="turn-time">{{ row.time }}</span>
<span class="turn-action">{{ row.action }}</span>
</div>
<div class="turn-buttons">
<button :disabled="!canReserveSelected" @click="reserveTurn(row.index)">
배치
</button>
<button class="ghost" @click="clearTurn(row.index)">휴식</button>
</div>
</div>
</div>
</div>
</PanelCard>
</div>
</section>
</main>
</template>
<style scoped>
.chief-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-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;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.layout-desktop {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
gap: 16px;
}
.chief-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 12px;
}
.chief-side {
display: flex;
flex-direction: column;
gap: 12px;
}
.layout-mobile {
display: flex;
flex-direction: column;
gap: 16px;
}
.chief-overview {
display: grid;
gap: 12px;
}
.command-selected {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
display: grid;
gap: 6px;
font-size: 0.75rem;
margin-top: 12px;
}
.command-selected .label {
color: rgba(232, 221, 196, 0.6);
}
.command-selected .meta {
display: flex;
gap: 8px;
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.turn-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.turn-actions button {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 4px 8px;
font-size: 0.75rem;
background: rgba(12, 12, 12, 0.6);
color: inherit;
cursor: pointer;
}
.turn-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.turn-item {
border: 1px solid rgba(201, 164, 90, 0.25);
padding: 8px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
font-size: 0.75rem;
}
.turn-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.turn-index {
color: rgba(232, 221, 196, 0.6);
}
.turn-time {
font-variant-numeric: tabular-nums;
}
.turn-action {
font-weight: 600;
}
.turn-buttons {
display: flex;
gap: 6px;
}
.turn-buttons button {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 4px 8px;
font-size: 0.7rem;
background: rgba(12, 12, 12, 0.6);
color: inherit;
cursor: pointer;
}
.turn-buttons .ghost {
background: rgba(16, 16, 16, 0.6);
}
.muted {
color: rgba(232, 221, 196, 0.6);
}
@media (max-width: 1024px) {
.chief-page {
padding: 16px;
}
.chief-overview {
grid-template-columns: 1fr;
}
}
</style>
+2
View File
@@ -94,6 +94,8 @@ watch(
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
<button
class="toggle"
@@ -0,0 +1,935 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type PolicyKey = keyof NationPolicy;
type PolicyField = {
key: PolicyKey;
label: string;
step: number;
description: string;
hint?: string;
percent?: boolean;
};
type PolicySection = {
title: string;
fields: PolicyField[];
};
const NUMERIC_POLICY_KEYS = [
'reqNationGold',
'reqNationRice',
'reqHumanWarUrgentGold',
'reqHumanWarUrgentRice',
'reqHumanWarRecommandGold',
'reqHumanWarRecommandRice',
'reqHumanDevelGold',
'reqHumanDevelRice',
'reqNPCWarGold',
'reqNPCWarRice',
'reqNPCDevelGold',
'reqNPCDevelRice',
'minimumResourceActionAmount',
'maximumResourceActionAmount',
'minNPCWarLeadership',
'minWarCrew',
'minNPCRecruitCityPopulation',
'safeRecruitCityPopulationRatio',
'properWarTrainAtmos',
'cureThreshold',
] as const;
type NumericPolicyKey = (typeof NUMERIC_POLICY_KEYS)[number];
type PrioritySectionKey = 'nation' | 'general';
type PriorityListState = {
active: string[];
inactive: string[];
available: string[];
};
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<NpcPolicyResponse | null>(null);
const policyDraft = ref<NationPolicy | null>(null);
const lastSavedPolicy = ref<NationPolicy | null>(null);
const nationPriority = ref<PriorityListState | null>(null);
const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const clonePolicy = (source: NationPolicy): NationPolicy => ({
...source,
CombatForce: { ...source.CombatForce },
SupportForce: [...source.SupportForce],
DevelopForce: [...source.DevelopForce],
});
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
const activeSet = new Set(active);
const inactive = available.filter((item) => !activeSet.has(item));
return {
active: [...active],
inactive,
available: [...available],
};
};
const loadPolicy = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
data.value = await trpc.npc.getPolicy.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
onMounted(() => {
void loadPolicy();
});
watch(
() => data.value,
(value) => {
if (!value) {
return;
}
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
}
);
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
const calcPolicyValue = (key: NumericPolicyKey): number => {
if (!data.value || !policyDraft.value) {
return 0;
}
const value = policyDraft.value[key];
if (value === 0) {
return data.value.zeroPolicy[key];
}
return value;
};
const safeRecruitPercent = computed({
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
set: (value: number) => {
if (!policyDraft.value) {
return;
}
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
},
});
const policySections = computed<PolicySection[]>(() => {
if (!data.value) {
return [];
}
const statMax = data.value.defaultStatMax;
const statNpcMax = data.value.defaultStatNpcMax;
return [
{
title: '국가 재정',
fields: [
{
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
],
},
{
title: '유저 전투장',
fields: [
{
key: 'reqHumanWarUrgentGold',
label: '긴급포상 금',
step: 100,
description:
'유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100 * 6)}) 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentGold
)}입니다.`,
},
{
key: 'reqHumanWarUrgentRice',
label: '긴급포상 쌀',
step: 100,
description:
'유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100 * 6)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentRice
)}입니다.`,
},
{
key: 'reqHumanWarRecommandGold',
label: '권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 금의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentGold') * 2
)}입니다.`,
},
{
key: 'reqHumanWarRecommandRice',
label: '권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 쌀의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentRice') * 2
)}입니다.`,
},
],
},
{
title: '유저 내정장',
fields: [
{
key: 'reqHumanDevelGold',
label: '권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqHumanDevelRice',
label: '권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
],
},
{
title: 'NPC 전투장',
fields: [
{
key: 'reqNPCWarGold',
label: '권장 금',
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100 * 4)}) 징병비를 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarGold
)}입니다.`,
},
{
key: 'reqNPCWarRice',
label: '권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100 * 4)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarRice
)}입니다.`,
},
],
},
{
title: 'NPC 내정장',
fields: [
{
key: 'reqNPCDevelGold',
label: '권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCDevelGold
)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: '권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
],
},
{
title: '자원 정책',
fields: [
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납 최소 단위',
step: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납 최대 단위',
step: 100,
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
},
],
},
{
title: '전투/징병 기준',
fields: [
{
key: 'minWarCrew',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description:
'도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
description:
'전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
percent: true,
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
],
},
{
title: '상태 기준',
fields: [
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준(%)',
step: 5,
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.',
},
],
},
];
});
const canEdit = computed(() => (data.value?.permissionLevel ?? 0) >= 3);
const resetPolicy = () => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
};
const rollbackPolicy = () => {
if (!lastSavedPolicy.value) {
return;
}
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
policyDraft.value = clonePolicy(lastSavedPolicy.value);
};
const submitPolicy = async () => {
if (!policyDraft.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const moveItem = (list: string[], from: number, to: number) => {
if (to < 0 || to >= list.length) {
return;
}
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
};
const insertByOrder = (list: string[], item: string, orderMap: Map<string, number>) => {
const targetOrder = orderMap.get(item) ?? Number.MAX_SAFE_INTEGER;
const index = list.findIndex((entry) => (orderMap.get(entry) ?? Number.MAX_SAFE_INTEGER) > targetOrder);
if (index === -1) {
list.push(item);
} else {
list.splice(index, 0, item);
}
};
const togglePriority = (section: PrioritySectionKey, item: string, enable: boolean) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
if (enable) {
const index = target.inactive.indexOf(item);
if (index >= 0) {
target.inactive.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.active, item, orderMap);
}
return;
}
const index = target.active.indexOf(item);
if (index >= 0) {
target.active.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.inactive, item, orderMap);
}
};
const reorderPriority = (section: PrioritySectionKey, index: number, direction: number) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
moveItem(target.active, index, index + direction);
};
const resetPriority = (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (section === 'nation') {
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems);
} else {
generalPriority.value = assignPriorityState(
data.value.defaultGeneralActionPriority,
data.value.availableGeneralActionPriorityItems
);
}
};
const rollbackPriority = (section: PrioritySectionKey) => {
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (section === 'nation' && data.value) {
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems);
}
if (section === 'general' && data.value) {
generalPriority.value = assignPriorityState(
lastSavedGeneralPriority.value,
data.value.availableGeneralActionPriorityItems
);
}
};
const submitPriority = async (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
try {
if (section === 'nation' && nationPriority.value) {
await trpc.npc.setNationPriority.mutate(nationPriority.value.active);
}
if (section === 'general' && generalPriority.value) {
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active);
}
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
</script>
<template>
<main class="npc-page">
<header class="page-header">
<div>
<h1 class="page-title">NPC 정책</h1>
<p class="page-subtitle">사령/일반 AI 우선순위 자원 기준</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<button class="ghost" @click="loadPolicy">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<section v-if="loading && !data">
<PanelCard title="NPC 정책 로딩">
<SkeletonLines :lines="6" />
</PanelCard>
</section>
<section v-else-if="data && policyDraft" class="npc-layout">
<PanelCard title="국가 정책" subtitle="NPC 자원 기준과 전투 판단 기준">
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div v-for="section in policySections" :key="section.title" class="policy-section">
<h3 class="section-title">{{ section.title }}</h3>
<div class="policy-grid">
<div
v-for="field in section.fields"
:key="field.key"
class="policy-field"
>
<label class="field-label">{{ field.label }}</label>
<input
v-if="field.percent"
v-model.number="safeRecruitPercent"
type="number"
class="field-input"
:step="field.step"
min="0"
max="100"
:disabled="!canEdit"
/>
<input
v-else
v-model.number="policyDraft[field.key as PolicyKey]"
type="number"
class="field-input"
:step="field.step"
min="0"
:disabled="!canEdit"
/>
<p class="field-desc">{{ field.description }}</p>
<p v-if="field.hint" class="field-hint">{{ field.hint }}</p>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPolicy">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPolicy">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPolicy">설정</button>
</div>
</PanelCard>
<div class="priority-grid">
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행">
<div class="setter">
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{
data.lastSetters.nation.date ?? '설정 기록 없음'
}})
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div
v-for="item in nationPriority?.inactive ?? []"
:key="item"
class="priority-item"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)">
활성
</button>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div
v-for="(item, idx) in nationPriority?.active ?? []"
:key="item"
class="priority-item"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button>
</div>
</PanelCard>
<PanelCard title="NPC 일반턴 우선순위" subtitle="순위가 높은 것부터 시도">
<div class="setter">
최근 설정: {{ data.lastSetters.general.setter ?? '-없음-' }} ({{
data.lastSetters.general.date ?? '설정 기록 없음'
}})
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div
v-for="item in generalPriority?.inactive ?? []"
:key="item"
class="priority-item"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, true)">
활성
</button>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div
v-for="(item, idx) in generalPriority?.active ?? []"
:key="item"
class="priority-item"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('general')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('general')">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('general')">설정</button>
</div>
</PanelCard>
</div>
</section>
</main>
</template>
<style scoped>
.npc-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-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;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(201, 164, 90, 0.2);
color: inherit;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.npc-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.setter {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
margin-bottom: 8px;
}
.readonly-note {
font-size: 0.75rem;
color: rgba(245, 208, 138, 0.8);
margin-bottom: 8px;
}
.policy-section {
margin-top: 12px;
}
.section-title {
font-size: 0.95rem;
margin-bottom: 8px;
color: rgba(232, 221, 196, 0.9);
}
.policy-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.policy-field {
border: 1px solid rgba(201, 164, 90, 0.2);
background: rgba(16, 16, 16, 0.5);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 0.8rem;
font-weight: 600;
}
.field-input {
background: rgba(12, 12, 12, 0.8);
border: 1px solid rgba(201, 164, 90, 0.3);
color: inherit;
padding: 6px 8px;
font-size: 0.8rem;
font-family: inherit;
}
.field-desc {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.field-hint {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.45);
white-space: pre-line;
}
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.btn-group {
display: flex;
gap: 8px;
}
.priority-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.priority-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.priority-column {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
background: rgba(12, 12, 12, 0.5);
}
.column-title {
font-size: 0.8rem;
margin-bottom: 6px;
color: rgba(232, 221, 196, 0.7);
}
.priority-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.priority-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(16, 16, 16, 0.6);
}
.priority-main {
display: flex;
align-items: center;
gap: 6px;
}
.priority-name {
font-size: 0.78rem;
}
.priority-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.priority-help {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.7rem;
border-radius: 999px;
border: 1px solid rgba(201, 164, 90, 0.4);
cursor: default;
}
.priority-help::after {
content: attr(data-text);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: rgba(16, 16, 16, 0.9);
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 8px;
font-size: 0.7rem;
width: 220px;
white-space: pre-line;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 10;
}
.priority-help:hover::after {
opacity: 1;
}
@media (max-width: 1024px) {
.npc-page {
padding: 16px;
}
}
</style>