From 5b51aad6d09c0639a3ad91e656c7f714f35f4597 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 18 Jan 2026 16:40:12 +0000 Subject: [PATCH] =?UTF-8?q?=EB=82=B4=EB=AC=B4=EB=B6=80,=20=EA=B8=88/?= =?UTF-8?q?=EC=8C=80=EC=88=98=EC=9E=85,=20=EA=B0=90=EC=B0=B0=EB=B6=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/nation/index.ts | 986 +++++++++++++++--- app/game-engine/src/turn/calendarHandlers.ts | 22 + app/game-engine/src/turn/incomeHandler.ts | 281 +++++ app/game-engine/src/turn/turnDaemon.ts | 13 +- app/game-frontend/src/router/index.ts | 20 + app/game-frontend/src/utils/diplomacy.ts | 17 + app/game-frontend/src/utils/formatLog.ts | 58 ++ .../src/views/BattleCenterView.vue | 429 ++++++++ app/game-frontend/src/views/MainView.vue | 2 + .../src/views/NationStratFinanView.vue | 740 +++++++++++++ packages/infra/src/db.ts | 2 + packages/logic/src/economy/index.ts | 1 + packages/logic/src/economy/nationIncome.ts | 275 +++++ packages/logic/src/index.ts | 1 + packages/logic/src/triggers/index.ts | 1 + 15 files changed, 2705 insertions(+), 143 deletions(-) create mode 100644 app/game-engine/src/turn/calendarHandlers.ts create mode 100644 app/game-engine/src/turn/incomeHandler.ts create mode 100644 app/game-frontend/src/utils/diplomacy.ts create mode 100644 app/game-frontend/src/utils/formatLog.ts create mode 100644 app/game-frontend/src/views/BattleCenterView.vue create mode 100644 app/game-frontend/src/views/NationStratFinanView.vue create mode 100644 packages/logic/src/economy/index.ts create mode 100644 packages/logic/src/economy/nationIncome.ts diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index 30d0b66..05ce215 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -2,8 +2,18 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asNumber, asRecord } from '@sammo-ts/common'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; import { + calcCityGoldIncome, + calcCityRiceIncome, + calcCityWallIncome, + createIncomeActionContext, DomesticTraitLoader, + getGoldIncome, + getOutcome, + getRiceIncome, + getWallIncome, + getWarGoldIncome, loadDomesticTraitModules, isDomesticTraitKey, loadNationTraitModules, @@ -15,13 +25,14 @@ import { loadWarTraitModules, WarTraitLoader, isWarTraitKey, - type GeneralActionContext, - type General as LogicGeneral, + type CityIncomeSource, type Nation as LogicNation, + type NationIncomeContext, + type TriggerNationalIncomeType, type TriggerValue, } from '@sammo-ts/logic'; -import type { WorldStateRow } from '../../context.js'; +import type { GameApiContext, InputJsonValue, 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'; @@ -40,12 +51,6 @@ type TraitCache = { type NationTraitModule = Awaited>[number] | null; -type NationIncomeContext = { - trait: NationTraitModule; - context: GeneralActionContext; - rate: number; -}; - type NationIncomeRow = { id: number; name: string; @@ -119,6 +124,46 @@ type GeneralOfficerRow = { meta: unknown; }; +type NationCountRow = { + nationId: number; + count: number; +}; + +type NationStratRow = { + id: number; + name: string; + color: string; + level: number; + typeCode: string; + capitalCityId: number | null; + gold: number | null; + rice: number | null; + tech: number | null; + meta: unknown; +}; + +type DiplomacyRow = { + destNationId: number; + stateCode: number; + term: number; +}; + +type GeneralPowerRow = { + id: number; + nationId: number; + cityId: number; + npcState: number; + officerLevel: number; + leadership: number; + strength: number; + intel: number; + experience: number; + dedication: number; + gold: number; + rice: number; + meta: unknown; +}; + const traitCache: TraitCache = { domestic: new Map(), war: new Map(), @@ -127,6 +172,8 @@ const traitCache: TraitCache = { }; const DEFAULT_CHIEF_STAT_MIN = 65; +const MAX_AVAILABLE_WAR_SETTING_CNT = 10; +const INC_AVAILABLE_WAR_SETTING_CNT = 2; const normalizeTraitKey = (value: string | null | undefined): string | null => { if (!value || value === 'None') { @@ -140,6 +187,26 @@ const readMetaNumber = (meta: Record, key: string, fallback: nu return typeof raw === 'number' && Number.isFinite(raw) ? raw : fallback; }; +const readMetaBool = (meta: Record, key: string, fallback = false): boolean => { + const raw = meta[key]; + if (typeof raw === 'boolean') { + return raw; + } + if (typeof raw === 'number') { + return raw !== 0; + } + if (typeof raw === 'string') { + const lowered = raw.toLowerCase(); + if (lowered === 'true' || lowered === '1') { + return true; + } + if (lowered === 'false' || lowered === '0') { + return false; + } + } + return fallback; +}; + const resolveOfficerCity = (meta: Record): number => { const camel = readMetaNumber(meta, 'officerCity', 0); if (camel > 0) { @@ -172,6 +239,53 @@ const resolveNationRate = (nation: NationIncomeRow): number => { return asNumber(meta.rate, 20); }; +const toIncomeCity = (city: CityIncomeRow): CityIncomeSource => ({ + id: city.id, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: city.trust, + supplyState: city.supplyState, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + meta: asRecord(city.meta), +}); + +const resolveNationBill = (meta: Record): number => readMetaNumber(meta, 'bill', 100); + +const resolveNationSecretLimit = (meta: Record): number => { + const legacy = readMetaNumber(meta, 'secretlimit', -1); + if (legacy >= 0) { + return legacy; + } + return readMetaNumber(meta, 'secretLimit', 3); +}; + +const resolveNationBlockWar = (meta: Record): boolean => + readMetaBool(meta, 'war', readMetaBool(meta, 'blockWar', false)); + +const resolveNationBlockScout = (meta: Record): boolean => + readMetaBool(meta, 'scout', readMetaBool(meta, 'blockScout', false)); + +const resolveNationNotice = (meta: Record): string => + typeof meta.notice === 'string' ? meta.notice : ''; + +const resolveNationScoutMessage = (meta: Record): string => + typeof meta.infoText === 'string' ? meta.infoText : ''; + +const resolveWarSettingRemain = (meta: Record): number => { + const legacy = readMetaNumber(meta, 'available_war_setting_cnt', -1); + const fallback = legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT); + return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_CNT, fallback)); +}; + const checkSecretMaxPermission = (penalty: Record): number => { if (penalty.noTopSecret) { return 1; @@ -234,46 +348,12 @@ const loadTraitNames = async ( return cache; }; -const buildIncomeContext = (nation: NationIncomeRow): GeneralActionContext => { +const buildNationIncomeContext = async (nation: NationIncomeRow): Promise => { + let trait: NationTraitModule = null; + if (isNationTraitKey(nation.typeCode)) { + [trait] = await loadNationTraitModules([nation.typeCode], new NationTraitLoader()); + } const nationMeta = asRecord(nation.meta) as Record; - const general: LogicGeneral = { - id: 0, - name: 'SYSTEM', - nationId: nation.id, - cityId: 0, - troopId: 0, - stats: { leadership: 0, strength: 0, intelligence: 0 }, - experience: 0, - dedication: 0, - officerLevel: 0, - role: { - personality: null, - specialDomestic: null, - specialWar: null, - items: { - horse: null, - weapon: null, - book: null, - item: null, - }, - }, - injury: 0, - gold: 0, - rice: 0, - crew: 0, - crewTypeId: 0, - train: 0, - atmos: 0, - age: 0, - npcState: 0, - triggerState: { - flags: {}, - counters: {}, - modifiers: {}, - meta: {}, - }, - meta: {}, - }; const logicNation: LogicNation = { id: nation.id, name: nation.name, @@ -287,105 +367,31 @@ const buildIncomeContext = (nation: NationIncomeRow): GeneralActionContext => { typeCode: nation.typeCode, meta: nationMeta, }; - return { general, nation: logicNation }; -}; - -const buildNationIncomeContext = async (nation: NationIncomeRow): Promise => { - let trait: NationTraitModule = null; - if (isNationTraitKey(nation.typeCode)) { - [trait] = await loadNationTraitModules([nation.typeCode], new NationTraitLoader()); - } + const actionContext = createIncomeActionContext(logicNation); + const modifyIncome = trait?.onCalcNationalIncome + ? (type: TriggerNationalIncomeType, amount: number) => trait.onCalcNationalIncome!(actionContext, type, amount) + : undefined; return { - trait, - context: buildIncomeContext(nation), + modifyIncome, rate: resolveNationRate(nation), }; }; -const applyNationIncome = ( - incomeContext: NationIncomeContext, - type: 'gold' | 'rice' | 'pop', - amount: number -): number => { - if (!incomeContext.trait?.onCalcNationalIncome) { - return amount; +const formatDateTime = (value: Date | null): string => { + if (!value) { + return ''; } - return incomeContext.trait.onCalcNationalIncome(incomeContext.context, type, amount); + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + const hours = String(value.getHours()).padStart(2, '0'); + const minutes = String(value.getMinutes()).padStart(2, '0'); + const seconds = String(value.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; }; -const calcCityGoldIncome = ( - incomeContext: NationIncomeContext, - city: CityIncomeRow, - officerCnt: number, - isCapital: boolean, - nationLevel: number -): number => { - if (city.supplyState === 0) { - return 0; - } - const trustRatio = city.trust / 200 + 0.5; - const commMax = Math.max(1, city.commerceMax); - const secuMax = Math.max(1, city.securityMax); - - let income = (city.population * city.commerce * trustRatio) / commMax / 30; - income *= 1 + city.security / secuMax / 10; - income *= Math.pow(1.05, officerCnt); - if (isCapital && nationLevel > 0) { - income *= 1 + 1 / (3 * nationLevel); - } - - const adjusted = applyNationIncome(incomeContext, 'gold', income); - return Math.round(adjusted * (incomeContext.rate / 20)); -}; - -const calcCityRiceIncome = ( - incomeContext: NationIncomeContext, - city: CityIncomeRow, - officerCnt: number, - isCapital: boolean, - nationLevel: number -): number => { - if (city.supplyState === 0) { - return 0; - } - const trustRatio = city.trust / 200 + 0.5; - const agriMax = Math.max(1, city.agricultureMax); - const secuMax = Math.max(1, city.securityMax); - - let income = (city.population * city.agriculture * trustRatio) / agriMax / 30; - income *= 1 + city.security / secuMax / 10; - income *= Math.pow(1.05, officerCnt); - if (isCapital && nationLevel > 0) { - income *= 1 + 1 / (3 * nationLevel); - } - - const adjusted = applyNationIncome(incomeContext, 'rice', income); - return Math.round(adjusted * (incomeContext.rate / 20)); -}; - -const calcCityWallIncome = ( - incomeContext: NationIncomeContext, - city: CityIncomeRow, - officerCnt: number, - isCapital: boolean, - nationLevel: number -): number => { - if (city.supplyState === 0) { - return 0; - } - const wallMax = Math.max(1, city.wallMax); - const secuMax = Math.max(1, city.securityMax); - - let income = (city.defence * city.wall) / wallMax / 3; - income *= 1 + city.security / secuMax / 10; - income *= Math.pow(1.05, officerCnt); - if (isCapital && nationLevel > 0) { - income *= 1 + 1 / (3 * nationLevel); - } - - const adjusted = applyNationIncome(incomeContext, 'rice', income); - return Math.round(adjusted * (incomeContext.rate / 20)); -}; +const zGeneralLogType = z.enum(['generalHistory', 'generalAction', 'battleResult', 'battleDetail']); +type GeneralLogType = z.infer; const assertNationAccess = (general: { nationId: number; officerLevel: number }) => { if (general.nationId <= 0 || general.officerLevel <= 0) { @@ -393,6 +399,58 @@ const assertNationAccess = (general: { nationId: number; officerLevel: number }) } }; +const resolveNationPermission = ( + general: { nationId: number; officerLevel: number; meta: unknown; penalty: unknown }, + nationMeta: unknown, + checkSecretLimit = true +): number => + resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nationMeta, + checkSecretLimit + ); + +const assertNationEditable = ( + general: { nationId: number; officerLevel: number; meta: unknown; penalty: unknown }, + nationMeta: unknown +): void => { + const permission = resolveNationPermission(general, nationMeta, false); + if (permission < 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + if (general.officerLevel < 5 && permission !== 4) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } +}; + +const updateNationMeta = async ( + ctx: Pick, + nationId: number, + updates: Record +): Promise => { + const nation = await ctx.db.nation.findUnique({ + where: { id: nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + const meta = asRecord(nation.meta); + const nextMeta = { ...meta, ...updates } as InputJsonValue; + await ctx.db.nation.update({ + where: { id: nationId }, + data: { + meta: nextMeta, + }, + }); + return nextMeta; +}; + const mapGeneralList = async ( generals: GeneralListRow[], cityNameMap: Map, @@ -632,10 +690,11 @@ export const nationRouter = router({ const officers = officerByCity.get(city.id) ?? {}; const officerCnt = officerCntByCity.get(city.id) ?? 0; const isCapital = nation.capitalCityId === city.id; + const incomeCity = toIncomeCity(city); const incomes = { - gold: calcCityGoldIncome(incomeContext, city, officerCnt, isCapital, nation.level), - rice: calcCityRiceIncome(incomeContext, city, officerCnt, isCapital, nation.level), - wall: calcCityWallIncome(incomeContext, city, officerCnt, isCapital, nation.level), + gold: calcCityGoldIncome(incomeContext, incomeCity, officerCnt, isCapital, nation.level), + rice: calcCityRiceIncome(incomeContext, incomeCity, officerCnt, isCapital, nation.level), + wall: calcCityWallIncome(incomeContext, incomeCity, officerCnt, isCapital, nation.level), }; return { @@ -871,6 +930,649 @@ export const nationRouter = router({ }, }; }), + getStratFinan: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const [nation, worldState, nationRows, diplomacyRows, generalCounts, cityCounts, cityRows, generalRows] = + (await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + typeCode: true, + capitalCityId: true, + gold: true, + rice: true, + tech: true, + meta: true, + }, + }), + ctx.db.worldState.findFirst(), + ctx.db.nation.findMany({ + select: { + id: true, + name: true, + color: true, + level: true, + typeCode: true, + capitalCityId: true, + gold: true, + rice: true, + tech: true, + meta: true, + }, + orderBy: { id: 'asc' }, + }), + ctx.db.diplomacy.findMany({ + where: { srcNationId: me.nationId }, + select: { + destNationId: true, + stateCode: true, + term: true, + }, + }), + ctx.db.$queryRaw` + SELECT nation_id as "nationId", COUNT(*)::int as "count" + FROM general + GROUP BY nation_id + `, + ctx.db.$queryRaw` + SELECT nation_id as "nationId", COUNT(*)::int as "count" + FROM city + GROUP BY nation_id + `, + ctx.db.city.findMany({ + select: { + id: true, + name: true, + level: true, + nationId: true, + region: true, + population: true, + populationMax: true, + agriculture: true, + agricultureMax: true, + commerce: true, + commerceMax: true, + security: true, + securityMax: true, + trust: true, + trade: true, + defence: true, + defenceMax: true, + wall: true, + wallMax: true, + supplyState: true, + frontState: true, + meta: true, + }, + }), + ctx.db.general.findMany({ + select: { + id: true, + nationId: true, + cityId: true, + npcState: true, + officerLevel: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + gold: true, + rice: true, + meta: true, + }, + }), + ])) as [ + (NationIncomeRow & { tech: number | null }) | null, + WorldStateRow | null, + NationStratRow[], + DiplomacyRow[], + NationCountRow[], + NationCountRow[], + CityIncomeRow[], + GeneralPowerRow[], + ]; + + 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 = resolveNationPermission(me, nation.meta, true); + if (permissionLevel < 1) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + + const nationMeta = asRecord(nation.meta); + const editable = me.officerLevel >= 5 || permissionLevel === 4; + + const generalCountMap = new Map(); + for (const row of generalCounts) { + generalCountMap.set(row.nationId, row.count); + } + + const cityCountMap = new Map(); + for (const row of cityCounts) { + cityCountMap.set(row.nationId, row.count); + } + + const diplomacyMap = new Map(); + for (const row of diplomacyRows) { + diplomacyMap.set(row.destNationId, { state: row.stateCode, term: row.term }); + } + + const cityStatsByNation = new Map(); + for (const city of cityRows) { + const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; + const valueSum = + city.population + + city.agriculture + + city.commerce + + city.security + + city.wall + + city.defence; + const maxSum = + city.populationMax + + city.agricultureMax + + city.commerceMax + + city.securityMax + + city.wallMax + + city.defenceMax; + entry.popSum += city.population; + entry.valueSum += valueSum; + entry.maxSum += maxSum; + cityStatsByNation.set(city.nationId, entry); + } + + const generalStatsByNation = new Map(); + for (const general of generalRows) { + const entry = generalStatsByNation.get(general.nationId) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + entry.goldRice += general.gold + general.rice; + const leadership = general.leadership; + const strength = general.strength; + const intel = general.intel; + const npcMultiplier = general.npcState < 2 ? 1.2 : 1; + const leaderCore = leadership >= 40 ? leadership : 0; + entry.statPower += npcMultiplier * leaderCore * 2 + (Math.sqrt(intel * strength) * 2 + leadership / 2) / 2; + entry.expDed += general.experience + general.dedication; + generalStatsByNation.set(general.nationId, entry); + } + + const powerByNation = new Map(); + for (const nationItem of nationRows) { + const generalStats = generalStatsByNation.get(nationItem.id) ?? { goldRice: 0, statPower: 0, expDed: 0 }; + const cityStats = cityStatsByNation.get(nationItem.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; + const resource = Math.round(((nationItem.gold ?? 0) + (nationItem.rice ?? 0) + generalStats.goldRice) / 100); + const tech = nationItem.tech ?? 0; + const cityPower = + nationItem.level > 0 && cityStats.maxSum > 0 + ? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100) + : 0; + const expDed = Math.round(generalStats.expDed / 100); + powerByNation.set( + nationItem.id, + Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10) + ); + } + + const nationsList = nationRows.map((nationItem) => { + const diplomacy = + nationItem.id === nation.id + ? { state: 7, term: null } + : diplomacyMap.get(nationItem.id) ?? { state: 2, term: 0 }; + return { + id: nationItem.id, + name: nationItem.name, + color: nationItem.color, + level: nationItem.level, + power: powerByNation.get(nationItem.id) ?? 0, + generalCount: generalCountMap.get(nationItem.id) ?? 0, + cityCount: cityCountMap.get(nationItem.id) ?? 0, + diplomacy, + }; + }); + + const nationCities = cityRows.filter((city) => city.nationId === nation.id); + const nationGenerals = generalRows.filter((general) => general.nationId === nation.id); + + const officerCntByCity = new Map(); + for (const general of nationGenerals) { + if (general.officerLevel < 2 || general.officerLevel > 4) { + continue; + } + const officerCity = resolveOfficerCity(asRecord(general.meta)); + if (!officerCity || general.cityId !== officerCity) { + continue; + } + officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1); + } + + const incomeContext = await buildNationIncomeContext(nation); + const baseIncomeContext: NationIncomeContext = { ...incomeContext, rate: 100 }; + const incomeCities = nationCities.map(toIncomeCity); + const goldCityIncome = getGoldIncome( + baseIncomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const riceCityIncome = getRiceIncome( + baseIncomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const riceWallIncome = getWallIncome( + baseIncomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const warGoldIncome = getWarGoldIncome(baseIncomeContext, incomeCities); + + const outcome = getOutcome( + 100, + nationGenerals.filter((general) => general.npcState !== 5) + ); + + return { + editable, + nationMsg: resolveNationNotice(nationMeta), + scoutMsg: resolveNationScoutMessage(nationMeta), + nationId: nation.id, + officerLevel: me.officerLevel, + year: worldState.currentYear, + month: worldState.currentMonth, + nationsList, + gold: nation.gold ?? 0, + rice: nation.rice ?? 0, + income: { + gold: { + city: goldCityIncome, + war: warGoldIncome, + }, + rice: { + city: riceCityIncome, + wall: riceWallIncome, + }, + }, + outcome, + policy: { + rate: resolveNationRate(nation), + bill: resolveNationBill(nationMeta), + secretLimit: resolveNationSecretLimit(nationMeta), + blockScout: resolveNationBlockScout(nationMeta), + blockWar: resolveNationBlockWar(nationMeta), + }, + warSettingCnt: { + remain: resolveWarSettingRemain(nationMeta), + inc: INC_AVAILABLE_WAR_SETTING_CNT, + max: MAX_AVAILABLE_WAR_SETTING_CNT, + }, + }; + }), + setNotice: authedProcedure + .input( + z.object({ + msg: z.string().max(16384), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + await updateNationMeta(ctx, me.nationId, { + notice: input.msg, + }); + return { ok: true }; + }), + setScoutMsg: authedProcedure + .input( + z.object({ + msg: z.string().max(1000), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + await updateNationMeta(ctx, me.nationId, { + infoText: input.msg, + }); + return { ok: true }; + }), + setRate: authedProcedure + .input( + z.object({ + amount: z.number().int().min(5).max(30), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + await updateNationMeta(ctx, me.nationId, { + rate: input.amount, + }); + return { ok: true }; + }), + setBill: authedProcedure + .input( + z.object({ + amount: z.number().int().min(20).max(200), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + await updateNationMeta(ctx, me.nationId, { + bill: input.amount, + }); + return { ok: true }; + }), + setSecretLimit: authedProcedure + .input( + z.object({ + amount: z.number().int().min(1).max(99), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + await updateNationMeta(ctx, me.nationId, { + secretlimit: input.amount, + }); + return { ok: true }; + }), + setBlockWar: authedProcedure + .input( + z.object({ + value: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + + const meta = asRecord(nation.meta); + const remain = resolveWarSettingRemain(meta); + if (remain <= 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '잔여 횟수가 부족합니다.' }); + } + const nextRemain = Math.max(0, remain - 1); + await updateNationMeta(ctx, me.nationId, { + war: input.value ? 1 : 0, + available_war_setting_cnt: nextRemain, + }); + return { availableCnt: nextRemain }; + }), + setBlockScout: authedProcedure + .input( + z.object({ + value: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + assertNationEditable(me, nation.meta); + await updateNationMeta(ctx, me.nationId, { + scout: input.value ? 1 : 0, + }); + return { ok: true }; + }), + getBattleCenter: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const [nation, worldState, generalRows] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + meta: true, + }, + }), + ctx.db.worldState.findFirst(), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + select: { + id: true, + name: true, + npcState: true, + officerLevel: true, + cityId: true, + turnTime: true, + recentWarTime: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + injury: true, + gold: true, + rice: true, + crew: true, + train: true, + atmos: true, + }, + orderBy: { id: 'asc' }, + }), + ]); + + 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 = resolveNationPermission(me, nation.meta, true); + if (permissionLevel < 1) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + + const generalIds = generalRows.map((general) => general.id); + const battleCounts = + generalIds.length > 0 + ? await ctx.db.logEntry.groupBy({ + by: ['generalId'], + where: { + generalId: { in: generalIds }, + category: LogCategory.BATTLE_BRIEF, + }, + _count: { _all: true }, + }) + : []; + const battleCountMap = new Map(); + for (const row of battleCounts) { + if (row.generalId !== null) { + battleCountMap.set(row.generalId, row._count._all); + } + } + + const generals = generalRows.map((general) => ({ + id: general.id, + name: general.name, + npcState: general.npcState, + officerLevel: general.officerLevel, + cityId: general.cityId, + turnTime: formatDateTime(general.turnTime), + recentWar: formatDateTime(general.recentWarTime), + warnum: battleCountMap.get(general.id) ?? 0, + stats: { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }, + experience: general.experience, + dedication: general.dedication, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + train: general.train, + atmos: general.atmos, + })); + + return { + me: { + id: me.id, + officerLevel: me.officerLevel, + permissionLevel, + }, + nation: { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + }, + currentYear: worldState.currentYear, + currentMonth: worldState.currentMonth, + turnTermMinutes: Math.max(1, Math.round(worldState.tickSeconds / 60)), + generals, + }; + }), + getGeneralLog: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + type: zGeneralLogType, + }) + ) + .query(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const [nation, target] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }), + ctx.db.general.findUnique({ + where: { id: input.generalId }, + select: { id: true, nationId: true, npcState: true }, + }), + ]); + + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + if (!target) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' }); + } + + const permissionLevel = resolveNationPermission(me, nation.meta, true); + if (permissionLevel < 1) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + if (target.nationId !== me.nationId) { + throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' }); + } + if ( + input.type === 'generalAction' && + target.npcState < 2 && + target.id !== me.id && + permissionLevel < 2 + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.', + }); + } + + const categoryMap: Record = { + generalHistory: LogCategory.HISTORY, + generalAction: LogCategory.ACTION, + battleResult: LogCategory.BATTLE_BRIEF, + battleDetail: LogCategory.BATTLE_DETAIL, + }; + + const logs = await ctx.db.logEntry.findMany({ + where: { + generalId: target.id, + scope: LogScope.GENERAL, + category: categoryMap[input.type], + }, + orderBy: { id: 'desc' }, + take: 30, + }); + + return { + type: input.type, + generalId: target.id, + logs: logs.map((entry) => ({ + id: entry.id, + text: entry.text, + })), + }; + }), getChiefCenter: authedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); diff --git a/app/game-engine/src/turn/calendarHandlers.ts b/app/game-engine/src/turn/calendarHandlers.ts new file mode 100644 index 0000000..e6ada00 --- /dev/null +++ b/app/game-engine/src/turn/calendarHandlers.ts @@ -0,0 +1,22 @@ +import type { TurnCalendarHandler } from './inMemoryWorld.js'; + +export const composeCalendarHandlers = ( + ...handlers: Array +): TurnCalendarHandler | null => { + const resolved = handlers.filter(Boolean) as TurnCalendarHandler[]; + if (resolved.length === 0) { + return null; + } + return { + onMonthChanged: (context) => { + for (const handler of resolved) { + handler.onMonthChanged?.(context); + } + }, + onYearChanged: (context) => { + for (const handler of resolved) { + handler.onYearChanged?.(context); + } + }, + }; +}; diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts new file mode 100644 index 0000000..9431b86 --- /dev/null +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -0,0 +1,281 @@ +import { asNumber, asRecord } from '@sammo-ts/common'; +import { + ActionLogger, + LogFormat, + createIncomeActionContext, + getBill, + getGoldIncome, + getOutcome, + getRiceIncome, + getWallIncome, + type CityIncomeSource, + type Nation, + type NationIncomeContext, + type NationTraitModule, + type TriggerNationalIncomeType, +} from '@sammo-ts/logic'; + +import type { ScenarioConfig } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from './inMemoryWorld.js'; +import type { TurnGeneral } from './types.js'; + +const resolveNumber = (source: Record, keys: string[], fallback: number): number => { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; +}; + +const resolveNationRate = (nation: Nation): number => asNumber(nation.meta.rate, 20); + +const resolveNationBill = (nation: Nation): number => asNumber(nation.meta.bill, 100); + +const resolveOfficerCity = (meta: Record): number => { + const camel = asNumber(meta.officerCity, 0); + if (camel > 0) { + return camel; + } + return asNumber(meta.officer_city, 0); +}; + +const resolveCityTrust = (meta: Record): number => { + const trust = asNumber(meta.trust, 50); + return trust; +}; + +const toIncomeCity = (city: ReturnType[number]): CityIncomeSource => ({ + id: city.id, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: resolveCityTrust(asRecord(city.meta)), + supplyState: city.supplyState, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + meta: asRecord(city.meta), +}); + +const buildNationIncomeContext = (nation: Nation, trait: NationTraitModule | null): NationIncomeContext => { + const actionContext = createIncomeActionContext(nation); + const modifyIncome = trait?.onCalcNationalIncome + ? (type: TriggerNationalIncomeType, amount: number) => trait.onCalcNationalIncome!(actionContext, type, amount) + : undefined; + return { + rate: resolveNationRate(nation), + modifyIncome, + }; +}; + +const buildOfficerCountMap = (generals: TurnGeneral[]): Map => { + const officerCntByCity = new Map(); + for (const general of generals) { + if (general.officerLevel < 2 || general.officerLevel > 4) { + continue; + } + const officerCity = resolveOfficerCity(asRecord(general.meta)); + if (!officerCity || general.cityId !== officerCity) { + continue; + } + officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1); + } + return officerCntByCity; +}; + +const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType): void => { + for (const log of logs) { + world.pushLog(log); + } +}; + +const roundResource = (value: number): number => Math.round(value); + +const applyIncomeOutcome = ( + current: number, + income: number, + outcome: number, + baseResource: number, + originOutcome: number +): { next: number; ratio: number; realOutcome: number } => { + let next = current + income; + let realOutcome = 0; + if (next < baseResource) { + realOutcome = 0; + next = baseResource; + } else if (next - baseResource < outcome) { + realOutcome = next - baseResource; + next = baseResource; + } else { + realOutcome = outcome; + next -= realOutcome; + } + + const ratio = originOutcome > 0 ? realOutcome / originOutcome : 0; + return { next: Math.max(next, baseResource), ratio, realOutcome }; +}; + +const processIncomeForNation = ( + world: InMemoryTurnWorld, + nation: Nation, + generals: TurnGeneral[], + cities: ReturnType, + officerCounts: Map, + traitMap: Map, + type: 'gold' | 'rice', + baseResource: number +): void => { + const nationCities = cities.filter((city) => city.nationId === nation.id).map(toIncomeCity); + const nationGenerals = generals.filter((general) => general.nationId === nation.id && general.npcState !== 5); + const trait = traitMap.get(nation.typeCode) ?? null; + const incomeContext = buildNationIncomeContext(nation, trait); + + let income = 0; + if (type === 'gold') { + income = getGoldIncome( + incomeContext, + nationCities, + officerCounts, + nation.capitalCityId ?? 0, + nation.level + ); + } else { + income = + getRiceIncome( + incomeContext, + nationCities, + officerCounts, + nation.capitalCityId ?? 0, + nation.level + ) + + getWallIncome( + incomeContext, + nationCities, + officerCounts, + nation.capitalCityId ?? 0, + nation.level + ); + } + + const incomeValue = roundResource(income); + const originOutcome = getOutcome(100, nationGenerals); + const bill = resolveNationBill(nation); + const outcome = Math.round((bill / 100) * originOutcome); + const current = type === 'gold' ? nation.gold : nation.rice; + + const { next, ratio } = applyIncomeOutcome(current, incomeValue, outcome, baseResource, originOutcome); + const nextMeta = { + ...nation.meta, + [`prev_income_${type}`]: incomeValue, + }; + + if (type === 'gold') { + world.updateNation(nation.id, { gold: next, meta: nextMeta }); + } else { + world.updateNation(nation.id, { rice: next, meta: nextMeta }); + } + + const incomeText = incomeValue.toLocaleString(); + const incomeLog = type === 'gold' ? `이번 수입은 금 ${incomeText}입니다.` : `이번 수입은 쌀 ${incomeText}입니다.`; + for (const general of nationGenerals) { + const pay = Math.round(getBill(general.dedication) * ratio); + if (type === 'gold') { + world.updateGeneral(general.id, { gold: general.gold + pay }); + } else { + world.updateGeneral(general.id, { rice: general.rice + pay }); + } + + const logger = new ActionLogger({ generalId: general.id, nationId: nation.id }); + if (general.officerLevel > 4) { + logger.pushGeneralActionLog(incomeLog, LogFormat.PLAIN); + } + const payText = pay.toLocaleString(); + const payLog = + type === 'gold' + ? `봉급으로 금 ${payText}을 받았습니다.` + : `봉급으로 쌀 ${payText}을 받았습니다.`; + logger.pushGeneralActionLog(payLog, LogFormat.PLAIN); + pushLogs(world, logger.flush()); + } +}; + +export const createIncomeHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + scenarioConfig: ScenarioConfig; + nationTraits: Map; +}): TurnCalendarHandler => { + const constValues = asRecord(options.scenarioConfig.const); + const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], 0); + const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], 0); + + const handler: TurnCalendarHandler = { + onMonthChanged: (context: TurnCalendarContext) => { + const world = options.getWorld(); + if (!world) { + return; + } + const month = context.currentMonth; + if (month !== 1 && month !== 7) { + return; + } + + const nations = world.listNations(); + const generals = world.listGenerals(); + const cities = world.listCities(); + + const byNation: Map = new Map(); + for (const general of generals) { + const bucket = byNation.get(general.nationId) ?? []; + bucket.push(general); + byNation.set(general.nationId, bucket); + } + + for (const nation of nations) { + const nationGenerals = byNation.get(nation.id) ?? []; + const officerCounts = buildOfficerCountMap(nationGenerals); + if (month === 1) { + processIncomeForNation( + world, + nation, + nationGenerals, + cities, + officerCounts, + options.nationTraits, + 'gold', + baseGold + ); + } else if (month === 7) { + processIncomeForNation( + world, + nation, + nationGenerals, + cities, + officerCounts, + options.nationTraits, + 'rice', + baseRice + ); + } + } + + const logger = new ActionLogger(); + if (month === 1) { + logger.pushGlobalHistoryLog('【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.'); + } else if (month === 7) { + logger.pushGlobalHistoryLog('【지급】가을이 되어 봉록에 따라 군량이 지급됩니다.'); + } + pushLogs(world, logger.flush()); + }, + }; + + return handler; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index fe622d1..53f14a3 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,6 +1,7 @@ import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic'; import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common'; import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; import { SystemClock } from '../lifecycle/clock.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; @@ -16,6 +17,8 @@ import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js'; import { InMemoryTurnStateStore } from './inMemoryStateStore.js'; import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js'; import { createGatewayProfileGate } from './gatewayProfileGate.js'; +import { composeCalendarHandlers } from './calendarHandlers.js'; +import { createIncomeHandler } from './incomeHandler.js'; import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; import { createTurnDaemonCommandHandler } from './worldCommandHandler.js'; @@ -100,6 +103,8 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) }) : await loadTurnCommandProfile()); let worldRef: InMemoryTurnWorld | null = null; + const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); + const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const unification = options.calendarHandler ? null : createUnificationHandler({ @@ -107,6 +112,12 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) profileName: options.profileName ?? options.profile, getWorld: () => worldRef, }); + const incomeHandler = createIncomeHandler({ + getWorld: () => worldRef, + scenarioConfig: snapshot.scenarioConfig, + nationTraits: nationTraitMap, + }); + const calendarHandler = composeCalendarHandlers(options.calendarHandler ?? unification?.handler, incomeHandler); const worldOptions: InMemoryTurnWorldOptions = { schedule, generalTurnHandler: @@ -120,7 +131,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) getWorld: () => worldRef, commandProfile, })), - calendarHandler: options.calendarHandler ?? unification?.handler, + calendarHandler: calendarHandler ?? undefined, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); worldRef = world; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 87dc302..c788f1f 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -7,7 +7,9 @@ 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 NationStratFinanView from '../views/NationStratFinanView.vue'; import ChiefCenterView from '../views/ChiefCenterView.vue'; +import BattleCenterView from '../views/BattleCenterView.vue'; import NpcControlView from '../views/NpcControlView.vue'; import NotFoundView from '../views/NotFoundView.vue'; import { useSessionStore } from '../stores/session'; @@ -72,6 +74,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/nation/finance', + name: 'nation-finance', + component: NationStratFinanView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/chief-center', name: 'chief-center', @@ -81,6 +92,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/battle-center', + name: 'battle-center', + component: BattleCenterView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/npc-control', name: 'npc-control', diff --git a/app/game-frontend/src/utils/diplomacy.ts b/app/game-frontend/src/utils/diplomacy.ts new file mode 100644 index 0000000..47268ca --- /dev/null +++ b/app/game-frontend/src/utils/diplomacy.ts @@ -0,0 +1,17 @@ +export type DiplomacyState = 0 | 1 | 2 | 7; + +export type DiplomacyInfo = { + name: string; + color?: string; +}; + +export const diplomacyStateInfo: Record = { + 0: { name: '교전', color: 'red' }, + 1: { name: '선포중', color: 'magenta' }, + 2: { name: '통상' }, + 7: { name: '불가침', color: 'green' }, +}; + +export const resolveDiplomacyInfo = (state: number): DiplomacyInfo => { + return diplomacyStateInfo[state as DiplomacyState] ?? { name: '알 수 없음' }; +}; diff --git a/app/game-frontend/src/utils/formatLog.ts b/app/game-frontend/src/utils/formatLog.ts new file mode 100644 index 0000000..39ac1e5 --- /dev/null +++ b/app/game-frontend/src/utils/formatLog.ts @@ -0,0 +1,58 @@ +const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g; + +const convertMap: Record = { + R: 'color: red;', + B: 'color: blue;', + G: 'color: green;', + M: 'color: magenta;', + C: 'color: cyan;', + L: 'color: limegreen;', + S: 'color: skyblue;', + O: 'color: orangered;', + D: 'color: orangered;', + Y: 'color: yellow;', + W: 'color: white;', + 1: 'font-size: 0.9em;', +}; + +const convertMap2: Record = { + 1: 'font-size: 0.9em;', +}; + +export const formatLog = (text?: string): string => { + if (!text) { + return ''; + } + + let match: RegExpExecArray | null = null; + let lastIndex = 0; + const result: string[] = []; + + while ((match = logRegex.exec(text)) !== null) { + const partAll = match[0]; + const subPart = match[1]; + const index = match.index; + + if (lastIndex !== index) { + result.push(text.slice(lastIndex, index)); + } + + if (subPart === '/') { + result.push(''); + } else if (subPart.length === 2) { + result.push( + `` + ); + } else { + result.push(``); + } + + lastIndex = index + partAll.length; + } + + if (lastIndex !== text.length) { + result.push(text.slice(lastIndex)); + } + + return result.join(''); +}; diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue new file mode 100644 index 0000000..1d3f0e6 --- /dev/null +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -0,0 +1,429 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index b49186e..cd68027 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -94,7 +94,9 @@ watch( 세력 도시 세력 장수 인사부 + 내무부 사령부 + 감찰부 NPC 정책 유산 강화