feat: add NpcControlView component for managing NPC policies and priorities
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user