From 45ba9604740c0a2ca8e451f40b7360974e1381f1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 08:42:57 +0000 Subject: [PATCH 1/2] feat: complete general turn lifecycle --- app/game-api/src/router/join/index.ts | 88 +++- app/game-engine/src/turn/databaseHooks.ts | 13 + .../turn/generalTurnLifecyclePersistence.ts | 320 ++++++++++++++ app/game-engine/src/turn/inMemoryWorld.ts | 44 +- .../src/turn/reservedTurnHandler.ts | 409 +++++++++++++++++- app/game-engine/src/turn/types.ts | 4 + app/game-engine/src/turn/worldLoader.ts | 4 + .../test/generalTurnLifecycle.test.ts | 310 +++++++++++++ ...rnLifecyclePersistence.integration.test.ts | 216 +++++++++ packages/infra/prisma/game.prisma | 14 + .../migration.sql | 13 + packages/infra/src/db.ts | 1 + packages/infra/src/turnEngineDb.ts | 6 + 13 files changed, 1412 insertions(+), 30 deletions(-) create mode 100644 app/game-engine/src/turn/generalTurnLifecyclePersistence.ts create mode 100644 app/game-engine/test/generalTurnLifecycle.test.ts create mode 100644 app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts create mode 100644 packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index bb98a8a..f6b65f5 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -517,6 +517,18 @@ export const joinRouter = router({ }, }, }); + await db.generalAccessLog.upsert({ + where: { generalId: general.id }, + update: { + userId, + lastRefresh: new Date(), + }, + create: { + generalId: general.id, + userId, + lastRefresh: new Date(), + }, + }); if (inheritRequiredPoint > 0) { await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint); @@ -618,25 +630,67 @@ export const joinRouter = router({ }); } - const updated = await ctx.db.general.updateMany({ - where: { - id: input.generalId, - userId: null, - npcState: { gte: 2 }, - }, - data: { - userId, - npcState: 1, - updatedAt: new Date(), - }, - }); + await ctx.db.$transaction!(async (db) => { + const [candidate, worldState] = await Promise.all([ + db.general.findUnique({ + where: { id: input.generalId }, + select: { npcState: true, meta: true }, + }), + db.worldState.findFirst({ + select: { currentYear: true, currentMonth: true }, + }), + ]); + if (!candidate || candidate.npcState < 2 || !worldState) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '빙의 가능한 장수를 찾지 못했습니다.', + }); + } - if (updated.count === 0) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: '빙의 가능한 장수를 찾지 못했습니다.', + const now = new Date(); + const updated = await db.general.updateMany({ + where: { + id: input.generalId, + userId: null, + npcState: candidate.npcState, + }, + data: { + userId, + npcState: 1, + meta: { + ...asRecord(candidate.meta), + npc_org: candidate.npcState, + owner_name: ctx.auth?.user.displayName ?? '', + pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1, + killturn: 6, + defence_train: 80, + }, + updatedAt: now, + }, }); - } + if (updated.count === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '빙의 가능한 장수를 찾지 못했습니다.', + }); + } + await db.generalAccessLog.upsert({ + where: { generalId: input.generalId }, + update: { + userId, + lastRefresh: now, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }, + create: { + generalId: input.generalId, + userId, + lastRefresh: now, + }, + }); + }); return { ok: true }; }), diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bba692c..177e333 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -28,6 +28,7 @@ import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; +import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -119,6 +120,7 @@ const buildRankRows = ( const buildGeneralUpdate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralUpdateInput => ({ + userId: general.userId ?? null, name: general.name, nationId: general.nationId, cityId: general.cityId, @@ -236,6 +238,7 @@ const buildNationUpdate = ( name: nation.name, color: nation.color, capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, gold: nation.gold, rice: nation.rice, level: nation.level, @@ -336,6 +339,7 @@ export const createDatabaseTurnHooks = async ( createdNations, createdTroops, createdDiplomacy, + lifecycleEvents, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -354,6 +358,12 @@ export const createDatabaseTurnHooks = async ( const meta = asRecord(state.meta); const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; + await persistGeneralLifecycleEvents( + prisma, + lifecycleEvents, + meta, + asRecord(world.getScenarioConfig().const) + ); if (deletedNationSnapshots.length > 0) { const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id); @@ -466,6 +476,9 @@ export const createDatabaseTurnHooks = async ( } if (deletedGenerals.length > 0) { + await prisma.generalTurn.deleteMany({ + where: { generalId: { in: deletedGenerals } }, + }); await prisma.general.deleteMany({ where: { id: { in: deletedGenerals } }, }); diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts new file mode 100644 index 0000000..a58cfb5 --- /dev/null +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -0,0 +1,320 @@ +import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; +import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; + +import type { GeneralLifecycleEvent } from './inMemoryWorld.js'; + +const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; + +const readNumber = (record: Record, key: string): number => { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +}; + +const readWorldNumber = (record: Record, key: string, fallback: number): number => { + const value = readNumber(record, key); + return value === 0 && record[key] === undefined ? fallback : Math.floor(value); +}; + +const computeDexPoint = (meta: Record): number => { + let total = 0; + for (let dex = 1; dex <= 5; dex += 1) { + total += readNumber(meta, `dex${dex}`); + } + return total * 0.001; +}; + +const settleInheritance = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent, + worldMeta: Record, + isRebirth: boolean, + configConst: Record +): Promise => { + const userId = event.before.userId; + if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) { + return; + } + const meta = asRecord(event.before.meta); + if (event.before.npcState === 1) { + const pickYearMonth = readNumber(meta, 'pickYearMonth'); + if (pickYearMonth === 0 && meta.pickYearMonth === undefined) { + return; + } + const pickYear = Math.floor(pickYearMonth / 12); + const scenarioMeta = asRecord(worldMeta.scenarioMeta); + const startYear = readWorldNumber( + worldMeta, + 'startYear', + readWorldNumber(worldMeta, 'startyear', readWorldNumber(scenarioMeta, 'startYear', event.year)) + ); + if ((event.year - pickYear) * 2 <= event.year - startYear) { + return; + } + } + + const [rows, rankRows] = await Promise.all([ + prisma.inheritancePoint.findMany({ + where: { userId }, + select: { key: true, value: true }, + }), + prisma.rankData.findMany({ + where: { generalId: event.generalId }, + select: { type: true, value: true }, + }), + ]); + const points = new Map(rows.map((row) => [row.key, row.value])); + const ranks = new Map(rankRows.map((row) => [row.type, row.value])); + const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`); + const previous = points.get('previous') ?? 0; + const refund = + (meta.inheritRandomUnique + ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) + : 0) + + (meta.inheritSpecificSpecialWar + ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) + : 0); + const lived = readNumber(meta, 'inherit_lived_month'); + const maxBelong = readNumber(meta, 'inherit_max_belong') * 10; + const maxDomestic = readNumber(meta, 'max_domestic_critical'); + const active = readNumber(meta, 'inherit_active_action') * 3; + const combat = rank('warnum') * 5; + const sabotage = (ranks.get('firenum') ?? readNumber(meta, 'firenum')) * 20; + const dex = computeDexPoint(meta); + const unifier = points.get('unifier') ?? 0; + const earned = isRebirth + ? lived + active + combat + sabotage + dex * 0.5 + : lived + maxBelong + maxDomestic + active + combat + sabotage + dex + unifier; + const total = Math.trunc(previous + refund + earned); + + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId, key: 'previous' } }, + update: { value: total }, + create: { userId, key: 'previous', value: total }, + }); + await prisma.inheritancePoint.deleteMany({ + where: { + userId, + key: isRebirth ? { notIn: ['previous', 'unifier'] } : { not: 'previous' }, + }, + }); + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + await prisma.inheritanceResult.create({ + data: { + serverId, + owner: userId, + generalId: event.generalId, + year: event.year, + month: event.month, + value: asJson({ + previous, + refund, + lived_month: lived, + max_belong: maxBelong, + max_domestic_critical: maxDomestic, + active_action: active, + combat, + sabotage, + dex: isRebirth ? dex * 0.5 : dex, + unifier: isRebirth ? 0 : unifier, + rebirth: isRebirth, + }), + }, + }); + await prisma.inheritanceLog.create({ + data: { + userId, + year: event.year, + month: event.month, + text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`, + }, + }); +}; + +const computeRate = (numerator: number, denominator: number): number => + denominator > 0 ? numerator / denominator : 0; + +const settleHall = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent, + worldMeta: Record +): Promise => { + const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0)); + if (isUnited !== 0) { + return; + } + const [ranks, nation, historyCount] = await Promise.all([ + prisma.rankData.findMany({ + where: { generalId: event.generalId }, + select: { type: true, value: true }, + }), + event.before.nationId > 0 + ? prisma.nation.findUnique({ + where: { id: event.before.nationId }, + select: { name: true, color: true }, + }) + : null, + prisma.gameHistory.count(), + ]); + const rank = new Map(ranks.map((row) => [row.type, row.value])); + const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key); + const warnum = value('warnum'); + const tt = value('ttw') + value('ttd') + value('ttl'); + const tl = value('tlw') + value('tld') + value('tll'); + const ts = value('tsw') + value('tsd') + value('tsl'); + const ti = value('tiw') + value('tid') + value('til'); + const calc: Record = { + winrate: computeRate(value('killnum'), warnum), + killrate: computeRate(value('killcrew'), Math.max(1, value('deathcrew'))), + killrate_person: computeRate(value('killcrew_person'), Math.max(1, value('deathcrew_person'))), + ttrate: computeRate(value('ttw'), Math.max(1, tt)), + tlrate: computeRate(value('tlw'), Math.max(1, tl)), + tsrate: computeRate(value('tsw'), Math.max(1, ts)), + tirate: computeRate(value('tiw'), Math.max(1, ti)), + betrate: computeRate(value('betwingold'), Math.max(1, value('betgold'))), + }; + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + const season = readWorldNumber(worldMeta, 'season', 1); + const scenario = readWorldNumber(worldMeta, 'scenarioId', 0); + const scenarioName = + typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : ''; + const aux = { + name: event.before.name, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? '#000000', + fgColor: nation?.color ?? '#000000', + startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null, + unitedTime: new Date().toISOString(), + ownerName: event.before.userId ?? null, + serverID: serverId, + serverIdx: historyCount, + scenarioName, + }; + + for (const type of HALL_OF_FAME_TYPES) { + let hallValue = + type === 'experience' + ? event.before.experience + : type === 'dedication' + ? event.before.dedication + : type.endsWith('rate') + ? (calc[type] ?? 0) + : value(type); + if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue; + if (type === 'ttrate' && tt < 50) continue; + if (type === 'tlrate' && tl < 50) continue; + if (type === 'tsrate' && ts < 50) continue; + if (type === 'tirate' && ti < 50) continue; + if (type === 'betrate' && value('betgold') < 1000) continue; + if (!Number.isFinite(hallValue) || hallValue <= 0) continue; + hallValue = Number(hallValue); + + const existing = await prisma.hallOfFame.findUnique({ + where: { + serverId_type_generalNo: { + serverId, + type: type as HallOfFameType, + generalNo: event.generalId, + }, + }, + }); + if (existing) { + if (hallValue > existing.value) { + await prisma.hallOfFame.update({ + where: { id: existing.id }, + data: { value: hallValue, aux: asJson(aux) }, + }); + } + continue; + } + await prisma.hallOfFame.createMany({ + data: [ + { + serverId, + season, + scenario, + generalNo: event.generalId, + type, + value: hallValue, + owner: event.before.userId ?? null, + aux: asJson(aux), + }, + ], + skipDuplicates: true, + }); + } +}; + +const archiveDeletedGeneral = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent, + worldMeta: Record +): Promise => { + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + const data = { + ...event.before, + turnTime: event.before.turnTime.toISOString(), + recentWarTime: event.before.recentWarTime?.toISOString() ?? null, + }; + await prisma.oldGeneral.upsert({ + where: { by_no: { serverId, generalNo: event.generalId } }, + update: { + owner: event.before.userId ?? null, + name: event.before.name, + lastYearMonth: event.year * 100 + event.month, + turnTime: event.before.turnTime, + data: asJson(data), + }, + create: { + serverId, + generalNo: event.generalId, + owner: event.before.userId ?? null, + name: event.before.name, + lastYearMonth: event.year * 100 + event.month, + turnTime: event.before.turnTime, + data: asJson(data), + }, + }); +}; + +export const persistGeneralLifecycleEvents = async ( + prisma: GamePrisma.TransactionClient, + events: GeneralLifecycleEvent[], + worldMeta: Record, + configConst: Record +): Promise => { + if (events.length === 0) { + return; + } + await prisma.generalAccessLog.updateMany({ + where: { generalId: { in: events.map((event) => event.generalId) } }, + data: { refreshScore: 0 }, + }); + + for (const event of events) { + if (event.outcome === 'detached' || event.outcome === 'deleted') { + await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); + } + if (event.outcome === 'deleted') { + await archiveDeletedGeneral(prisma, event, worldMeta); + await settleInheritance(prisma, event, worldMeta, false, configConst); + } + if (event.outcome === 'retired') { + await settleHall(prisma, event, worldMeta); + await settleInheritance(prisma, event, worldMeta, true, configConst); + await prisma.rankData.updateMany({ + where: { generalId: event.generalId }, + data: { value: 0 }, + }); + } + } +}; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bdf02a3..0e33908 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -42,6 +42,20 @@ export interface GeneralTurnResult { nations?: Nation[]; troops?: Troop[]; }; + deleted?: { + general: boolean; + troopIds?: number[]; + }; + lifecycleEvent?: GeneralLifecycleEvent; +} + +export interface GeneralLifecycleEvent { + generalId: number; + outcome: 'active' | 'detached' | 'deleted' | 'retired'; + before: TurnGeneral; + after?: TurnGeneral; + year: number; + month: number; } export interface GeneralTurnHandler { @@ -85,6 +99,7 @@ export interface TurnWorldChanges { createdNations: Nation[]; createdTroops: Troop[]; createdDiplomacy: TurnDiplomacy[]; + lifecycleEvents: GeneralLifecycleEvent[]; } const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { @@ -250,6 +265,7 @@ export class InMemoryTurnWorld { }> = []; private readonly logs: LogEntryDraft[] = []; private readonly messages: MessageDraft[] = []; + private readonly lifecycleEvents: GeneralLifecycleEvent[] = []; private readonly scenarioConfig: ScenarioConfig; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -572,12 +588,14 @@ export class InMemoryTurnWorld { }); const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); - const nextGeneral = { - ...(result.general ?? currentGeneral), - turnTime: nextTurnAt, - }; - this.generals.set(nextGeneral.id, nextGeneral); - this.dirtyGeneralIds.add(nextGeneral.id); + if (!result.deleted?.general) { + const nextGeneral = { + ...(result.general ?? currentGeneral), + turnTime: nextTurnAt, + }; + this.generals.set(nextGeneral.id, nextGeneral); + this.dirtyGeneralIds.add(nextGeneral.id); + } if (result.city) { this.cities.set(result.city.id, result.city); @@ -675,6 +693,17 @@ export class InMemoryTurnWorld { } } } + if (result.deleted?.troopIds) { + for (const troopId of result.deleted.troopIds) { + this.removeTroop(troopId); + } + } + if (result.deleted?.general) { + this.removeGeneral(currentGeneral.id); + } + if (result.lifecycleEvent) { + this.lifecycleEvents.push(result.lifecycleEvent); + } this.removeCollapsedNations(); @@ -751,6 +780,7 @@ export class InMemoryTurnWorld { const deletedNationSnapshots = this.deletedNationSnapshots.slice(); const logs = this.logs.slice(); const messages = this.messages.slice(); + const lifecycleEvents = this.lifecycleEvents.slice(); return { generals, @@ -768,6 +798,7 @@ export class InMemoryTurnWorld { createdNations, createdTroops, createdDiplomacy, + lifecycleEvents, }; } @@ -791,6 +822,7 @@ export class InMemoryTurnWorld { this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length); this.logs.splice(0, changes.logs.length); this.messages.splice(0, changes.messages.length); + this.lifecycleEvents.splice(0, changes.lifecycleEvents.length); } consumeDirtyState(): TurnWorldChanges { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 22eb6b5..ad80532 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -17,7 +17,9 @@ import type { import { DEFAULT_TURN_COMMAND_PROFILE, GeneralTurnCommandLoader, + GeneralActionPipeline, NationTurnCommandLoader, + createGeneralTriggerContext, defaultActionContextBuilder, evaluateConstraints, resolveGeneralAction, @@ -28,6 +30,7 @@ import { loadItemModules, resolveUniqueConfig, rollUniqueLottery, + getNextTurnAt, type ItemModule, type UniqueLotteryRunner, } from '@sammo-ts/logic'; @@ -101,6 +104,90 @@ const serializeSeed = (...values: Array): string => const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; +const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number): number => { + const value = asRecord(config.const)[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +}; + +const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ + ...general, + stats: { ...general.stats }, + role: { + ...general.role, + items: { ...general.role.items }, + }, + meta: { ...general.meta }, + triggerState: { + ...general.triggerState, + flags: { ...general.triggerState.flags }, + counters: { ...general.triggerState.counters }, + modifiers: { ...general.triggerState.modifiers }, + meta: { ...general.triggerState.meta }, + }, +}); + +const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => { + const meta = { ...general.meta }; + for (const key of [ + 'firenum', + 'rank_warnum', + 'rank_killnum', + 'rank_deathnum', + 'rank_occupied', + 'rank_killcrew', + 'rank_deathcrew', + 'rank_killcrew_person', + 'rank_deathcrew_person', + 'rank_ttw', + 'rank_ttd', + 'rank_ttl', + 'rank_ttg', + 'rank_ttp', + 'rank_tlw', + 'rank_tld', + 'rank_tll', + 'rank_tlg', + 'rank_tlp', + 'rank_tsw', + 'rank_tsd', + 'rank_tsl', + 'rank_tsg', + 'rank_tsp', + 'rank_tiw', + 'rank_tid', + 'rank_til', + 'rank_tig', + 'rank_tip', + 'rank_betgold', + 'rank_betwin', + 'rank_betwingold', + 'specage', + 'specage2', + ]) { + meta[key] = 0; + } + for (let dex = 1; dex <= 5; dex += 1) { + const key = `dex${dex}`; + meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5); + } + meta.inherit_lived_month = 0; + meta.inherit_active_action = 0; + + return { + ...general, + stats: { + leadership: Math.max(10, Math.round(general.stats.leadership * 0.85)), + strength: Math.max(10, Math.round(general.stats.strength * 0.85)), + intelligence: Math.max(10, Math.round(general.stats.intelligence * 0.85)), + }, + injury: 0, + experience: Math.round(general.experience * 0.5), + dedication: Math.round(general.dedication * 0.5), + age: 20, + meta, + }; +}; + type NationLastTurn = { command: string; arg?: Record; @@ -959,12 +1046,108 @@ export const createReservedTurnHandler = async (options: { }; }; - if (currentNation && currentGeneral.officerLevel >= 5) { + const lifecycleBefore = cloneTurnGeneral(currentGeneral); + currentGeneral = cloneTurnGeneral(currentGeneral); + currentGeneral.meta.inherit_lived_month = + readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1; + + const preprocessRng = new RandUtil( + new LiteHashDRBG( + serializeSeed( + buildSeedBase(context.world), + 'preprocess', + context.world.currentYear, + context.world.currentMonth, + currentGeneral.id + ) + ) + ); + const cityGeneralCopies = new Map(); + for (const general of worldView?.listGenerals() ?? []) { + cityGeneralCopies.set( + general.id, + general.id === currentGeneral.id ? currentGeneral : cloneTurnGeneral(general) + ); + } + cityGeneralCopies.set(currentGeneral.id, currentGeneral); + const preTurnPipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + const preTurnContext = createGeneralTriggerContext({ + general: currentGeneral, + nation: currentNation, + worldView: { + listGenerals: () => Array.from(cityGeneralCopies.values()), + listGeneralsByCity: (cityId) => + Array.from(cityGeneralCopies.values()).filter((general) => general.cityId === cityId), + }, + rng: preprocessRng, + log: { + push: (message) => logs.push(createActionLog(message)), + }, + }); + preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); + if (currentGeneral.injury > 0 && !preTurnContext.skill.has('pre.부상경감')) { + currentGeneral.injury = Math.max(0, currentGeneral.injury - 10); + preTurnContext.skill.activate('pre.부상경감'); + } + if (currentGeneral.crew >= 100) { + const consumeRice = Math.trunc(currentGeneral.crew / 100); + if (consumeRice <= currentGeneral.rice) { + currentGeneral.rice -= consumeRice; + } else { + const releasedCrew = Math.trunc( + preTurnPipeline.onCalcDomestic(preTurnContext, '징집인구', 'score', currentGeneral.crew) + ); + if (currentCity) { + currentCity = { + ...currentCity, + population: currentCity.population + releasedCrew, + meta: { ...currentCity.meta }, + }; + worldOverlay?.syncCity(currentCity); + } + currentGeneral.crew = 0; + currentGeneral.rice = 0; + logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); + preTurnContext.skill.activate('pre.소집해제'); + } + preTurnContext.skill.activate('pre.병력군량소모'); + } + for (const [generalId, next] of cityGeneralCopies) { + if (generalId === currentGeneral.id) { + continue; + } + const previous = worldView?.getGeneralById(generalId); + if (!previous || previous.injury === next.injury) { + continue; + } + patches.generals.push({ id: generalId, patch: { injury: next.injury } }); + worldOverlay?.applyGeneralPatch(generalId, { injury: next.injury }); + } + worldOverlay?.syncGeneral(currentGeneral); + + const blockCode = readMetaNumber(currentGeneral.meta, 'block', 0); + const isBlocked = blockCode === 2 || blockCode === 3; + if (isBlocked) { + currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1); + logs.push( + createActionLog( + blockCode === 2 + ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' + : '현재 악성유저로 분류되어 블럭 대상자입니다.' + ) + ); + } + + let hasReservedTurn = false; + if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) { let nationCommand = options.reservedTurns.getNationTurn( currentNation.id, currentGeneral.officerLevel, 0 ); + if (nationCommand.action !== DEFAULT_ACTION) { + hasReservedTurn = true; + } let nationAiState: ReturnType | undefined; if (worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ @@ -1003,10 +1186,17 @@ export const createReservedTurnHandler = async (options: { }); options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } + if (isBlocked && currentNation && currentGeneral.officerLevel >= 5) { + options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); + } let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0); + if (!isBlocked && generalCommand.action !== DEFAULT_ACTION) { + hasReservedTurn = true; + } let generalAiState: ReturnType | undefined; - if (worldView && shouldUseAi(currentGeneral, context.world)) { + let generalAutorunMode = false; + if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ general: currentGeneral, city: currentCity, @@ -1026,11 +1216,20 @@ export const createReservedTurnHandler = async (options: { }); const candidate = ai.chooseGeneralTurn(generalCommand); if (candidate) { + generalAutorunMode = + candidate.action !== generalCommand.action || + JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {}); generalCommand = { action: candidate.action, args: candidate.args }; } generalAiState = ai.getDebugState(); } - const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true); + const generalResult = isBlocked + ? { + actionKey: DEFAULT_ACTION, + usedFallback: true, + blockedReason: '블럭 대상자입니다.', + } + : runAction('general', generalDefinitions, generalFallback, generalCommand, true); options.onActionResolved?.({ kind: 'general', generalId: currentGeneral.id, @@ -1041,15 +1240,25 @@ export const createReservedTurnHandler = async (options: { ...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}), ...(generalAiState ? { aiState: generalAiState } : {}), }); - const nextTurnAt = generalResult.nextTurnAt; + let nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); const worldMeta = asRecord(context.world.meta); - if (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) { + if (!isBlocked) { const meta = { ...currentGeneral.meta }; - const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0; + const currentKillturn = readMetaNumber(meta, 'killturn', 0); + const worldKillturn = readMetaNumber(worldMeta, 'killturn', currentKillturn); + if ( + currentGeneral.npcState >= 2 || + currentKillturn > worldKillturn || + generalAutorunMode || + generalCommand.action === DEFAULT_ACTION + ) { + meta.killturn = Math.max(0, currentKillturn - 1); + } else { + meta.killturn = worldKillturn; + } const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; - meta.inherit_lived_month = lived + 1; if (generalResult.actionKey !== DEFAULT_ACTION) { meta.inherit_active_action = active + 1; } else { @@ -1059,6 +1268,176 @@ export const createReservedTurnHandler = async (options: { worldOverlay?.syncGeneral(currentGeneral); } + const incDefSettingChange = readConfigNumber(options.scenarioConfig, 'incDefSettingChange', 3); + const maxDefSettingChange = readConfigNumber(options.scenarioConfig, 'maxDefSettingChange', 9); + currentGeneral = { + ...currentGeneral, + meta: { + ...currentGeneral.meta, + myset: Math.min( + maxDefSettingChange, + readMetaNumber(currentGeneral.meta, 'myset', 0) + incDefSettingChange + ), + }, + }; + + const autorunUser = asRecord(worldMeta.autorun_user); + const autorunLimitMinutes = readMetaNumber(autorunUser, 'limit_minutes', 0); + if (hasReservedTurn && currentGeneral.npcState < 2 && autorunLimitMinutes > 0) { + const turnMinutes = Math.max(1, Math.round(context.world.tickSeconds / 60)); + currentGeneral.meta.autorun_limit = + joinYearMonth(context.world.currentYear, context.world.currentMonth) + + Math.trunc(autorunLimitMinutes / turnMinutes); + } + + const nextTurnTimeBase = readMetaNumber(currentGeneral.meta, 'nextTurnTimeBase', -1); + if (nextTurnTimeBase >= 0) { + const alignedNextTurn = nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, context.schedule); + nextTurnAt = new Date(alignedNextTurn.getTime() + nextTurnTimeBase * 1000); + delete currentGeneral.meta.nextTurnTimeBase; + } + + let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active'; + let deleteGeneral = false; + const deletedTroopIds: number[] = []; + const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); + if (currentGeneral.meta.killturn <= 0) { + if ( + currentGeneral.npcState === 1 && + typeof currentGeneral.deadYear === 'number' && + currentGeneral.deadYear > context.world.currentYear + ) { + const npcOrg = readMetaNumber(currentGeneral.meta, 'npc_org', 2); + const ownerName = + typeof currentGeneral.meta.owner_name === 'string' + ? currentGeneral.meta.owner_name + : currentGeneral.userId; + logs.push( + createActionLog( + `${ownerName ?? '사용자'}이 ${currentGeneral.name}의 육체에서 유체이탈합니다!` + ) + ); + currentGeneral = { + ...currentGeneral, + userId: null, + npcState: npcOrg, + meta: { + ...currentGeneral.meta, + killturn: (currentGeneral.deadYear - context.world.currentYear) * 12, + defence_train: 80, + owner_name: '', + }, + }; + lifecycleOutcome = 'detached'; + } else { + if (currentGeneral.officerLevel === 12 && currentNation && worldView) { + const candidates = worldView + .listGenerals() + .filter( + (candidate) => + candidate.id !== currentGeneral.id && + candidate.nationId === currentGeneral.nationId && + candidate.officerLevel !== 12 && + candidate.npcState !== 5 + ); + let successor: TurnGeneral | undefined; + const fiction = readMetaNumber(worldMeta, 'fiction', 0); + if ( + fiction === 0 && + currentGeneral.npcState > 0 && + typeof currentGeneral.affinity === 'number' + ) { + const npcCandidates = candidates.filter( + (candidate) => + candidate.npcState >= 1 && + candidate.npcState <= 3 && + typeof candidate.affinity === 'number' + ); + const affinityDistance = (candidate: TurnGeneral): number => { + const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0)); + return distance > 75 ? 150 - distance : distance; + }; + const minDistance = Math.min(...npcCandidates.map(affinityDistance)); + const nearest = npcCandidates.filter( + (candidate) => affinityDistance(candidate) === minDistance + ); + if (nearest.length > 0) { + const rng = new RandUtil( + new LiteHashDRBG( + serializeSeed( + buildSeedBase(context.world), + 'NextNPCRuler', + context.world.currentYear, + context.world.currentMonth, + currentGeneral.id + ) + ) + ); + successor = rng.choice(nearest); + } + } + successor ??= candidates + .filter((candidate) => candidate.officerLevel >= 9) + .sort((left, right) => right.officerLevel - left.officerLevel || left.id - right.id)[0]; + successor ??= candidates.sort( + (left, right) => right.dedication - left.dedication || left.id - right.id + )[0]; + if (successor) { + patches.generals.push({ + id: successor.id, + patch: { officerLevel: 12 }, + }); + currentNation = { + ...currentNation, + chiefGeneralId: successor.id, + }; + logs.push( + createActionLog( + `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` + ) + ); + } + } + if (currentGeneral.troopId === currentGeneral.id) { + deletedTroopIds.push(currentGeneral.id); + for (const member of worldView?.listGenerals() ?? []) { + if (member.id !== currentGeneral.id && member.troopId === currentGeneral.id) { + patches.generals.push({ id: member.id, patch: { troopId: 0 } }); + } + } + } + if (currentNation) { + const gennum = readMetaNumber(asRecord(currentNation.meta), 'gennum', 0); + currentNation = { + ...currentNation, + meta: { + ...currentNation.meta, + gennum: Math.max(0, gennum - 1), + }, + }; + } + deleteGeneral = true; + lifecycleOutcome = 'deleted'; + } + } + + const retirementYear = readConfigNumber(options.scenarioConfig, 'retirementYear', 80); + if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) { + currentGeneral = resetRetiredGeneral(currentGeneral); + lifecycleOutcome = 'retired'; + logs.push( + createActionLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.') + ); + } + + currentGeneral = { + ...currentGeneral, + triggerState: { + ...currentGeneral.triggerState, + flags: {}, + }, + }; + const result: GeneralTurnResult = { general: currentGeneral, city: currentCity, @@ -1075,6 +1454,22 @@ export const createReservedTurnHandler = async (options: { ...(createdNations.length > 0 ? { nations: createdNations } : {}), } : undefined, + ...(deleteGeneral + ? { + deleted: { + general: true, + ...(deletedTroopIds.length > 0 ? { troopIds: deletedTroopIds } : {}), + }, + } + : undefined), + lifecycleEvent: { + generalId: currentGeneral.id, + outcome: lifecycleOutcome, + before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot, + ...(deleteGeneral ? {} : { after: currentGeneral }), + year: context.world.currentYear, + month: context.world.currentMonth, + }, }; return result; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 499e80f..5905ca5 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -20,6 +20,10 @@ export interface TurnWorldState { } export interface TurnGeneral extends General { + userId?: string | null; + bornYear?: number; + deadYear?: number; + affinity?: number | null; turnTime: Date; recentWarTime?: Date | null; } diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 5fa9177..79b690d 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -161,6 +161,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { return { meta: { ...meta, killturn } as TurnGeneral['meta'] }; })(), id: row.id, + userId: row.userId, name: row.name, nationId: row.nationId, cityId: row.cityId, @@ -188,6 +189,9 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { atmos: row.atmos, age: row.age, npcState: row.npcState, + bornYear: row.bornYear, + deadYear: row.deadYear, + affinity: row.affinity, triggerState: { flags: {}, counters: {}, diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts new file mode 100644 index 0000000..33c2b4e --- /dev/null +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import type { TurnSchedule } from '@sammo-ts/logic'; + +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnTestHarness } from './helpers/turnTestHarness.js'; + +const start = new Date('0200-01-01T00:00:00.000Z'); +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const map = { + id: 'general-lifecycle', + name: '장수 lifecycle', + cities: [ + { + id: 1, + name: '테스트성', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: { + population: 50_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + }, + initial: { + population: 10_000, + agriculture: 500, + commerce: 500, + security: 500, + defence: 500, + wall: 500, + }, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const makeGeneral = (patch: Partial = {}): TurnGeneral => ({ + id: 1, + userId: 'user-1', + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 100, + dedication: 80, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 2_000, + rice: 2_000, + crew: 0, + crewTypeId: 1, + train: 40, + atmos: 40, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 260, + affinity: 50, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + turnTime: start, + ...patch, +}); + +const makeSnapshot = (generals: TurnGeneral[]): TurnWorldSnapshot => ({ + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + develCost: 100, + trainDelta: 30, + atmosDelta: 30, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + initialNationGenLimit: 10, + incDefSettingChange: 3, + maxDefSettingChange: 9, + retirementYear: 80, + }, + environment: { mapName: map.id, unitSet: 'test' }, + }, + scenarioMeta: { + title: '장수 lifecycle', + startYear: 200, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map, + unitSet: { id: 'test', name: 'test', crewTypes: [] }, + nations: [ + { + id: 1, + name: '테스트국', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { gennum: generals.length, tech: 0 }, + }, + ], + cities: [ + { + id: 1, + name: '테스트성', + nationId: 1, + level: 1, + state: 0, + population: 10_000, + populationMax: 50_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: { trust: 50, trade: 100, region: 1 }, + }, + ], + generals, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], +}); + +const makeState = (meta: Record = {}): TurnWorldState => ({ + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: start, + meta: { killturn: 24, ...meta }, +}); + +describe('legacy general turn lifecycle', () => { + it('runs preprocess before commands and restores crew population when rice is insufficient', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ injury: 25, crew: 200, rice: 1 })]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + const general = harness.world.getGeneralById(1)!; + expect(general.injury).toBe(15); + expect(general.crew).toBe(0); + expect(general.rice).toBe(0); + expect(general.meta.inherit_lived_month).toBe(1); + expect(harness.world.getCityById(1)!.population).toBe(10_200); + }); + + it('skips blocked commands, decreases killturn once, and advances the queue', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ injury: 20, meta: { killturn: 5, block: 3 } })]), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} }; + + await harness.runOneTick(); + + const general = harness.world.getGeneralById(1)!; + expect(general.injury).toBe(10); + expect(general.experience).toBe(100); + expect(general.meta.killturn).toBe(4); + expect(general.meta.myset).toBe(3); + expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식'); + }); + + it('updates killturn, autorun_limit, myset, and nextTurnTimeBase with legacy branches', async () => { + const general = makeGeneral({ + injury: 20, + meta: { killturn: 3, myset: 8, nextTurnTimeBase: 30 }, + }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([general]), + state: makeState({ autorun_user: { limit_minutes: 60, options: {} } }), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} }; + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.meta.killturn).toBe(24); + expect(updated.meta.myset).toBe(9); + expect(updated.meta.autorun_limit).toBe(2406); + expect(updated.meta.nextTurnTimeBase).toBeUndefined(); + expect(updated.turnTime.toISOString()).toBe('0200-01-01T00:10:30.000Z'); + }); + + it('detaches an expired possessed NPC instead of deleting its body', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + npcState: 1, + deadYear: 205, + meta: { killturn: 1, npc_org: 3, owner_name: '소유자' }, + }), + ]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.userId).toBeNull(); + expect(updated.npcState).toBe(3); + expect(updated.meta.killturn).toBe(60); + expect(updated.meta.defence_train).toBe(80); + expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('detached'); + }); + + it('deletes a zero-killturn general, clears its troop, and appoints a successor', async () => { + const leader = makeGeneral({ + troopId: 1, + officerLevel: 12, + meta: { killturn: 1 }, + }); + const successor = makeGeneral({ + id: 2, + userId: 'user-2', + name: '후계자', + troopId: 1, + officerLevel: 9, + dedication: 200, + meta: { killturn: 24 }, + }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([leader, successor]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + expect(harness.world.getGeneralById(1)).toBeNull(); + expect(harness.world.getGeneralById(2)!.troopId).toBe(0); + expect(harness.world.getGeneralById(2)!.officerLevel).toBe(12); + expect(harness.world.getNationById(1)!.chiefGeneralId).toBe(2); + expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); + }); + + it('retires a player general and resets inherited stats and rank state', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + age: 80, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 101, + dedication: 81, + meta: { + killturn: 24, + dex1: 101, + inherit_lived_month: 10, + inherit_active_action: 4, + rank_warnum: 12, + }, + }), + ]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.age).toBe(20); + expect(updated.stats).toEqual({ leadership: 68, strength: 60, intelligence: 51 }); + expect(updated.experience).toBe(51); + expect(updated.dedication).toBe(41); + expect(updated.meta.dex1).toBe(51); + expect(updated.meta.inherit_lived_month).toBe(0); + expect(updated.meta.inherit_active_action).toBe(0); + expect(updated.meta.rank_warnum).toBe(0); + expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired'); + }); +}); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts new file mode 100644 index 0000000..c9a3c4f --- /dev/null +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -0,0 +1,216 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js'; +import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js'; +import type { TurnGeneral } from '../src/turn/types.js'; + +const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalIds = [990_001, 990_002, 990_003]; +const userIds = [ + 'integration-lifecycle-dead', + 'integration-lifecycle-retired', + 'integration-lifecycle-possessed', +]; +const serverId = 'integration-lifecycle'; + +const makeGeneral = (id: number, userId: string, patch: Partial = {}): TurnGeneral => ({ + id, + userId, + name: `lifecycle-${id}`, + nationId: 0, + cityId: 0, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 1_000, + dedication: 800, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1, + train: 0, + atmos: 0, + age: 80, + npcState: 0, + bornYear: 170, + deadYear: 260, + affinity: 50, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + dex1: 1_000, + }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + ...patch, +}); + +const event = ( + general: TurnGeneral, + outcome: GeneralLifecycleEvent['outcome'] +): GeneralLifecycleEvent => ({ + generalId: general.id, + outcome, + before: general, + ...(outcome === 'deleted' ? {} : { after: general }), + year: 200, + month: 1, +}); + +integration('general turn lifecycle persistence', () => { + let db: GamePrismaClient; + let close: (() => Promise) | undefined; + + const cleanup = async () => { + await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); + await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); + await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await close?.(); + }); + + it('archives deletion, removes access state, and settles inheritance', async () => { + const general = makeGeneral(generalIds[0]!, userIds[0]!, { + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + inheritRandomUnique: true, + dex1: 1_000, + }, + }); + await db.generalAccessLog.create({ + data: { generalId: general.id, userId: general.userId, refreshScore: 99 }, + }); + await db.inheritancePoint.create({ + data: { userId: general.userId!, key: 'previous', value: 100 }, + }); + await db.rankData.createMany({ + data: [ + { generalId: general.id, nationId: 0, type: 'warnum', value: 2 }, + { generalId: general.id, nationId: 0, type: 'firenum', value: 1 }, + ], + }); + + await db.$transaction((tx) => + persistGeneralLifecycleEvents( + tx, + [event(general, 'deleted')], + { serverId }, + { inheritItemRandomPoint: 3_000, inheritSpecificSpecialPoint: 4_000 } + ) + ); + + expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull(); + expect(await db.oldGeneral.findUnique({ where: { by_no: { serverId, generalNo: general.id } } })).not.toBeNull(); + expect( + await db.inheritancePoint.findUnique({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).toMatchObject({ value: 3_147 }); + }); + + it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => { + const general = makeGeneral(generalIds[1]!, userIds[1]!); + await db.generalAccessLog.create({ + data: { generalId: general.id, userId: general.userId, refreshScore: 77 }, + }); + await db.inheritancePoint.create({ + data: { userId: general.userId!, key: 'previous', value: 50 }, + }); + await db.rankData.create({ + data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 }, + }); + + await db.$transaction((tx) => + persistGeneralLifecycleEvents( + tx, + [event(general, 'retired')], + { serverId, season: 1, scenarioId: 2, isUnited: 0 }, + {} + ) + ); + + expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({ + refreshScore: 0, + }); + expect(await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })) + .toMatchObject({ value: 0 }); + expect( + await db.hallOfFame.findUnique({ + where: { + serverId_type_generalNo: { + serverId, + type: 'experience', + generalNo: general.id, + }, + }, + }) + ).toMatchObject({ value: 1_000 }); + expect( + await db.inheritancePoint.findUnique({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).toMatchObject({ value: 116 }); + }); + + it('does not settle a possessed NPC before the legacy minimum possession period', async () => { + const general = makeGeneral(generalIds[2]!, userIds[2]!, { + npcState: 1, + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + pickYearMonth: 190 * 12, + }, + }); + await db.inheritancePoint.create({ + data: { userId: general.userId!, key: 'previous', value: 100 }, + }); + + await db.$transaction((tx) => + persistGeneralLifecycleEvents( + tx, + [event(general, 'deleted')], + { serverId, startYear: 180 }, + {} + ) + ); + + expect( + await db.inheritancePoint.findUnique({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).toMatchObject({ value: 100 }); + expect( + await db.inheritanceResult.count({ + where: { serverId, owner: general.userId! }, + }) + ).toBe(0); + }); +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index c718f89..e0d46d0 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -179,6 +179,20 @@ model General { @@map("general") } +model GeneralAccessLog { + id Int @id @default(autoincrement()) + generalId Int @unique @map("general_id") + userId String? @map("user_id") + lastRefresh DateTime? @map("last_refresh") + refresh Int @default(0) + refreshTotal Int @default(0) @map("refresh_total") + refreshScore Int @default(0) @map("refresh_score") + refreshScoreTotal Int @default(0) @map("refresh_score_total") + + @@index([userId]) + @@map("general_access_log") +} + model RankData { id Int @id @default(autoincrement()) nationId Int @default(0) @map("nation_id") diff --git a/packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql b/packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql new file mode 100644 index 0000000..c1e9124 --- /dev/null +++ b/packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "general_access_log" ( + "id" SERIAL PRIMARY KEY, + "general_id" INTEGER NOT NULL, + "user_id" TEXT, + "last_refresh" TIMESTAMP(3), + "refresh" INTEGER NOT NULL DEFAULT 0, + "refresh_total" INTEGER NOT NULL DEFAULT 0, + "refresh_score" INTEGER NOT NULL DEFAULT 0, + "refresh_score_total" INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX "general_access_log_general_id_key" ON "general_access_log"("general_id"); +CREATE INDEX "general_access_log_user_id_idx" ON "general_access_log"("user_id"); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index d8aa337..3573074 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -6,6 +6,7 @@ export interface DatabaseClient { $executeRaw: GamePrismaClient['$executeRaw']; worldState: GamePrisma.WorldStateDelegate; general: GamePrisma.GeneralDelegate; + generalAccessLog: GamePrisma.GeneralAccessLogDelegate; city: GamePrisma.CityDelegate; nation: GamePrisma.NationDelegate; diplomacy: GamePrisma.DiplomacyDelegate; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index ffd2b44..74736c0 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -16,6 +16,7 @@ export interface TurnEngineWorldStateRow { export interface TurnEngineGeneralRow { id: number; + userId: string | null; name: string; nationId: number; cityId: number; @@ -42,6 +43,9 @@ export interface TurnEngineGeneralRow { atmos: number; age: number; npcState: number; + bornYear: number; + deadYear: number; + affinity: number | null; meta: JsonValue; turnTime: Date; recentWarTime: Date | null; @@ -140,6 +144,7 @@ export interface TurnEngineWorldStateCreateInput { } export interface TurnEngineGeneralUpdateInput { + userId: string | null; name: string; nationId: number; cityId: number; @@ -264,6 +269,7 @@ export interface TurnEngineNationUpdateInput { name: string; color: string; capitalCityId: number | null; + chiefGeneralId: number | null; gold: number; rice: number; level: number; From ae4cc31a16af27542d85b7f9395739151d78795b Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 08:44:44 +0000 Subject: [PATCH 2/2] feat: add secure troop management --- .gitignore | 2 + app/game-api/src/router/troop/index.ts | 285 ++++++-- app/game-api/test/troopRouter.test.ts | 243 +++++++ app/game-engine/src/turn/commandRegistry.ts | 47 ++ app/game-engine/src/turn/inMemoryWorld.ts | 12 + app/game-engine/src/turn/types.ts | 1 + .../src/turn/worldCommandHandler.ts | 172 +++++ app/game-engine/src/turn/worldLoader.ts | 1 + app/game-engine/test/troopManagement.test.ts | 224 ++++++ app/game-frontend/e2e/playwright.config.mjs | 34 + app/game-frontend/e2e/troop.spec.ts | 347 +++++++++ app/game-frontend/package.json | 1 + app/game-frontend/src/main.ts | 4 - app/game-frontend/src/router/index.ts | 10 + app/game-frontend/src/stores/session.ts | 5 + app/game-frontend/src/views/MainView.vue | 1 + app/game-frontend/src/views/TroopView.vue | 678 ++++++++++++++++++ app/game-frontend/tsconfig.json | 3 +- package.json | 1 + packages/common/src/turnDaemon/types.ts | 51 ++ packages/infra/src/turnEngineDb.ts | 1 + packages/logic/src/index.ts | 1 + packages/logic/src/troop/management.ts | 115 +++ pnpm-lock.yaml | 38 + pnpm-workspace.yaml | 3 + 25 files changed, 2232 insertions(+), 48 deletions(-) create mode 100644 app/game-api/test/troopRouter.test.ts create mode 100644 app/game-engine/test/troopManagement.test.ts create mode 100644 app/game-frontend/e2e/playwright.config.mjs create mode 100644 app/game-frontend/e2e/troop.spec.ts create mode 100644 app/game-frontend/src/views/TroopView.vue create mode 100644 packages/logic/src/troop/management.ts diff --git a/.gitignore b/.gitignore index 869af95..3beadec 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ docker-compose.override.yml .turbo/ *_errors.txt docs/image-storage.md +playwright-report/ +test-results/ diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index af5420d..3942c23 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -1,73 +1,272 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import type { TurnDaemonCommandResult } from '@sammo-ts/common'; +import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic'; + import { authedProcedure, router } from '../../trpc.js'; +import { getMyGeneral } from '../shared/general.js'; + +const troopNameSchema = z + .string() + .refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); + +const normalizeRequiredTroopName = (value: string): string => { + const name = normalizeTroopName(value); + if (!name) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '부대 이름이 없습니다.' }); + } + return name; +}; + +const assertCommandResult = ( + result: TurnDaemonCommandResult | null, + expectedType: T +): never => { + if (!result) { + throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' }); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Unexpected turn daemon response for ${expectedType}.`, + }); +}; export const troopRouter = router({ - join: authedProcedure + getList: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + if (me.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); + } + + const [nation, troops, generals, cities] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { id: true, name: true, meta: true }, + }), + ctx.db.troop.findMany({ + where: { nationId: me.nationId }, + select: { troopLeaderId: true, nationId: true, name: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + select: { + id: true, + name: true, + cityId: true, + troopId: true, + picture: true, + imageServer: true, + turnTime: true, + }, + }), + ctx.db.city.findMany({ + select: { id: true, name: true }, + }), + ]); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); + } + + const troopLeaderIds = troops.map((troop) => troop.troopLeaderId); + const turns = + troopLeaderIds.length === 0 + ? [] + : await ctx.db.generalTurn.findMany({ + where: { generalId: { in: troopLeaderIds } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }); + const cityNames = new Map(cities.map((city) => [city.id, city.name])); + const generalMap = new Map(generals.map((general) => [general.id, general])); + const reservedByLeader = new Map(); + for (const turn of turns) { + const list = reservedByLeader.get(turn.generalId) ?? []; + list.push(turn.actionCode); + reservedByLeader.set(turn.generalId, list); + } + + const mappedTroops = troops + .map((troop) => { + const leader = generalMap.get(troop.troopLeaderId); + return { + id: troop.troopLeaderId, + name: troop.name, + nationId: troop.nationId, + turnTime: leader?.turnTime.toISOString() ?? null, + reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [], + leader: leader + ? { + id: leader.id, + name: leader.name, + cityId: leader.cityId, + cityName: cityNames.get(leader.cityId) ?? '알 수 없음', + picture: leader.picture, + imageServer: leader.imageServer, + } + : null, + members: generals + .filter((general) => general.troopId === troop.troopLeaderId) + .map((general) => ({ + id: general.id, + name: general.name, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? '알 수 없음', + })), + }; + }) + .sort((left, right) => { + const timeOrder = (left.turnTime ?? '').localeCompare(right.turnTime ?? ''); + return timeOrder || left.id - right.id; + }); + + return { + nation: { id: nation.id, name: nation.name }, + me: { id: me.id, troopId: me.troopId }, + permission: resolveTroopSecretPermission(me, nation.meta, false), + troops: mappedTroops, + }; + }), + create: authedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const troopName = normalizeRequiredTroopName(input.troopName); + if (me.troopId !== 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '이미 부대에 소속되어 있습니다.', + }); + } + if (me.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '국가에 소속되어 있지 않습니다.', + }); + } + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopCreate', + generalId: me.id, + troopName, + }); + if (!result || result.type !== 'troopCreate') { + return assertCommandResult(result, 'troopCreate'); + } + if (!result.ok) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); + } + return { ok: true, troopId: result.troopId, troopName: result.troopName }; + }), + join: authedProcedure.input(z.object({ troopId: z.number().int().positive() })).mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopJoin', + generalId: me.id, + troopId: input.troopId, + }); + if (!result || result.type !== 'troopJoin') { + return assertCommandResult(result, 'troopJoin'); + } + if (!result.ok) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); + } + return { ok: true }; + }), + exit: authedProcedure.mutation(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopExit', + generalId: me.id, + }); + if (!result || result.type !== 'troopExit') { + return assertCommandResult(result, 'troopExit'); + } + if (!result.ok) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); + } + return { ok: true, wasLeader: result.wasLeader }; + }), + kick: authedProcedure .input( z.object({ - generalId: z.number().int().positive(), troopId: z.number().int().positive(), + targetGeneralId: z.number().int().positive(), }) ) .mutation(async ({ ctx, input }) => { - const result = await ctx.turnDaemon.requestCommand({ - type: 'troopJoin', - generalId: input.generalId, - troopId: input.troopId, + const me = await getMyGeneral(ctx); + if (me.id !== input.troopId || me.troopId !== me.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + const target = await ctx.db.general.findUnique({ + where: { id: input.targetGeneralId }, + select: { id: true, troopId: true }, }); - if (!result) { - throw new TRPCError({ - code: 'TIMEOUT', - message: 'Turn daemon did not respond.', - }); + if (!target) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 정보를 찾을 수 없습니다.' }); } - if (result.type !== 'troopJoin') { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Unexpected turn daemon response.', - }); + if (target.troopId === 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대에 소속되어 있지 않습니다.' }); } - if (!result.ok) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: result.reason, - }); + if (target.troopId !== input.troopId) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '다른 부대에 소속되어 있습니다.' }); + } + if (target.id === input.troopId) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대장을 추방할 수 없습니다.' }); } + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopKick', + generalId: me.id, + troopId: input.troopId, + targetGeneralId: input.targetGeneralId, + }); + if (!result || result.type !== 'troopKick') { + return assertCommandResult(result, 'troopKick'); + } + if (!result.ok) { + const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED'; + throw new TRPCError({ code, message: result.reason }); + } return { ok: true }; }), - exit: authedProcedure + rename: authedProcedure .input( z.object({ - generalId: z.number().int().positive(), + troopId: z.number().int().positive(), + troopName: troopNameSchema, }) ) .mutation(async ({ ctx, input }) => { - const result = await ctx.turnDaemon.requestCommand({ - type: 'troopExit', - generalId: input.generalId, + const me = await getMyGeneral(ctx); + const troopName = normalizeRequiredTroopName(input.troopName); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, }); - if (!result) { - throw new TRPCError({ - code: 'TIMEOUT', - message: 'Turn daemon did not respond.', - }); + const permission = resolveTroopSecretPermission(me, nation?.meta ?? {}, false); + if (me.id !== input.troopId && permission < 4) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } - if (result.type !== 'troopExit') { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Unexpected turn daemon response.', - }); - } - if (!result.ok) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: result.reason, - }); + const troop = await ctx.db.troop.findUnique({ + where: { troopLeaderId: input.troopId }, + select: { nationId: true }, + }); + if (!troop || me.nationId <= 0 || troop.nationId !== me.nationId) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' }); } - return { ok: true, wasLeader: result.wasLeader }; + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopRename', + generalId: me.id, + troopId: input.troopId, + troopName, + }); + if (!result || result.type !== 'troopRename') { + return assertCommandResult(result, 'troopRename'); + } + if (!result.ok) { + const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED'; + throw new TRPCError({ code, message: result.reason }); + } + return { ok: true, troopName: result.troopName }; }), }); diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts new file mode 100644 index 0000000..1bf0a4a --- /dev/null +++ b/app/game-api/test/troopRouter.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'user-1', + name: '부대장', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, +}); + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-1', + user: { + id: 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, +}; + +const buildContext = (options: { + me?: GeneralRow; + target?: GeneralRow | null; + troop?: { troopLeaderId: number; nationId: number; name: string } | null; + nationMeta?: Record; + auth?: GameSessionTokenPayload | null; + result: Awaited>; +}) => { + const me = options.me ?? buildGeneral(); + const requestCommand = vi.fn(async () => options.result); + const db = { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + me.userId === where.userId ? me : null + ), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => { + if (where.id === me.id) { + return me; + } + return options.target?.id === where.id ? options.target : null; + }), + }, + nation: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === me.nationId ? { id: me.nationId, meta: options.nationMeta ?? {} } : null + ), + }, + troop: { + findUnique: vi.fn(async ({ where }: { where: { troopLeaderId: number } }) => + options.troop?.troopLeaderId === where.troopLeaderId ? options.troop : null + ), + }, + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: options.auth === undefined ? auth : options.auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand }; +}; + +describe('troop router permissions and mutations', () => { + it('creates a troop only for the general owned by the authenticated user', async () => { + const { context, requestCommand } = buildContext({ + result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, + }); + const caller = appRouter.createCaller(context); + + await expect(caller.troop.create({ troopName: '백마대' })).resolves.toEqual({ + ok: true, + troopId: 1, + troopName: '백마대', + }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'troopCreate', + generalId: 1, + troopName: '백마대', + }); + }); + + it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => { + const assigned = buildContext({ + me: buildGeneral({ troopId: 9 }), + result: null, + }); + await expect( + appRouter.createCaller(assigned.context).troop.create({ troopName: '신규대' }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '이미 부대에 소속되어 있습니다.', + }); + expect(assigned.requestCommand).not.toHaveBeenCalled(); + + const blank = buildContext({ result: null }); + await expect(appRouter.createCaller(blank.context).troop.create({ troopName: ' ' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '부대 이름이 없습니다.', + }); + expect(blank.requestCommand).not.toHaveBeenCalled(); + }); + + it('rejects an over-width legacy troop name', async () => { + const fixture = buildContext({ result: null }); + await expect( + appRouter.createCaller(fixture.context).troop.create({ troopName: '가나다라마바사아자차' }) + ).rejects.toThrow('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('allows only the current troop leader to kick a member', async () => { + const unauthorized = buildContext({ + me: buildGeneral({ id: 2, troopId: 1 }), + target: buildGeneral({ id: 3, userId: null, troopId: 1 }), + result: null, + }); + await expect( + appRouter.createCaller(unauthorized.context).troop.kick({ troopId: 1, targetGeneralId: 3 }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + expect(unauthorized.requestCommand).not.toHaveBeenCalled(); + + const authorized = buildContext({ + me: buildGeneral({ id: 1, troopId: 1 }), + target: buildGeneral({ id: 3, userId: null, troopId: 1 }), + result: { type: 'troopKick', ok: true, generalId: 1, troopId: 1, targetGeneralId: 3 }, + }); + await expect( + appRouter.createCaller(authorized.context).troop.kick({ troopId: 1, targetGeneralId: 3 }) + ).resolves.toEqual({ ok: true }); + expect(authorized.requestCommand).toHaveBeenCalledWith({ + type: 'troopKick', + generalId: 1, + troopId: 1, + targetGeneralId: 3, + }); + }); + + it('checks same-nation top-secret permission before renaming another troop', async () => { + const forbidden = buildContext({ + me: buildGeneral({ id: 2, officerLevel: 1, meta: {} }), + troop: { troopLeaderId: 1, nationId: 1, name: '구대' }, + result: null, + }); + await expect( + appRouter.createCaller(forbidden.context).troop.rename({ troopId: 1, troopName: '신대' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + + const ambassador = buildContext({ + me: buildGeneral({ id: 2, officerLevel: 1, meta: { permission: 'ambassador' } }), + troop: { troopLeaderId: 1, nationId: 1, name: '구대' }, + result: { type: 'troopRename', ok: true, generalId: 2, troopId: 1, troopName: '신대' }, + }); + await expect( + appRouter.createCaller(ambassador.context).troop.rename({ troopId: 1, troopName: '신대' }) + ).resolves.toEqual({ ok: true, troopName: '신대' }); + + const crossNation = buildContext({ + me: buildGeneral({ id: 2, officerLevel: 1, meta: { permission: 'ambassador' } }), + troop: { troopLeaderId: 1, nationId: 9, name: '타국대' }, + result: null, + }); + await expect( + appRouter.createCaller(crossNation.context).troop.rename({ troopId: 1, troopName: '신대' }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' }); + expect(crossNation.requestCommand).not.toHaveBeenCalled(); + }); + + it('does not accept troop mutations without authentication', async () => { + const fixture = buildContext({ auth: null, result: null }); + await expect( + appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 3f80ae8..9e8a951 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -50,11 +50,31 @@ const zTroopJoin = z.object({ troopId: zFiniteNumber, }); +const zTroopCreate = z.object({ + type: z.literal('troopCreate'), + generalId: zFiniteNumber, + troopName: z.string(), +}); + const zTroopExit = z.object({ type: z.literal('troopExit'), generalId: zFiniteNumber, }); +const zTroopKick = z.object({ + type: z.literal('troopKick'), + generalId: zFiniteNumber, + troopId: zFiniteNumber, + targetGeneralId: zFiniteNumber, +}); + +const zTroopRename = z.object({ + type: z.literal('troopRename'), + generalId: zFiniteNumber, + troopId: zFiniteNumber, + troopName: z.string(), +}); + const zDieOnPrestart = z.object({ type: z.literal('dieOnPrestart'), generalId: zFiniteNumber, @@ -262,6 +282,14 @@ const normalizeTroopJoin: CommandNormalizer<'troopJoin'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeTroopCreate: CommandNormalizer<'troopCreate'> = (envelope) => { + const command = parseWith(zTroopCreate, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => { const command = parseWith(zTroopExit, envelope.command); if (!command) { @@ -270,6 +298,22 @@ const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeTroopKick: CommandNormalizer<'troopKick'> = (envelope) => { + const command = parseWith(zTroopKick, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + +const normalizeTroopRename: CommandNormalizer<'troopRename'> = (envelope) => { + const command = parseWith(zTroopRename, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) => { const command = parseWith(zDieOnPrestart, envelope.command); if (!command) { @@ -445,8 +489,11 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => { const normalizers: CommandNormalizerMap = { auctionFinalize: normalizeAuctionFinalize, auctionBid: normalizeAuctionBid, + troopCreate: normalizeTroopCreate, troopJoin: normalizeTroopJoin, troopExit: normalizeTroopExit, + troopKick: normalizeTroopKick, + troopRename: normalizeTroopRename, dieOnPrestart: normalizeDieOnPrestart, buildNationCandidate: normalizeBuildNationCandidate, instantRetreat: normalizeInstantRetreat, diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bdf02a3..6c11cb4 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -434,6 +434,18 @@ export class InMemoryTurnWorld { return next; } + createTroop(troop: Troop): Troop | null { + if (this.troops.has(troop.id)) { + return null; + } + const next = { ...troop }; + this.troops.set(troop.id, next); + this.dirtyTroopIds.add(troop.id); + this.createdTroopIds.add(troop.id); + this.deletedTroopIds.delete(troop.id); + return next; + } + removeTroop(id: number): boolean { if (!this.troops.has(id)) { return false; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 499e80f..cc53d8b 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -22,6 +22,7 @@ export interface TurnWorldState { export interface TurnGeneral extends General { turnTime: Date; recentWarTime?: Date | null; + penalty?: unknown; } export interface TurnDiplomacy { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 5e9d525..920a4dc 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -14,7 +14,10 @@ import { buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, + isValidTroopNameWidth, loadItemModules, + normalizeTroopName, + resolveTroopSecretPermission, resolveUniqueConfig, rollUniqueLottery, type ItemModule, @@ -441,6 +444,73 @@ async function handleTroopJoin( }; } +async function handleTroopCreate( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world } = ctx; + const general = world.getGeneralById(command.generalId); + if (!general) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '장수 정보를 찾을 수 없습니다.', + }; + } + + const troopName = normalizeTroopName(command.troopName); + if (!troopName) { + return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대 이름이 없습니다.' }; + } + if (!isValidTroopNameWidth(troopName)) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.', + }; + } + if (general.troopId !== 0 || world.getTroopById(general.id)) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '이미 부대에 소속되어 있습니다.', + }; + } + if (general.nationId <= 0 || !world.getNationById(general.nationId)) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '국가에 소속되어 있지 않습니다.', + }; + } + + const troop = world.createTroop({ + id: general.id, + nationId: general.nationId, + name: troopName, + }); + if (!troop) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '부대가 생성되지 않았습니다. 버그일 수 있습니다.', + }; + } + world.updateGeneral(general.id, { troopId: general.id }); + return { + type: 'troopCreate', + ok: true, + generalId: general.id, + troopId: troop.id, + troopName: troop.name, + }; +} + async function handleTroopExit( ctx: CommandHandlerContext, command: Extract @@ -490,6 +560,103 @@ async function handleTroopExit( }; } +async function handleTroopKick( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const fail = (reason: string): TurnDaemonCommandResult => ({ + type: 'troopKick', + ok: false, + generalId: command.generalId, + troopId: command.troopId, + targetGeneralId: command.targetGeneralId, + reason, + }); + const { world } = ctx; + const actor = world.getGeneralById(command.generalId); + if (!actor) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + const troop = world.getTroopById(command.troopId); + if ( + command.generalId !== command.troopId || + actor.troopId !== actor.id || + !troop || + troop.id !== actor.id || + troop.nationId !== actor.nationId + ) { + return fail('권한이 부족합니다.'); + } + + const target = world.getGeneralById(command.targetGeneralId); + if (!target) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + if (target.troopId === 0) { + return fail('부대에 소속되어 있지 않습니다.'); + } + if (target.troopId !== command.troopId) { + return fail('다른 부대에 소속되어 있습니다.'); + } + if (target.id === command.troopId) { + return fail('부대장을 추방할 수 없습니다.'); + } + + world.updateGeneral(target.id, { troopId: 0 }); + return { + type: 'troopKick', + ok: true, + generalId: actor.id, + troopId: troop.id, + targetGeneralId: target.id, + }; +} + +async function handleTroopRename( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const fail = (reason: string): TurnDaemonCommandResult => ({ + type: 'troopRename', + ok: false, + generalId: command.generalId, + troopId: command.troopId, + reason, + }); + const { world } = ctx; + const actor = world.getGeneralById(command.generalId); + if (!actor) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + + const nation = world.getNationById(actor.nationId); + const permission = resolveTroopSecretPermission(actor, nation?.meta ?? {}, false); + if (actor.id !== command.troopId && permission < 4) { + return fail('권한이 부족합니다.'); + } + + const troopName = normalizeTroopName(command.troopName); + if (!troopName) { + return fail('부대 이름이 없습니다.'); + } + if (!isValidTroopNameWidth(troopName)) { + return fail('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); + } + + const troop = world.getTroopById(command.troopId); + if (!troop || actor.nationId <= 0 || troop.nationId !== actor.nationId) { + return fail('부대가 없습니다.'); + } + world.updateTroop(troop.id, { name: troopName }); + return { + type: 'troopRename', + ok: true, + generalId: actor.id, + troopId: troop.id, + troopName, + }; +} + async function handleDieOnPrestart( ctx: CommandHandlerContext, command: Extract @@ -1154,8 +1321,13 @@ export const createTurnDaemonCommandHandler = (options: { >; const handlers: HandlerMap = { + troopCreate: (command) => + handleTroopCreate(ctx, command as Extract), troopJoin: (command) => handleTroopJoin(ctx, command as Extract), troopExit: (command) => handleTroopExit(ctx, command as Extract), + troopKick: (command) => handleTroopKick(ctx, command as Extract), + troopRename: (command) => + handleTroopRename(ctx, command as Extract), dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract), buildNationCandidate: (command) => diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 5fa9177..245cdc9 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -195,6 +195,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { meta: {}, }, itemInventory, + penalty: row.penalty, // meta는 상단에서 보장 처리됨. turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, diff --git a/app/game-engine/test/troopManagement.test.ts b/app/game-engine/test/troopManagement.test.ts new file mode 100644 index 0000000..154df6e --- /dev/null +++ b/app/game-engine/test/troopManagement.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; + +import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (id: number, overrides: Partial = {}): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('0180-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + ...overrides, +}); + +const buildWorld = (options: { + generals?: TurnGeneral[]; + troops?: Array<{ id: number; nationId: number; name: string }>; + nationMeta?: Record; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { killturn: 24 }, + }; + const snapshot: TurnWorldSnapshot = { + generals: options.generals ?? [buildGeneral(1)], + cities: [], + nations: [ + { + id: 1, + name: '테스트국', + color: '#ff0000', + capitalCityId: null, + chiefGeneralId: 1, + gold: 1000, + rice: 1000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: options.nationMeta ?? {}, + }, + ], + troops: options.troops ?? [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { schedule }); +}; + +describe('troop management world commands', () => { + it('creates a troop and assigns the authenticated general atomically in dirty state', async () => { + const world = buildWorld({}); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({ + type: 'troopCreate', + ok: true, + generalId: 1, + troopId: 1, + troopName: '백마대', + }); + expect(world.getGeneralById(1)?.troopId).toBe(1); + expect(world.getTroopById(1)).toEqual({ id: 1, nationId: 1, name: '백마대' }); + expect(world.peekDirtyState().createdTroops).toEqual([{ id: 1, nationId: 1, name: '백마대' }]); + + const escapedWorld = buildWorld({ generals: [buildGeneral(2)] }); + const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld }); + await expect( + escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' }) + ).resolves.toMatchObject({ ok: true, troopName: '<백마대>' }); + }); + + it('preserves legacy creation failures without mutating state', async () => { + const assigned = buildWorld({ + generals: [buildGeneral(1, { troopId: 1 })], + troops: [{ id: 1, nationId: 1, name: '기존대' }], + }); + const assignedHandler = createTurnDaemonCommandHandler({ world: assigned }); + await expect( + assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' }) + ).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' }); + + const blank = buildWorld({}); + const blankHandler = createTurnDaemonCommandHandler({ world: blank }); + await expect( + blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' }) + ).resolves.toMatchObject({ + ok: false, + reason: '부대 이름이 없습니다.', + }); + expect(blank.getGeneralById(1)?.troopId).toBe(0); + expect(blank.peekDirtyState().createdTroops).toEqual([]); + }); + + it('allows only the troop leader to kick a current non-leader member', async () => { + const buildFixture = () => + buildWorld({ + generals: [ + buildGeneral(1, { troopId: 1 }), + buildGeneral(2, { troopId: 1 }), + buildGeneral(3, { troopId: 1 }), + ], + troops: [{ id: 1, nationId: 1, name: '백마대' }], + }); + + const forbidden = buildFixture(); + const forbiddenHandler = createTurnDaemonCommandHandler({ world: forbidden }); + await expect( + forbiddenHandler.handle({ + type: 'troopKick', + generalId: 2, + troopId: 1, + targetGeneralId: 3, + }) + ).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' }); + expect(forbidden.getGeneralById(3)?.troopId).toBe(1); + + const allowed = buildFixture(); + const allowedHandler = createTurnDaemonCommandHandler({ world: allowed }); + await expect( + allowedHandler.handle({ + type: 'troopKick', + generalId: 1, + troopId: 1, + targetGeneralId: 3, + }) + ).resolves.toMatchObject({ ok: true, targetGeneralId: 3 }); + expect(allowed.getGeneralById(3)?.troopId).toBe(0); + + await expect( + allowedHandler.handle({ + type: 'troopKick', + generalId: 1, + troopId: 1, + targetGeneralId: 1, + }) + ).resolves.toMatchObject({ ok: false, reason: '부대장을 추방할 수 없습니다.' }); + }); + + it('renames for the leader or a same-nation top-secret actor and honors penalties', async () => { + const leaderWorld = buildWorld({ + generals: [buildGeneral(1, { troopId: 1, penalty: { noTopSecret: true } })], + troops: [{ id: 1, nationId: 1, name: '구대' }], + }); + const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld }); + await expect( + leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' }) + ).resolves.toMatchObject({ ok: true, troopName: '신대' }); + + const managerWorld = buildWorld({ + generals: [ + buildGeneral(1, { troopId: 1 }), + buildGeneral(2, { meta: { killturn: 24, permission: 'ambassador' } }), + ], + troops: [{ id: 1, nationId: 1, name: '구대' }], + }); + const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld }); + await expect( + managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' }) + ).resolves.toMatchObject({ ok: true, troopName: '신대' }); + + const penalizedWorld = buildWorld({ + generals: [ + buildGeneral(1, { troopId: 1 }), + buildGeneral(2, { + meta: { killturn: 24, permission: 'ambassador' }, + penalty: { noTopSecret: true }, + }), + ], + troops: [{ id: 1, nationId: 1, name: '구대' }], + }); + const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld }); + await expect( + penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' }) + ).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' }); + expect(penalizedWorld.getTroopById(1)?.name).toBe('구대'); + }); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs new file mode 100644 index 0000000..eddbfbd --- /dev/null +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -0,0 +1,34 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +export default defineConfig({ + testDir: '.', + testMatch: 'troop.spec.ts', + fullyParallel: false, + workers: 1, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]], + outputDir: resolve(repositoryRoot, 'test-results/troop'), + use: { + baseURL: 'http://127.0.0.1:15120/che/', + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + colorScheme: 'dark', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: { + command: + 'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15120', + cwd: repositoryRoot, + url: 'http://127.0.0.1:15120/che/', + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts new file mode 100644 index 0000000..8d47bc8 --- /dev/null +++ b/app/game-frontend/e2e/troop.spec.ts @@ -0,0 +1,347 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const imageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../../image/game'); + +type Member = { id: number; name: string; cityId: number; cityName: string }; +type TroopFixture = { + id: number; + name: string; + nationId: number; + turnTime: string; + reservedCommands: string[]; + leader: { + id: number; + name: string; + cityId: number; + cityName: string; + picture: string | null; + imageServer: number; + }; + members: Member[]; +}; +type FixtureState = { + me: { id: number; troopId: number }; + permission: number; + troops: TroopFixture[]; + failCreate?: boolean; +}; + +const baseTroops = (): TroopFixture[] => [ + { + id: 1, + name: '백마대', + nationId: 1, + turnTime: '2026-07-25T08:20:30.000Z', + reservedCommands: ['che_집합', 'che_이동'], + leader: { + id: 1, + name: '공손찬', + cityId: 1, + cityName: '북평', + picture: 'default.jpg', + imageServer: 0, + }, + members: [ + { id: 1, name: '공손찬', cityId: 1, cityName: '북평' }, + { id: 3, name: '조운', cityId: 1, cityName: '북평' }, + { id: 4, name: '전예', cityId: 2, cityName: '계' }, + ], + }, + { + id: 2, + name: '청룡대', + nationId: 1, + turnTime: '2026-07-25T08:30:30.000Z', + reservedCommands: ['che_징병'], + leader: { + id: 2, + name: '관우', + cityId: 2, + cityName: '계', + picture: 'default.jpg', + imageServer: 0, + }, + members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }], + }, +]; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); +const operationName = (route: Route): string => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)); +}; + +const fulfillJson = async (route: Route, body: unknown) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); +}; + +const gotoTroop = async (page: Page) => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto('troop'); + await lobbyResponse; +}; + +const installApiFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(imageRoot, filename)), + }); + }); + } + await page.route('**/image/icons/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/png', + body: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ), + }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationName(route).split(','); + const results = operations.map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: state.me.id, name: '테스트 장수' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'troop.getList') { + return response({ + nation: { id: 1, name: '테스트국' }, + me: state.me, + permission: state.permission, + troops: state.troops, + }); + } + if (operation === 'troop.create') { + if (state.failCreate) { + state.failCreate = false; + return errorResponse(operation, '부대 이름이 없습니다.'); + } + const createdId = state.me.id; + state.me.troopId = createdId; + state.troops.push({ + id: createdId, + name: '신규대', + nationId: 1, + turnTime: '2026-07-25T08:40:30.000Z', + reservedCommands: [], + leader: { + id: createdId, + name: '유비', + cityId: 1, + cityName: '북평', + picture: 'default.jpg', + imageServer: 0, + }, + members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }], + }); + return response({ ok: true, troopId: createdId, troopName: '신규대' }); + } + if (operation === 'troop.rename') { + state.troops[0]!.name = '백마의종'; + return response({ ok: true, troopName: '백마의종' }); + } + if (operation === 'troop.kick') { + state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3); + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfillJson(route, results); + }); +}; + +test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => { + await installApiFixture(page, { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoTroop(page); + await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); + + const geometry = await page + .locator('.troopItem') + .first() + .evaluate((item) => { + const origin = item.getBoundingClientRect(); + const box = (selector: string) => { + const rect = item.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + const members = item.querySelector('.troopMembers')!; + const style = getComputedStyle(members); + return { + item: { x: origin.x, y: origin.y, width: origin.width, height: origin.height }, + info: box('.troopInfo'), + icon: box('.troopLeaderIcon'), + reserved: box('.troopReservedCommand'), + members: box('.troopMembers'), + action: box('.troopAction'), + membersStyle: { + paddingTop: style.paddingTop, + paddingLeft: style.paddingLeft, + textAlign: style.textAlign, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }, + }; + }); + expect(geometry.item).toEqual({ x: 0, y: 32, width: 1000, height: 127.5 }); + expect(geometry.info.width).toBeCloseTo(130, 0); + expect(geometry.info.height).toBeCloseTo(65, 0); + expect(geometry.icon.x - geometry.info.x).toBeCloseTo(130, 0); + expect(geometry.reserved.x - geometry.info.x).toBeCloseTo(260, 0); + expect(geometry.members.x - geometry.info.x).toBeCloseTo(360, 0); + expect(geometry.members.width).toBeCloseTo(639, 0); + expect(geometry.members.height).toBeCloseTo(93, 0); + expect(geometry.action.x - geometry.info.x).toBeCloseTo(65, 0); + expect(geometry.action.y - geometry.info.y).toBeCloseTo(93, 0); + expect(geometry.action.width).toBeCloseTo(934, 0); + expect(geometry.membersStyle).toEqual({ + paddingTop: '7px', + paddingLeft: '9.8px', + textAlign: 'left', + fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"', + fontSize: '14px', + lineHeight: '21px', + }); + + const kickButton = page.getByRole('button', { name: '부대원 추방...' }).first(); + await kickButton.hover(); + const hoverStyle = await kickButton.evaluate((button) => ({ + cursor: getComputedStyle(button).cursor, + filter: getComputedStyle(button).filter, + })); + expect(hoverStyle.cursor).toBe('pointer'); + expect(hoverStyle.filter).not.toBe('none'); + + await page.locator('.troopMember').nth(1).hover(); + await expect(page.getByRole('tooltip')).toContainText('조운'); + expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo( + 500, + 0 + ); + await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true }); +}); + +test('matches the legacy 500px responsive placement', async ({ page }) => { + await installApiFixture(page, { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoTroop(page); + await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); + + const geometry = await page + .locator('.troopItem') + .first() + .evaluate((item) => { + const origin = item.getBoundingClientRect(); + const relative = (selector: string) => { + const rect = item.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width }; + }; + return { + item: { width: origin.width, height: origin.height }, + info: relative('.troopInfo'), + icon: relative('.troopLeaderIcon'), + reserved: relative('.troopReservedCommand'), + action: relative('.troopAction'), + members: relative('.troopMembers'), + }; + }); + expect(geometry.item).toEqual({ width: 500, height: 129 }); + expect(geometry.info).toMatchObject({ x: 0, y: 0, width: 130 }); + expect(geometry.icon).toMatchObject({ x: 130, y: 0, width: 130 }); + expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 }); + expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 }); + expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 }); + await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true }); +}); + +test('shows API failure then creates a troop successfully', async ({ page }) => { + const state: FixtureState = { + me: { id: 7, troopId: 0 }, + permission: 0, + troops: baseTroops(), + failCreate: true, + }; + await installApiFixture(page, state); + await gotoTroop(page); + + const input = page.getByRole('textbox', { name: '부대명' }); + await input.fill('실패대'); + await page.getByRole('button', { name: '부대 창설', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.'); + expect(state.me.troopId).toBe(0); + + await input.fill('신규대'); + await page.getByRole('button', { name: '부대 창설', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.'); + await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible(); + await expect(input).toBeHidden(); +}); + +test('renames and kicks through confirm dialogs, then refreshes state', async ({ page }) => { + const state: FixtureState = { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }; + await installApiFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + await gotoTroop(page); + + await page.getByRole('button', { name: '부대명 변경...' }).first().click(); + await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종'); + await page.getByRole('button', { name: '변경', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.'); + await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible(); + + await page.getByRole('button', { name: '부대원 추방...' }).click(); + await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3'); + await page.getByRole('button', { name: '추방', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.'); + await expect(page.locator('.troopMembers').first()).not.toContainText('조운'); +}); + +test('does not render management controls for an unauthorized member', async ({ page }) => { + await installApiFixture(page, { + me: { id: 3, troopId: 1 }, + permission: 1, + troops: baseTroops(), + }); + await gotoTroop(page); + await expect(page.getByRole('button', { name: '부대 탈퇴' })).toBeVisible(); + await expect(page.getByRole('button', { name: '부대원 추방...' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '부대명 변경...' })).toHaveCount(0); +}); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index df3671d..4d24888 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", + "test:e2e:troop": "playwright test --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/main.ts b/app/game-frontend/src/main.ts index da2eff3..05866a0 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -3,7 +3,6 @@ import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; import './assets/main.css'; -import { useSessionStore } from './stores/session'; const app = createApp(App); @@ -11,7 +10,4 @@ const pinia = createPinia(); app.use(pinia); app.use(router); -const session = useSessionStore(pinia); -void session.initialize(); - app.mount('#app'); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a81233d..2e14ecb 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -25,6 +25,7 @@ import HallOfFameView from '../views/HallOfFameView.vue'; import DynastyListView from '../views/DynastyListView.vue'; import DynastyDetailView from '../views/DynastyDetailView.vue'; import SurveyView from '../views/SurveyView.vue'; +import TroopView from '../views/TroopView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -60,6 +61,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/troop', + name: 'troop', + component: TroopView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/cities', name: 'nation-cities', diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index 6369a0f..ce50cb3 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -162,6 +162,11 @@ export const useSessionStore = defineStore('session', { this.setSessionToken(storedToken); } + const storedGameToken = this.gameToken ?? readStorage(GAME_TOKEN_KEY); + if (storedGameToken && storedGameToken !== this.gameToken) { + this.setGameToken(storedGameToken); + } + const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE; if (storedProfile && storedProfile !== this.profile) { this.setProfile(storedProfile); diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d2e5f27..00638de 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -94,6 +94,7 @@ watch( 세력 도시 세력 장수 인사부 + 부대 편성 내무부 외교부 사령부 diff --git a/app/game-frontend/src/views/TroopView.vue b/app/game-frontend/src/views/TroopView.vue new file mode 100644 index 0000000..bbc516f --- /dev/null +++ b/app/game-frontend/src/views/TroopView.vue @@ -0,0 +1,678 @@ + + + + + diff --git a/app/game-frontend/tsconfig.json b/app/game-frontend/tsconfig.json index bb3d6d6..bb471ef 100644 --- a/app/game-frontend/tsconfig.json +++ b/app/game-frontend/tsconfig.json @@ -38,7 +38,8 @@ "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", - "test/**/*.ts" + "test/**/*.ts", + "e2e/**/*.ts" ], "references": [ { diff --git a/package.json b/package.json index 39afe6a..92e0346 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "1.62.0", "@types/node": "^26.1.1", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index dd6a3a5..aa8f5aa 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -52,8 +52,17 @@ export type TurnDaemonCommand = | { type: 'resume'; requestId?: string; reason?: string } | { type: 'shutdown'; requestId?: string; reason?: string } | { type: 'getStatus'; requestId?: string } + | { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string } | { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number } | { type: 'troopExit'; requestId?: string; generalId: number } + | { + type: 'troopKick'; + requestId?: string; + generalId: number; + troopId: number; + targetGeneralId: number; + } + | { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string } | { type: 'dieOnPrestart'; requestId?: string; generalId: number } | { type: 'buildNationCandidate'; requestId?: string; generalId: number } | { type: 'instantRetreat'; requestId?: string; generalId: number } @@ -204,6 +213,19 @@ export type TurnDaemonCommandResult = auctionId: number; reason: string; } + | { + type: 'troopCreate'; + ok: true; + generalId: number; + troopId: number; + troopName: string; + } + | { + type: 'troopCreate'; + ok: false; + generalId: number; + reason: string; + } | { type: 'troopJoin'; ok: true; @@ -229,6 +251,35 @@ export type TurnDaemonCommandResult = generalId: number; reason: string; } + | { + type: 'troopKick'; + ok: true; + generalId: number; + troopId: number; + targetGeneralId: number; + } + | { + type: 'troopKick'; + ok: false; + generalId: number; + troopId: number; + targetGeneralId: number; + reason: string; + } + | { + type: 'troopRename'; + ok: true; + generalId: number; + troopId: number; + troopName: string; + } + | { + type: 'troopRename'; + ok: false; + generalId: number; + troopId: number; + reason: string; + } | { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string } | { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string } | { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string } diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index ffd2b44..9c14ece 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -43,6 +43,7 @@ export interface TurnEngineGeneralRow { age: number; npcState: number; meta: JsonValue; + penalty: JsonValue; turnTime: Date; recentWarTime: Date | null; } diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 47d8f1d..6b164d0 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -18,5 +18,6 @@ export * from './scenario/index.js'; export * from './triggers/index.js'; export * from './turn/index.js'; export * from './tournament/index.js'; +export * from './troop/management.js'; export * from './world/index.js'; export * from './war/index.js'; diff --git a/packages/logic/src/troop/management.ts b/packages/logic/src/troop/management.ts new file mode 100644 index 0000000..9be19ee --- /dev/null +++ b/packages/logic/src/troop/management.ts @@ -0,0 +1,115 @@ +import { asRecord } from '@sammo-ts/common'; + +export interface TroopPermissionGeneral { + 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 isFullWidthCodePoint = (codePoint: number): boolean => + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) || + (codePoint >= 0x3250 && codePoint <= 0x4dbf) || + (codePoint >= 0x4e00 && codePoint <= 0xa4c6) || + (codePoint >= 0xa960 && codePoint <= 0xa97c) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || + (codePoint >= 0xff01 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1b000 && codePoint <= 0x1b001) || + (codePoint >= 0x1f200 && codePoint <= 0x1f251) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)); + +// PHP mb_strwidth와 같은 전각 2/반각 1 기준으로 부대명 길이를 센다. +export const getLegacyStringWidth = (value: string): number => { + let width = 0; + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined) { + continue; + } + if (codePoint === 0 || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) { + continue; + } + width += isFullWidthCodePoint(codePoint) ? 2 : 1; + } + return width; +}; + +// 레거시 StringUtil::neutralize처럼 HTML 특수문자를 저장용 문자열로 바꾼 뒤 +// 양 끝의 separator/control만 제거한다. +export const normalizeTroopName = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, ''); + +export const isValidTroopNameWidth = (value: string): boolean => { + const width = getLegacyStringWidth(value); + return width >= 1 && width <= 18; +}; + +export const resolveTroopSecretPermission = ( + general: TroopPermissionGeneral, + nationMeta: unknown, + checkSecretLimit = false +): number => { + if (general.nationId <= 0 || general.officerLevel === 0) { + return -1; + } + + const penalty = asRecord(general.penalty); + if (penalty.noChief) { + return 0; + } + + const meta = asRecord(general.meta); + const permission = meta.permission; + const belong = readNumber(meta.belong, 0); + const nation = asRecord(nationMeta); + const secretLimit = readNumber(nation.secretlimit ?? nation.secretLimit, 3); + + let secretMax = 4; + if (penalty.noTopSecret || penalty.noChief) { + secretMax = 1; + } else if (penalty.noAmbassador) { + secretMax = 2; + } + + let secretMin = 0; + if (general.officerLevel === 12 || permission === 'ambassador') { + secretMin = 4; + } else if (permission === 'auditor') { + secretMin = 3; + } else if (general.officerLevel >= 5) { + secretMin = 2; + } else if (general.officerLevel > 1) { + secretMin = 1; + } else if (checkSecretLimit && belong >= secretLimit) { + secretMin = 1; + } + + return Math.min(secretMin, secretMax); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5159ef9..857a47b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.6.1)(supports-color@7.2.0)) + '@playwright/test': + specifier: 1.62.0 + version: 1.62.0 '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -1017,6 +1020,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + '@pm2/agent@2.0.4': resolution: {integrity: sha512-n7WYvvTJhHLS2oBb1PjOtgLpMhgImOq8sXkPBw6smeg9LJBWZjiEgPKOpR8mn9UJZsB5P3W4V/MyvNnp31LKeA==} @@ -2919,6 +2927,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3490,6 +3503,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + pm2-axon-rpc@0.7.1: resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==} engines: {node: '>=5'} @@ -4816,6 +4839,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + '@pm2/agent@2.0.4(supports-color@7.2.0)': dependencies: async: 3.2.6 @@ -6532,6 +6559,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7047,6 +7077,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + pm2-axon-rpc@0.7.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6db63a2..82420ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,9 @@ allowBuilds: minimumReleaseAgeExclude: - eslint@10.8.0 + - '@playwright/test@1.62.0' + - playwright-core@1.62.0 + - playwright@1.62.0 onlyBuiltDependencies: - '@prisma/client'