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 { lobbyRouter } from './router/lobby/index.js';
|
||||||
import { messagesRouter } from './router/messages/index.js';
|
import { messagesRouter } from './router/messages/index.js';
|
||||||
import { nationRouter } from './router/nation/index.js';
|
import { nationRouter } from './router/nation/index.js';
|
||||||
|
import { npcRouter } from './router/npc/index.js';
|
||||||
import { publicRouter } from './router/public/index.js';
|
import { publicRouter } from './router/public/index.js';
|
||||||
import { troopRouter } from './router/troop/index.js';
|
import { troopRouter } from './router/troop/index.js';
|
||||||
import { turnDaemonRouter } from './router/turnDaemon/index.js';
|
import { turnDaemonRouter } from './router/turnDaemon/index.js';
|
||||||
@@ -29,6 +30,7 @@ export const appRouter = router({
|
|||||||
troop: troopRouter,
|
troop: troopRouter,
|
||||||
general: generalRouter,
|
general: generalRouter,
|
||||||
nation: nationRouter,
|
nation: nationRouter,
|
||||||
|
npc: npcRouter,
|
||||||
turnDaemon: turnDaemonRouter,
|
turnDaemon: turnDaemonRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
|
|
||||||
import type { WorldStateRow } from '../../context.js';
|
import type { WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
|
import { MAX_NATION_TURNS, listNationTurns } from '../../turns/reservedTurns.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||||
|
|
||||||
type PermissionKind = 'normal' | 'ambassador' | 'auditor';
|
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
|
changePermission: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
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 NationCitiesView from '../views/NationCitiesView.vue';
|
||||||
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
||||||
import NationPersonnelView from '../views/NationPersonnelView.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 NotFoundView from '../views/NotFoundView.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
@@ -70,6 +72,24 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
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',
|
path: '/login',
|
||||||
name: '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/cities">세력 도시</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/personnel">인사부</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>
|
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
||||||
<button
|
<button
|
||||||
class="toggle"
|
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