From bb618a9aded45c511ceed99d0a79bf6869057374 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 18 Jan 2026 14:59:06 +0000 Subject: [PATCH] feat: add NpcControlView component for managing NPC policies and priorities --- app/game-api/src/router.ts | 2 + app/game-api/src/router/nation/index.ts | 85 ++ app/game-api/src/router/npc/index.ts | 793 +++++++++++++++ .../src/router/shared/secretPermission.ts | 92 ++ .../src/components/chief/ChiefTurnCard.vue | 158 +++ app/game-frontend/src/router/index.ts | 20 + app/game-frontend/src/utils/npcColor.ts | 18 + .../src/utils/npcPriorityHelp.ts | 67 ++ .../src/views/ChiefCenterView.vue | 686 +++++++++++++ app/game-frontend/src/views/MainView.vue | 2 + .../src/views/NpcControlView.vue | 935 ++++++++++++++++++ 11 files changed, 2858 insertions(+) create mode 100644 app/game-api/src/router/npc/index.ts create mode 100644 app/game-api/src/router/shared/secretPermission.ts create mode 100644 app/game-frontend/src/components/chief/ChiefTurnCard.vue create mode 100644 app/game-frontend/src/utils/npcColor.ts create mode 100644 app/game-frontend/src/utils/npcPriorityHelp.ts create mode 100644 app/game-frontend/src/views/ChiefCenterView.vue create mode 100644 app/game-frontend/src/views/NpcControlView.vue diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index b6935a6..13a85ba 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -9,6 +9,7 @@ import { inheritRouter } from './router/inherit/index.js'; import { lobbyRouter } from './router/lobby/index.js'; import { messagesRouter } from './router/messages/index.js'; import { nationRouter } from './router/nation/index.js'; +import { npcRouter } from './router/npc/index.js'; import { publicRouter } from './router/public/index.js'; import { troopRouter } from './router/troop/index.js'; import { turnDaemonRouter } from './router/turnDaemon/index.js'; @@ -29,6 +30,7 @@ export const appRouter = router({ troop: troopRouter, general: generalRouter, nation: nationRouter, + npc: npcRouter, turnDaemon: turnDaemonRouter, }); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index a710143..30d0b66 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -23,7 +23,9 @@ import { import type { WorldStateRow } from '../../context.js'; import { authedProcedure, router } from '../../trpc.js'; +import { MAX_NATION_TURNS, listNationTurns } from '../../turns/reservedTurns.js'; import { getMyGeneral } from '../shared/general.js'; +import { resolveSecretPermission } from '../shared/secretPermission.js'; type PermissionKind = 'normal' | 'ambassador' | 'auditor'; @@ -869,6 +871,89 @@ export const nationRouter = router({ }, }; }), + getChiefCenter: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const [nation, worldState, nationGenerals] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { + id: true, + name: true, + level: true, + meta: true, + }, + }), + ctx.db.worldState.findFirst(), + ctx.db.general.findMany({ + where: { nationId: me.nationId, officerLevel: { gte: 5 } }, + select: { + id: true, + name: true, + officerLevel: true, + npcState: true, + turnTime: true, + }, + }), + ]); + + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + if (!worldState) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + + const permissionLevel = resolveSecretPermission( + { + nationId: me.nationId, + officerLevel: me.officerLevel, + meta: me.meta, + penalty: me.penalty, + }, + nation.meta + ); + if (permissionLevel < 1) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + + const chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5]; + const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general])); + + const turnsByLevel = await Promise.all( + chiefLevels.map((level) => listNationTurns(ctx.db, nation.id, level)) + ); + + const chiefs = chiefLevels.map((level, idx) => { + const entry = generalByLevel.get(level); + return { + officerLevel: level, + name: entry?.name ?? null, + npcState: entry?.npcState ?? null, + turnTime: entry?.turnTime ? entry.turnTime.toISOString() : null, + turns: turnsByLevel[idx], + }; + }); + + return { + me: { + id: me.id, + officerLevel: me.officerLevel, + nationId: me.nationId, + }, + nation: { + id: nation.id, + name: nation.name, + level: nation.level, + }, + currentYear: worldState.currentYear, + currentMonth: worldState.currentMonth, + turnTermMinutes: Math.max(1, Math.round(worldState.tickSeconds / 60)), + maxTurns: MAX_NATION_TURNS, + chiefs, + }; + }), changePermission: authedProcedure .input( z.object({ diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts new file mode 100644 index 0000000..252fd01 --- /dev/null +++ b/app/game-api/src/router/npc/index.ts @@ -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; + 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(Object.keys(DEFAULT_NATION_POLICY) as Array); + +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, 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, 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): { 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): { 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): 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 = {}; + 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 => { + 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, 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(); + + 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(); + + 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 = {}; + 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 }; + }), +}); diff --git a/app/game-api/src/router/shared/secretPermission.ts b/app/game-api/src/router/shared/secretPermission.ts new file mode 100644 index 0000000..2882ac7 --- /dev/null +++ b/app/game-api/src/router/shared/secretPermission.ts @@ -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): PermissionKind => { + const value = meta.permission; + if (value === 'ambassador' || value === 'auditor') { + return value; + } + return 'normal'; +}; + +const resolveBelong = (meta: Record): number => readNumber(meta.belong, 0); + +const resolveSecretLimit = (meta: Record): number => + readNumber(meta.secretlimit ?? meta.secretLimit, 3); + +const checkSecretMaxPermission = (penalty: Record): 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); +}; diff --git a/app/game-frontend/src/components/chief/ChiefTurnCard.vue b/app/game-frontend/src/components/chief/ChiefTurnCard.vue new file mode 100644 index 0000000..c4eb17c --- /dev/null +++ b/app/game-frontend/src/components/chief/ChiefTurnCard.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 7e21081..87dc302 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -7,6 +7,8 @@ import InheritView from '../views/InheritView.vue'; import NationCitiesView from '../views/NationCitiesView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; +import ChiefCenterView from '../views/ChiefCenterView.vue'; +import NpcControlView from '../views/NpcControlView.vue'; import NotFoundView from '../views/NotFoundView.vue'; import { useSessionStore } from '../stores/session'; @@ -70,6 +72,24 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/chief-center', + name: 'chief-center', + component: ChiefCenterView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, + { + path: '/npc-control', + name: 'npc-control', + component: NpcControlView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/login', name: 'login', diff --git a/app/game-frontend/src/utils/npcColor.ts b/app/game-frontend/src/utils/npcColor.ts new file mode 100644 index 0000000..476ce47 --- /dev/null +++ b/app/game-frontend/src/utils/npcColor.ts @@ -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; +}; diff --git a/app/game-frontend/src/utils/npcPriorityHelp.ts b/app/game-frontend/src/utils/npcPriorityHelp.ts new file mode 100644 index 0000000..bdc9c49 --- /dev/null +++ b/app/game-frontend/src/utils/npcPriorityHelp.ts @@ -0,0 +1,67 @@ +const rawHelp: Record = { + 불가침제의: + '군주가 NPC이고, 타국에서 원조를 받았을 때,
세입(금/쌀) 대비 원조량에 따라 불가침제의를 합니다.', + 선전포고: + '군주가 NPC이고, 전쟁중이 아닐 때,
주변국중 하나를 골라 선포합니다.

선포 시점은 다음을 참고합니다.
- 인구율
- 도시내정률
- NPC전투장권장 금 충족률
- NPC전투장권장 쌀 충족률

국력이 낮은 국가를 조금 더 선호합니다.', + 천도: '인구가 많은 곳을 찾아 천도를 시도합니다.
영토의 가운데를 선호합니다.

도시 인구가 충분하다면, 굳이 천도하지는 않습니다.', + 유저장긴급포상: + '금/쌀이 부족한 유저전투장에게 긴급하게 포상합니다.
국고가 권장량보다 적어지더라도 시도합니다.', + 부대전방발령: + '(작동하지 않음)
전투 부대를 접경으로 발령합니다.
수도->시작점->도착점 경로를 따릅니다.', + 유저장구출발령: + '아군 영토에 있지 않은 유저장을 아군 영토로 발령합니다.
곧 집합하는 부대에 탑승한 경우는 제외합니다.', + 유저장후방발령: + '유저전투장 중에
- 병력이 충분하지 않고,
- 도시의 인구가 제자리 징병할 수 있을 정도로 충분하지 않고,
- 부대에 탑승하지 않았다면,
인구가 충분한 후방도시로 발령합니다.', + 부대유저장후방발령: + '접경에 위치한 부대에 탑승한 유저전투장 중에,
- 병력이 충분하지 않고,
- 첫 턴이 징병턴이며,
- 부대장 집합 턴 사이라면,
인구가 충분한 후방도시로 발령합니다.

부대장의 위치와 유저장의 위치가 다르다면 발령하지 않습니다.', + 유저장전방발령: + '후방에 있는 유저장이
- 병력을 가지고 있으며,
- 곧 훈련/사기진작이 완료될 것 같으면,
전방으로 발령합니다.

도시 관직이 많이 임명된 도시를 선호합니다.', + 유저장포상: + '금/쌀이 부족한 유저장에게 포상합니다.
유저전투장과 유저내정장은 각각 기준을 따릅니다.
국고 권장량을 가급적 지킵니다.', + 부대후방발령: + '(작동하지 않음)
후방 부대가 위치한 도시의 인구가 충분하지 않을 경우,
인구가 충분한 도시로 발령합니다.', + 부대구출발령: + '전투 부대, 후방 부대가 아닌 부대가 아군 영토에 있지 않을 때,
전방 도시 중 하나를 골라 발령합니다.', + NPC긴급포상: + '금/쌀이 부족한 NPC전투장에게 긴급하게 포상합니다.
국고가 권장량보다 "약간" 적어지더라도 시도합니다.', + NPC구출발령: '아군 영토에 있지 않은 NPC장을 아군 영토로 발령합니다.', + NPC후방발령: + 'NPC전투장 중에
- 병력이 충분하지 않고,
- 도시의 인구가 제자리 징병할 수 있을 정도로 충분하지 않고,
- 부대에 탑승하지 않았다면,
인구가 충분한 후방도시로 발령합니다.', + NPC포상: + '금/쌀이 부족한 NPC에게 포상합니다.
NPC전투장과 NPC내정장은 각각 기준을 따릅니다.
국고 권장량을 가급적 지킵니다.', + NPC전방발령: + '후방에 있는 유저장이
- 병력을 가지고 있으며,
- 곧 훈련/사기진작이 완료될 것 같으면,
전방으로 발령합니다.

도시 관직이 많이 임명된 도시를 선호합니다.', + 유저장내정발령: + '내정중인 유저장이 위치한 도시의 내정률이 95% 이상이면
개발되지 않은 도시로 발령합니다.', + NPC내정발령: + '내정중인 NPC장이 위치한 도시의 내정률이 95% 이상이면
개발되지 않은 도시로 발령합니다.', + NPC몰수: '국고가 부족하다면 NPC에게서 몰수합니다. 내정NPC장은 국고가 부족하지 않아도 몰수합니다.', + NPC사망대비: 'NPC의 사망까지 5턴 이내인 경우, 헌납합니다.
헌납할 금쌀이 없다면 물자조달을 수행합니다.', + 귀환: '아국 도시에 있지 않다면 귀환합니다.', + 금쌀구매: + '전쟁 중에 금쌀의 비율이 크게 차이난다면 금쌀을 거래하여 비슷하게 맞춥니다.
금쌀 비율이 적절하는지 판단하는데 살상률을 포함합니다.
NPC는 상인이 없어도 금쌀을 구매할 수 있습니다.

또는 금쌀 한쪽이 지나치게 적은 경우에는 내정 중에도 금쌀을 거래합니다.', + 출병: + '충분한 병력과 충분한 훈련/사기를 가지고 있는 경우 출병합니다.
접경이 여럿인 경우 무작위로 선택합니다.

타국과 전쟁중인 경우 공백지로는 출병하지 않습니다.', + 긴급내정: + '전쟁중에 민심이 70 미만이거나,
인구가 제자리 징병이 가능하지 않을 정도로 적을 경우,
일정확률로 주민선정과 정착장려를 수행합니다.

통솔이 높을 수록 수행할 확률이 높습니다.', + 전투준비: '충분한 병력을 가지고 있지만 훈련과 사기가 부족한 경우 훈련과 사기진작을 수행합니다.', + 전방워프: '전투장이 충분한 병력을 가지고 있다면 전방으로 이동합니다.', + NPC헌납: + '국고가 부족한데 NPC전쟁장이 충분한 금쌀(권장량 대비 1.5배)을 가지고 있다면 일부를 헌납합니다.
NPC내정장은 국고가 넉넉하더라도 충분한 금쌀을 가지고 있다면 권장량만 유지하고 헌납합니다.', + 징병: + '전쟁 중 병력을 소진하였다면 재 징병합니다.

기존에 사용한 병종군 중에서 사용가능한 병종을 랜덤하게 선택합니다.
고급 병종을 선택할 확률이 조금 더 높습니다.

NPC의 경우 도시의 인구가 충분하지 않다면 징병을 할 확률이 감소합니다.

유저장은 최대한 고급병종을 유지하며,
유저장 모병이 허용되는 경우 모병을 3회할 수 있다면 모병합니다.', + 후방워프: '전쟁 중 병력을 소진하였는데 도시의 인구가 충분하지 않다면,
인구가 많은 도시로 이동합니다.', + 전쟁내정: + '전쟁 중 수행하는 내정입니다.
정착장려, 기술연구의 확률이 좀 더 높고,
치안강화, 농지개간, 상업투자의 확률이 낮습니다.

내정이 가능하다 하더라도 전시임을 고려해,
30% 확률로 다른 턴을 수행합니다.', + 소집해제: '전쟁 중이 아닌 데 병력이 남아있는 경우,
3/4 확률로 소집해제합니다.', + 일반내정: + '도시에서 내정을 수행합니다. 낮은 내정일 수록 수행할 확률이 높습니다.
기술 연구는 1등급 이상 뒤쳐지지 않도록 노력합니다.', + 내정워프: + '도시에서 더이상 내정을 수행할 수 없는 경우,
일정확률로 내정이 부족한 다른 도시로 이동합니다.', +}; + +const normalizeLineBreaks = (text: string): string => text.replace(//g, '\n'); + +export const npcPriorityHelp: Record = Object.fromEntries( + Object.entries(rawHelp).map(([key, value]) => [key, normalizeLineBreaks(value)]) +); diff --git a/app/game-frontend/src/views/ChiefCenterView.vue b/app/game-frontend/src/views/ChiefCenterView.vue new file mode 100644 index 0000000..427eabb --- /dev/null +++ b/app/game-frontend/src/views/ChiefCenterView.vue @@ -0,0 +1,686 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 17db152..b49186e 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -94,6 +94,8 @@ watch( 세력 도시 세력 장수 인사부 + 사령부 + NPC 정책 유산 강화