diff --git a/app/game-api/src/router/nation/endpoints/getChiefCenter.ts b/app/game-api/src/router/nation/endpoints/getChiefCenter.ts index 15f3e401..b5215211 100644 --- a/app/game-api/src/router/nation/endpoints/getChiefCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getChiefCenter.ts @@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'; import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { resolveSecretPermission } from '../../shared/secretPermission.js'; -import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js'; +import { MAX_NATION_TURNS, getNationTurnSnapshots } from '../../../turns/reservedTurns.js'; import { assertNationAccess } from '../shared.js'; export const getChiefCenter = accessAuthedProcedure.query(async ({ ctx }) => { @@ -56,17 +56,18 @@ export const getChiefCenter = accessAuthedProcedure.query(async ({ ctx }) => { const chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5]; const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general])); - const turnsByLevel = await Promise.all(chiefLevels.map((level) => getNationTurnSnapshot(ctx.db, nation.id, level))); + const turnsByLevel = await getNationTurnSnapshots(ctx.db, nation.id, chiefLevels); - const chiefs = chiefLevels.map((level, idx) => { + const chiefs = chiefLevels.map((level) => { const entry = generalByLevel.get(level); + const snapshot = turnsByLevel.get(level); return { officerLevel: level, name: entry?.name ?? null, npcState: entry?.npcState ?? null, turnTime: entry?.turnTime ? entry.turnTime.toISOString() : null, - revision: turnsByLevel[idx]?.revision ?? 0, - turns: turnsByLevel[idx]?.turns ?? [], + revision: snapshot?.revision ?? 0, + turns: snapshot?.turns ?? [], }; }); diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index 77e9863a..b133cea0 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -218,6 +218,42 @@ export const getNationTurnSnapshot = async ( }; }; +export const getNationTurnSnapshots = async ( + db: DatabaseClient, + nationId: number, + officerLevels: readonly number[] +): Promise> => { + const levels = [...new Set(officerLevels)]; + if (levels.length === 0) return new Map(); + const [turnRows, revisionRows] = await Promise.all([ + db.nationTurn.findMany({ + where: { nationId, officerLevel: { in: levels } }, + orderBy: [{ officerLevel: 'asc' }, { turnIdx: 'asc' }], + }), + db.nationTurnRevision.findMany({ + where: { nationId, officerLevel: { in: levels } }, + }), + ]); + const turnsByLevel = new Map(); + for (const row of turnRows) { + const rows = turnsByLevel.get(row.officerLevel) ?? []; + rows.push(row); + turnsByLevel.set(row.officerLevel, rows); + } + const revisionByLevel = new Map(revisionRows.map((row) => [row.officerLevel, row.revision])); + return new Map( + levels.map((level) => [ + level, + { + revision: revisionByLevel.get(level) ?? 0, + turns: serializeTurnList( + buildTurnListFromRows(turnsByLevel.get(level) ?? [], MAX_NATION_TURNS) + ), + }, + ]) + ); +}; + const claimGeneralRevision = async ( db: DatabaseClient, generalId: number, diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index 1ca8cf02..833d2b33 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -5,6 +5,7 @@ import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, expandGeneralTurnIndices, + getNationTurnSnapshots, repeatGeneralTurns, repeatNationTurns, setGeneralTurn, @@ -201,6 +202,52 @@ const buildDb = (autorunLimit: number | null = null) => { }; describe('reservedTurns', () => { + it('loads multiple nation officer queues with two batched queries and preserves defaults', async () => { + const findTurns = vi.fn(async () => [ + { + id: 1, + nationId: 4, + officerLevel: 12, + turnIdx: 1, + actionCode: 'che_징병', + arg: { amount: 100 }, + createdAt: new Date(), + }, + { + id: 2, + nationId: 4, + officerLevel: 10, + turnIdx: 0, + actionCode: 'che_훈련', + arg: {}, + createdAt: new Date(), + }, + ] satisfies NationTurnRow[]); + const findRevisions = vi.fn(async () => [ + { nationId: 4, officerLevel: 12, revision: 7, updatedAt: new Date() }, + ]); + const db = { + nationTurn: { findMany: findTurns }, + nationTurnRevision: { findMany: findRevisions }, + } as unknown as DatabaseClient; + + const snapshots = await getNationTurnSnapshots(db, 4, [12, 10, 8, 12]); + + expect(findTurns).toHaveBeenCalledOnce(); + expect(findRevisions).toHaveBeenCalledOnce(); + expect(snapshots.size).toBe(3); + expect(snapshots.get(12)?.revision).toBe(7); + expect(snapshots.get(12)?.turns.slice(0, 2)).toEqual([ + { index: 0, action: '휴식', args: {} }, + { index: 1, action: 'che_징병', args: { amount: 100 } }, + ]); + expect(snapshots.get(10)?.revision).toBe(0); + expect(snapshots.get(10)?.turns[0]).toEqual({ index: 0, action: 'che_훈련', args: {} }); + expect(snapshots.get(8)?.revision).toBe(0); + expect(snapshots.get(8)?.turns[0]).toEqual({ index: 0, action: '휴식', args: {} }); + expect(snapshots.get(8)?.turns).toHaveLength(MAX_NATION_TURNS); + }); + it('sets and shifts general turns', async () => { const { db } = buildDb(2408); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index a1ffd79f..0d1968c9 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -673,10 +673,53 @@ export class GeneralAI { this.promotionNationMeta = nextMeta; } - getReservedTurn(generalId: number): ReservedTurnEntry { + getFirstReservedGeneralTurn(generalId: number): ReservedTurnEntry { return this.reservedTurnProvider.getGeneralTurn(generalId, 0); } + resolveGeneralAiStats(general: TurnGeneral): ReturnType { + if (this.commandEnv.generalActionModules) { + return resolveLegacyAiStatsWithModules( + general, + this.nation, + this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, + this.commandEnv.generalActionModules, + this.worldRef, + this.world, + this.startYear + ); + } + return resolveLegacyAiStats(general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL); + } + + calculateRecruitPopulationScore(general: TurnGeneral): number { + const pipeline = new GeneralActionPipeline(this.commandEnv.generalActionModules ?? []); + return pipeline.onCalcDomestic( + { + general, + nation: this.nation, + ...(this.worldRef + ? { + worldView: { + listGenerals: () => this.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + this.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => this.worldRef!.listNations(), + }, + } + : {}), + time: { + year: this.world.currentYear, + month: this.world.currentMonth, + startYear: this.startYear, + }, + }, + '징집인구', + 'score', + 100 + ); + } + calcNationDevelopedRate(): Record { if (this.devRate) { return this.devRate; @@ -850,7 +893,8 @@ export class GeneralAI { const isTroopLeader = npcType === 5 || - (candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합'); + (candidate.troopId === candidate.id && + this.getFirstReservedGeneralTurn(candidate.id).action === 'che_집합'); if (isTroopLeader) { troopLeaders[candidate.id] = candidate; continue; @@ -875,18 +919,7 @@ export class GeneralAI { continue; } - const fullLeadership = this.commandEnv.generalActionModules - ? resolveLegacyAiStatsWithModules( - candidate, - this.nation, - this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, - this.commandEnv.generalActionModules, - this.worldRef, - this.world, - this.startYear - ).fullLeadership - : resolveLegacyAiStats(candidate, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL) - .fullLeadership; + const fullLeadership = this.resolveGeneralAiStats(candidate).fullLeadership; if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) { npcWarGenerals[candidate.id] = candidate; } else { @@ -1055,17 +1088,7 @@ export class GeneralAI { } private refreshLegacyFullStats(): void { - const stats = this.commandEnv.generalActionModules - ? resolveLegacyAiStatsWithModules( - this.general, - this.nation, - this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, - this.commandEnv.generalActionModules, - this.worldRef, - this.world, - this.startYear - ) - : resolveLegacyAiStats(this.general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL); + const stats = this.resolveGeneralAiStats(this.general); this.general.meta = { ...this.general.meta, ...stats, diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts index 8478d9b1..12757e60 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts @@ -1,5 +1,4 @@ import type { GeneralAI } from '../../core.js'; -import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js'; export const doNPC후방발령 = (ai: GeneralAI) => { @@ -13,26 +12,6 @@ export const doNPC후방발령 = (ai: GeneralAI) => { return null; } - const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); - const actionContext = (general: GeneralAI['general']) => ({ - general, - nation: ai.nation, - ...(ai.worldRef - ? { - worldView: { - listGenerals: () => ai.worldRef!.listGenerals(), - listGeneralsByCity: (cityId: number) => - ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), - listNations: () => ai.worldRef!.listNations(), - }, - } - : {}), - time: { - year: ai.world.currentYear, - month: ai.world.currentMonth, - startYear: ai.startYear, - }, - }); const candidates = Object.values(ai.npcWarGenerals).filter((general) => { if (general.id === ai.general.id) { return false; @@ -50,7 +29,7 @@ export const doNPC후방발령 = (ai: GeneralAI) => { if (general.crew >= ai.nationPolicy.minWarCrew) { return false; } - if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) { + if (ai.calculateRecruitPopulationScore(general) <= 1) { return false; } return true; @@ -64,11 +43,7 @@ export const doNPC후방발령 = (ai: GeneralAI) => { } const picked = ai.rng.choice(candidates); - const fullLeadership = actionPipeline.onCalcStat( - actionContext(picked), - 'leadership', - picked.stats.leadership - ) as number; + const fullLeadership = ai.resolveGeneralAiStats(picked).fullLeadership; const minPop = Math.max( fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop, fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts index 9cf2c783..ad4e1b01 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts @@ -1,10 +1,13 @@ import type { GeneralAI } from '../../core.js'; +import { asRecord, readMetaNumber } from '../../../aiUtils.js'; import { buildAssignmentCandidate, + isGeneralTurnBefore, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, - selectRecruitableCity, + selectSafeRearCity, + selectUserRecruitmentRearCity, } from '../helpers.js'; export const do부대유저장후방발령 = (ai: GeneralAI) => { @@ -46,7 +49,13 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => { if (general.crew >= ai.nationPolicy.minWarCrew) { return false; } - const reserved = ai.getReservedTurn(general.id); + if (ai.calculateRecruitPopulationScore(general) <= 1) { + return false; + } + if (!isGeneralTurnBefore(general, troopLeader)) { + return false; + } + const reserved = ai.getFirstReservedGeneralTurn(general.id); if (reserved.action !== 'che_징병') { return false; } @@ -57,16 +66,18 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => { return null; } - const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation); + const destCityCandidates = selectSafeRearCity(ai); if (Object.keys(destCityCandidates).length === 0) { return null; } + // Ref chooses the user first, then the destination city. Both consume the + // shared nation AI RNG even when a later command constraint rejects it. + const destGeneral = ai.rng.choice(candidates); const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates)); if (!Number.isFinite(destCityId)) { return null; } - const destGeneral = ai.rng.choice(candidates); return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령'); }; @@ -98,6 +109,9 @@ export const do유저장후방발령 = (ai: GeneralAI) => { if (general.crew >= ai.nationPolicy.minWarCrew) { return false; } + if (ai.calculateRecruitPopulationScore(general) <= 1) { + return false; + } return true; }); @@ -106,8 +120,8 @@ export const do유저장후방발령 = (ai: GeneralAI) => { } const picked = ai.rng.choice(candidates); - const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; - const destCityCandidates = selectRecruitableCity(ai, minPop); + const minPop = ai.resolveGeneralAiStats(picked).fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop; + const destCityCandidates = selectUserRecruitmentRearCity(ai, minPop); if (Object.keys(destCityCandidates).length === 0) { return null; } @@ -123,16 +137,35 @@ export const do유저장구출발령 = (ai: GeneralAI) => { if (!ai.nation || !ai.nation.capitalCityId) { return null; } - const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2); - if (lostCandidates.length === 0) { + const useFrontCities = [3, 4].includes(ai.dipState) && Object.keys(ai.frontCities).length > 2; + const candidates = Object.values(ai.lostGenerals).flatMap((general) => { + if (general.npcState >= 2) { + return []; + } + const defenceTrain = readMetaNumber(asRecord(general.meta), 'defence_train', 80); + if ( + general.crew >= ai.nationPolicy.minWarCrew && + general.train >= defenceTrain && + general.atmos >= defenceTrain + ) { + // A battle-ready user may be intentionally holding an isolated city. + return []; + } + const troopLeader = general.troopId ? ai.troopLeaders[general.troopId] : undefined; + if (troopLeader && ai.supplyCities[troopLeader.cityId] && isGeneralTurnBefore(troopLeader, general)) { + // The mounted user can already escape with the leader's earlier turn. + return []; + } + const destCityId = pickRandomCityId(ai, useFrontCities ? ai.frontCities : ai.supplyCities); + return destCityId === null ? [] : [{ general, destCityId }]; + }); + if (candidates.length === 0) { return null; } - const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities); - if (destCityId === null) { - return null; - } - const destGeneral = ai.rng.choice(lostCandidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령'); + // Ref consumes one city draw per eligible isolated user before choosing a + // completed (user, city) pair. + const picked = ai.rng.choice(candidates); + return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, '유저장구출발령'); }; export const do유저장전방발령 = (ai: GeneralAI) => { @@ -159,6 +192,12 @@ export const do유저장전방발령 = (ai: GeneralAI) => { if (general.crew < ai.nationPolicy.minWarCrew) { return false; } + if (general.troopId) { + return false; + } + if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) { + return false; + } return true; }); @@ -166,12 +205,12 @@ export const do유저장전방발령 = (ai: GeneralAI) => { return null; } - const cityCandidates = pickFrontCityWeight(ai); - const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); + // Ref selects the prepared user before drawing the weighted front city. + const destGeneral = ai.rng.choice(candidates); + const destCityId = Number(ai.rng.choiceUsingWeight(pickFrontCityWeight(ai))); if (!Number.isFinite(destCityId)) { return null; } - const destGeneral = ai.rng.choice(candidates); return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령'); }; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts b/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts index cfbbef0b..c8f710d9 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts @@ -37,24 +37,56 @@ export const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: return last >= yearMonth; }; -export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record => { +export const isGeneralTurnBefore = (lhs: GeneralAI['general'], rhs: GeneralAI['general']): boolean => { + if (lhs.turnTick !== undefined && rhs.turnTick !== undefined) { + return lhs.turnTick < rhs.turnTick; + } + return lhs.turnTime.getTime() < rhs.turnTime.getTime(); +}; + +export const selectSafeRearCity = (ai: GeneralAI): Record => { const candidates: Record = {}; for (const city of Object.values(ai.backupCities)) { - if (city.population < minPop) { + const ratio = resolveCityPopRatio(city); + if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) { continue; } - const ratio = resolveCityPopRatio(city); candidates[city.id] = ratio; } if (Object.keys(candidates).length > 0) { return candidates; } for (const city of Object.values(ai.supplyCities)) { - if (city.population < minPop) { + const ratio = resolveCityPopRatio(city); + if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) { + continue; + } + candidates[city.id] = ratio; + } + return candidates; +}; + +export const selectUserRecruitmentRearCity = (ai: GeneralAI, minPopulation: number): Record => { + const candidates: Record = {}; + for (const city of Object.values(ai.backupCities)) { + if (city.id === ai.city?.id || city.population < minPopulation) { + continue; + } + let ratio = resolveCityPopRatio(city); + if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) { + ratio /= 4; + } + candidates[city.id] = ratio; + } + if (Object.keys(candidates).length > 0) { + return candidates; + } + for (const city of Object.values(ai.supplyCities)) { + if (city.id === ai.city?.id || city.population <= minPopulation) { continue; } const ratio = resolveCityPopRatio(city); - candidates[city.id] = ratio; + candidates[city.id] = ratio < ai.nationPolicy.safeRecruitCityPopulationRatio ? ratio / 2 : ratio; } return candidates; }; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts index 877308fe..908f12a8 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -1,6 +1,4 @@ import type { GeneralAI } from '../core.js'; -import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; -import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import type { TurnGeneral } from '../../../types.js'; import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js'; @@ -47,39 +45,7 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num }; const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => { - const modules = ai.commandEnv.generalActionModules; - if (modules && modules.length > 0) { - const pipeline = new GeneralActionPipeline(modules); - const adjusted = pipeline.onCalcStat( - { - general, - nation: ai.nation, - ...(ai.worldRef - ? { - worldView: { - listGenerals: () => ai.worldRef!.listGenerals(), - listGeneralsByCity: (cityId: number) => - ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), - listNations: () => ai.worldRef!.listNations(), - }, - } - : {}), - time: { - year: ai.world.currentYear, - month: ai.world.currentMonth, - startYear: ai.startYear, - }, - }, - 'leadership', - general.stats.leadership - ); - const maxStat = ai.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL; - return Math.trunc(Math.max(0, Math.min(Number(adjusted), maxStat))); - } - const nationLevel = ai.nation?.level ?? 0; - const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0; - const maxStat = ai.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL; - return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat)); + return ai.resolveGeneralAiStats(general).fullLeadership; }; const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => { diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 89c00d34..8f9ed7e8 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -25,6 +25,12 @@ import { doNPC후방발령, } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js'; import { do부대구출발령, do부대후방발령 } from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js'; +import { + do부대유저장후방발령, + do유저장구출발령, + do유저장전방발령, + do유저장후방발령, +} from '../src/turn/ai/generalAi/nation/assignments/userAssignments.js'; type Candidate = { action: string; @@ -203,6 +209,7 @@ const makeAi = ( generals?: General[]; disabledPolicyActions?: string[]; generalActionModules?: NonNullable; + reservedTurns?: Record }>; } = {} ): GeneralAI => { const general = { @@ -223,7 +230,7 @@ const makeAi = ( const generals = overrides.generals ?? [general]; const candidates: Candidate[] = []; - return { + return Object.assign(Object.create(GeneralAI.prototype), { general, city, nation, @@ -346,6 +353,12 @@ const makeAi = ( genType: overrides.genType ?? 7, rng, maxResourceActionAmount: 10_000, + reservedTurnProvider: { + getGeneralTurn: (generalId: number) => { + const reserved = overrides.reservedTurns?.[generalId]; + return reserved ? { action: reserved.action, args: reserved.args ?? {} } : { action: '휴식', args: {} }; + }, + }, generalPolicy: { can: (action: string) => !disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action), @@ -392,7 +405,7 @@ const makeAi = ( candidates.push(candidate); return candidate; }, - } as unknown as GeneralAI; + }) as GeneralAI; }; const makePromotionGeneral = (overrides: Partial): TurnGeneral => ({ @@ -1363,6 +1376,200 @@ describe('legacy NPC AI final-decision parity', () => { expect(do내정워프(ai)?.action).toBe('che_NPC능동'); }); + it('moves a mounted user rear only for an earlier first recruitment turn', () => { + const run = (options: { + reservedAction: string; + userTurnTick: number; + leaderTurnTick: number; + recruitmentScore?: number; + }) => { + const rng = makeRng([], [0, 0]); + const user = { + ...baseGeneral(), + id: 2, + cityId: 2, + troopId: 10, + npcState: 0, + turnTick: options.userTurnTick, + }; + const leader = { + ...baseGeneral(), + id: 10, + cityId: 2, + troopId: 10, + npcState: 5, + turnTick: options.leaderTurnTick, + }; + const ai = makeAi({ + dipState: 4, + rng, + generals: [baseGeneral(), user, leader], + reservedTurns: { 2: { action: options.reservedAction } }, + generalActionModules: + options.recruitmentScore === undefined + ? undefined + : singleActionModuleStack({ + eventHandlers: {}, + onCalcDomestic: (_context, turnType, varType, value) => + turnType === '징집인구' && varType === 'score' ? options.recruitmentScore! : value, + }), + }); + ai.userWarGenerals = { 2: user }; + ai.troopLeaders = { 10: leader }; + ai.nationCities = { + 2: { + ...baseCity(), + id: 2, + population: 10_000, + frontState: 3, + dev: 1, + important: 1, + }, + }; + ai.frontCities = { 2: ai.nationCities[2]! }; + ai.supplyCities = { + 2: ai.nationCities[2]!, + 3: { ...baseCity(), id: 3, dev: 1, important: 1 }, + }; + ai.backupCities = { 3: ai.supplyCities[3]! }; + return { result: do부대유저장후방발령(ai), rng }; + }; + + expect(run({ reservedAction: 'che_징병', userTurnTick: 100, leaderTurnTick: 200 }).result).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 2, destCityId: 3 }, + }); + expect(run({ reservedAction: 'che_모병', userTurnTick: 100, leaderTurnTick: 200 }).result).toBeNull(); + expect(run({ reservedAction: 'che_징병', userTurnTick: 200, leaderTurnTick: 100 }).result).toBeNull(); + expect( + run({ reservedAction: 'che_징병', userTurnTick: 100, leaderTurnTick: 200, recruitmentScore: 0 }).result + ).toBeNull(); + }); + + it('uses full leadership and the chief city exclusion for a user rear assignment', () => { + const user = { ...baseGeneral(), id: 2, cityId: 2, npcState: 0 }; + const build = (withLeadershipBonus: boolean) => { + const ai = makeAi({ + dipState: 4, + generals: [baseGeneral(), user], + generalActionModules: withLeadershipBonus + ? singleActionModuleStack({ + eventHandlers: {}, + onCalcStat: (_context, statName, value) => + statName === 'leadership' ? Number(value) + 30 : value, + }) + : undefined, + }); + ai.userWarGenerals = { 2: user }; + ai.supplyCities = { + 1: { ...baseCity(), id: 1, dev: 1, important: 1 }, + 2: { ...baseCity(), id: 2, population: 10_000, dev: 1, important: 1 }, + 3: { ...baseCity(), id: 3, population: 37_000, dev: 1, important: 1 }, + }; + ai.backupCities = { + 1: ai.supplyCities[1]!, + 3: ai.supplyCities[3]!, + }; + return do유저장후방발령(ai); + }; + + expect(build(false)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 2, destCityId: 3 }, + }); + expect(build(true)).toBeNull(); + }); + + it('waits through recruitment and preparation before sending a user to the front', () => { + const run = (crew: number, train: number, atmos: number) => { + const user = { ...baseGeneral(), id: 2, cityId: 2, npcState: 0, crew, train, atmos }; + const ai = makeAi({ dipState: 4, generals: [baseGeneral(), user] }); + ai.userWarGenerals = { 2: user }; + ai.nationCities = { + 2: { ...baseCity(), id: 2, population: 10_000, dev: 1, important: 1 }, + }; + ai.supplyCities = { + 2: ai.nationCities[2]!, + 3: { ...baseCity(), id: 3, dev: 1, important: 1 }, + }; + ai.backupCities = { 3: ai.supplyCities[3]! }; + ai.frontCities = { + 20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 }, + }; + return { + rear: do유저장후방발령(ai), + front: do유저장전방발령(ai), + }; + }; + + expect(run(0, 0, 0).rear).toMatchObject({ args: { destGeneralId: 2, destCityId: 3 } }); + expect(run(1_500, 0, 0)).toEqual({ rear: null, front: null }); + expect(run(1_500, 90, 0).front).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 2, destCityId: 20 }, + }); + }); + + it('keeps mounted users out of front assignment and draws the prepared user first', () => { + const mounted = { + ...baseGeneral(), + id: 2, + cityId: 2, + npcState: 0, + troopId: 10, + crew: 2_000, + train: 100, + atmos: 100, + }; + const first = { ...mounted, troopId: 0 }; + const second = { ...mounted, id: 3, troopId: 0 }; + const rng = makeRng([], [1, 20]); + const ai = makeAi({ dipState: 4, rng, generals: [baseGeneral(), first, second] }); + ai.userWarGenerals = { 2: first, 3: second }; + ai.nationCities = { 2: { ...baseCity(), id: 2, dev: 1, important: 1 } }; + ai.frontCities = { 20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 } }; + + expect(do유저장전방발령(ai)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 3, destCityId: 20 }, + }); + + ai.userWarGenerals = { 2: mounted }; + expect(do유저장전방발령(ai)).toBeNull(); + }); + + it('preserves intentional defenders and earlier troop escapes during user rescue assignment', () => { + const ready = { + ...baseGeneral(), + id: 2, + npcState: 0, + crew: 2_000, + train: 80, + atmos: 80, + meta: { ...baseGeneral().meta, defence_train: 80 }, + }; + const rider = { ...baseGeneral(), id: 3, npcState: 0, troopId: 10, turnTick: 200 }; + const leader = { ...baseGeneral(), id: 10, npcState: 5, cityId: 5, troopId: 10, turnTick: 100 }; + const first = { ...baseGeneral(), id: 4, npcState: 0 }; + const second = { ...baseGeneral(), id: 5, npcState: 0 }; + const rng = makeRng([], [1, 2, 1]); + const ai = makeAi({ dipState: 4, rng, generals: [baseGeneral(), ready, rider, leader, first, second] }); + ai.lostGenerals = { 2: ready, 3: rider, 4: first, 5: second }; + ai.troopLeaders = { 10: leader }; + ai.supplyCities = { 5: { ...baseCity(), id: 5, dev: 1, important: 1 } }; + ai.frontCities = { + 20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 }, + 21: { ...baseCity(), id: 21, frontState: 3, dev: 1, important: 1 }, + 22: { ...baseCity(), id: 22, frontState: 3, dev: 1, important: 1 }, + }; + + expect(do유저장구출발령(ai)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 5, destCityId: 22 }, + }); + expect(rng.choices).toEqual([]); + }); + it('awards a resource-poor civil user general like the legacy nation AI', () => { const ai = makeAi(); const civilGeneral = { @@ -1379,6 +1586,23 @@ describe('legacy NPC AI final-decision parity', () => { expect(do유저장포상(ai)?.action).toBe('che_포상'); }); + it('never includes user generals in the legacy NPC seizure pool', () => { + const ai = makeAi({ nation: { gold: 1_000, rice: 1_000 } }); + const richUser = { + ...baseGeneral(), + id: 2, + npcState: 0, + gold: 100_000, + rice: 100_000, + }; + ai.userGenerals = { 2: richUser }; + ai.userWarGenerals = { 2: richUser }; + ai.npcWarGenerals = {}; + ai.npcCivilGenerals = {}; + + expect(doNPC몰수(ai)).toBeNull(); + }); + it('consumes the legacy reward draw before a selected command fails constraints', () => { const rng = makeRng(); const ai = makeAi({ rng, blockedActions: ['che_포상'] }); diff --git a/tools/load-tests/README.md b/tools/load-tests/README.md index 7e47387b..a224b002 100644 --- a/tools/load-tests/README.md +++ b/tools/load-tests/README.md @@ -62,7 +62,9 @@ pnpm --filter @sammo-ts/load-tests activate-coverage \ ``` `seed`는 해당 `load_` schema에 migration을 적용하고 scenario 2601을 고정 seed/time으로 설치한 뒤 정확히 -900 NPC + 300 synthetic 사용자 장수로 재구성한다. 각 사용자의 24시간 access token은 Redis 전용 DB와 +900 NPC + 300 synthetic 사용자 장수로 재구성한다. synthetic 사용자는 같은 fixture 국가와 도시에 배치하고 +관직 5 이상을 순환 배정하여 암행부·사령부·감찰부·내무부를 실제 권한으로 읽을 수 있게 한다. 시나리오에 +비중립 국가가 없으면 전용 fixture 안에만 측정 국가 하나를 만든다. 각 사용자의 24시간 access token은 Redis 전용 DB와 새 `0600` JSON에만 저장한다. stdout에는 token, user/general ID, DB/Redis URL을 내보내지 않고 count와 비밀값을 제외한 fixture SHA-256만 기록한다. token 파일이 이미 있으면 DB 작업 전에 실패한다. @@ -83,6 +85,7 @@ pnpm --filter @sammo-ts/game-api build systemd-run --user --unit=sammo-capacity-api --collect \ --property=MemoryMax=8G \ + --setenv=POSTGRES_POOL_MAX=4 \ --working-directory="$(pwd)" \ "$(pwd)/tools/load-tests/scripts/run-capacity-api.sh" ``` @@ -181,7 +184,40 @@ schema 간 row-lock 격리와 공유 CPU/I/O/connection 경합을 확인한다. - `tools/load-tests/config/nya-10-users-800-npcs-1m.json` - `tools/load-tests/config/pya-10-users-800-npcs-1m.json` -### 5. 명시적 cleanup +### 5. 1분 턴과 10-user 실제 화면 동시 측정 + +`measure-turn-cycle`은 fixture의 권위 `turn_time` 분포대로 장수 턴을 wall clock 1분에 pacing하고 월 경계를 +한 번 처리한다. `measure-page-navigation`은 실제 Chromium context 10개가 암행부, 사령부, 현재 도시, +감찰부, 내무부를 순환하며 페이지와 tRPC 지연을 기록한다. 화면 이동 중에도 사용자마다 별도 SSE 한 개를 +계속 유지한다. 이는 제품 UI가 메인 화면 밖에서 dashboard SSE를 닫는 동작과 “각 사용자가 실시간 알림을 +계속 받는다”는 부하 조건을 분리해 재현하기 위한 구성이다. + +두 명령에 같은 미래 `LOAD_TEST_START_AT_EPOCH_MS`를 주면 턴과 브라우저 측정 구간을 맞출 수 있다. +`LOAD_TEST_FRONTEND_URL`은 private/loopback URL과 정확한 profile suffix여야 하며, +`LOAD_TEST_API_PID`는 같은 workspace에서 실행 중인 game-api PID여야 한다. 결과에는 token, 응답 본문과 +사용자 식별자를 넣지 않는다. + +```sh +export LOAD_TEST_START_AT_EPOCH_MS=REPLACE_WITH_NEAR_FUTURE_EPOCH_MS +export LOAD_TEST_FRONTEND_URL=http://127.0.0.1:15000/pya/ +export LOAD_TEST_API_PID=REPLACE_WITH_LOCAL_GAME_API_PID + +pnpm --filter @sammo-ts/load-tests measure-page-navigation \ + --config tools/load-tests/config/pya-10-users-800-npcs-1m.json \ + --tokens tools/load-tests/secrets/game-tokens.json \ + --output tools/load-tests/results/page-navigation.json + +pnpm --filter @sammo-ts/load-tests measure-turn-cycle \ + --config tools/load-tests/config/pya-10-users-800-npcs-1m.json \ + --confirm load_capacity_pya_10_800_1m \ + --output tools/load-tests/results/turn-cycle.json +``` + +페이지 latency는 production frontend의 lazy chunk, 인증 초기화와 Playwright `networkidle` 500ms를 모두 +포함하므로 API procedure latency와 함께 해석한다. API CPU/RSS는 지정 PID만, PostgreSQL 지표는 같은 DB의 +observer를 포함한 database-wide delta만 나타낸다. 호스트의 다른 profile, Gateway와 worker 부하는 포함하지 않는다. + +### 6. 명시적 cleanup token 파일은 별도로 안전하게 삭제하고, fixture schema/Redis token은 schema명을 그대로 확인 인자로 주어 정리한다. named volume은 보존한다. 데이터 폐기가 필요하지 않으면 이 명령을 실행하지 않는다. @@ -195,8 +231,8 @@ docker compose -f tools/load-tests/compose.capacity.yml down ## 아직 남은 측정 경계 -- `measure-turn-flush`는 한 달을 wall-clock보다 빠르게 replay하는 처리량 시험이다. 실제 schedule lag, - 장시간 pool wait와 autovacuum/checkpoint 영향을 보려면 profile별 속도로 pacing한 soak가 별도로 필요하다. +- `measure-turn-flush`는 한 달을 wall-clock보다 빠르게 replay하는 처리량 시험이다. 실제 1분 schedule lag는 + `measure-turn-cycle`로 확인할 수 있지만 장시간 pool wait와 autovacuum/checkpoint는 더 긴 soak가 필요하다. - 월 경계 latency는 실행당 표본이 하나다. scenario 진행 시점과 월별 event 차이를 포괄하지 않는다. - 두 1분 profile 동시 실행은 최악 turn-rate 조합의 국소 증거이며, 여섯 profile의 전체 PM2 RSS, Gateway·worker connection과 운영 container cgroup을 재현하지 않는다. diff --git a/tools/load-tests/package.json b/tools/load-tests/package.json index 8c9c5d7f..ab8b32a4 100644 --- a/tools/load-tests/package.json +++ b/tools/load-tests/package.json @@ -11,6 +11,8 @@ "seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed", "verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture", "activate-coverage": "pnpm -w exec tsx tools/load-tests/src/cli.ts activate-coverage", + "measure-page-navigation": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-page-navigation", + "measure-turn-cycle": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-turn-cycle", "measure-turn-flush": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-turn-flush", "materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration", "cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup", diff --git a/tools/load-tests/src/cli.ts b/tools/load-tests/src/cli.ts index c6c9f5c5..455b4066 100644 --- a/tools/load-tests/src/cli.ts +++ b/tools/load-tests/src/cli.ts @@ -10,6 +10,7 @@ import { seedCapacityFixture, verifyCapacityFixture, } from './fixture.js'; +import { measurePageNavigation } from './pageNavigation.js'; import { describeDryRun, runLoadTest } from './runner.js'; import { measureTurnFlush } from './turnFlush.js'; @@ -21,13 +22,15 @@ type Command = | 'seed' | 'verify-fixture' | 'activate-coverage' + | 'measure-page-navigation' + | 'measure-turn-cycle' | 'measure-turn-flush' | 'materialize-calibration' | 'cleanup'; const usage = (): never => { process.stderr.write( - 'usage: cli.ts --config [--tokens <0600-gitignored-file>] [--output ] [--confirm ]\n' + 'usage: cli.ts --config [--tokens <0600-gitignored-file>] [--output ] [--confirm ]\n' ); process.exit(64); }; @@ -45,6 +48,8 @@ const parseArguments = ( 'seed', 'verify-fixture', 'activate-coverage', + 'measure-page-navigation', + 'measure-turn-cycle', 'measure-turn-flush', 'materialize-calibration', 'cleanup', @@ -73,7 +78,12 @@ const parseArguments = ( ) usage(); if ( - command === 'measure-turn-flush' && + command === 'measure-page-navigation' && + (!values.get('--tokens') || !values.get('--output') || values.has('--confirm')) + ) + usage(); + if ( + (command === 'measure-turn-flush' || command === 'measure-turn-cycle') && (!values.get('--confirm') || !values.get('--output') || values.has('--tokens')) ) usage(); @@ -124,10 +134,14 @@ const main = async (): Promise => { process.stdout.write(`${JSON.stringify(await activateCapacityCoverage(config, args.confirm!))}\n`); return; } - if (args.command === 'measure-turn-flush') { + if (args.command === 'measure-turn-flush' || args.command === 'measure-turn-cycle') { const output = path.resolve(args.output!); await mkdir(path.dirname(output), { recursive: true }); - const result = await measureTurnFlush({ config, confirmation: args.confirm! }); + const result = await measureTurnFlush({ + config, + confirmation: args.confirm!, + paced: args.command === 'measure-turn-cycle', + }); await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', @@ -138,6 +152,21 @@ const main = async (): Promise => { ); return; } + if (args.command === 'measure-page-navigation') { + const tokens = await loadTokens(args.tokens!, workspaceRoot, config.capacity.authenticatedViewers); + const output = path.resolve(args.output!); + await mkdir(path.dirname(output), { recursive: true }); + const result = await measurePageNavigation({ config, tokens, workspaceRoot }); + await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + process.stdout.write( + `${JSON.stringify({ completed: true, viewers: result.fixture.viewers, outputWritten: true })}\n` + ); + return; + } if (args.command === 'materialize-calibration') { process.stdout.write( `${JSON.stringify( diff --git a/tools/load-tests/src/fixture.ts b/tools/load-tests/src/fixture.ts index 17626b34..d688c835 100644 --- a/tools/load-tests/src/fixture.ts +++ b/tools/load-tests/src/fixture.ts @@ -182,9 +182,24 @@ const migrateDedicatedSchema = async (workspaceRoot: string, databaseUrl: string type GeneralRow = Awaited>[number]; +const PRIVILEGED_VIEWER_LEVELS = [12, 10, 8, 6, 11, 9, 7, 5] as const; + +export const privilegedViewerPlacement = (index: number, nationId: number, cityId: number) => ({ + nationId, + cityId, + officerLevel: PRIVILEGED_VIEWER_LEVELS[index % PRIVILEGED_VIEWER_LEVELS.length]!, +}); + const cloneGeneral = ( source: GeneralRow, - input: { id: number; userId: string | null; npcState: number } + input: { + id: number; + userId: string | null; + npcState: number; + nationId?: number; + cityId?: number; + officerLevel?: number; + } ): GamePrisma.GeneralCreateManyInput => ({ ...source, @@ -192,6 +207,9 @@ const cloneGeneral = ( name: `${source.name}#L${input.id}`, userId: input.userId, npcState: input.npcState, + nationId: input.nationId ?? source.nationId, + cityId: input.cityId ?? source.cityId, + officerLevel: input.officerLevel ?? source.officerLevel, turnTime: new Date(source.turnTime), recentWarTime: source.recentWarTime ? new Date(source.recentWarTime) : null, createdAt: FIXED_NOW, @@ -237,8 +255,17 @@ const projectFixtureState = async (db: GamePrismaClient) => { }; const resizeSeededGenerals = async (db: GamePrismaClient, config: LoadConfig): Promise => { - const source = await db.general.findMany({ orderBy: { id: 'asc' } }); + const [source, nations, cities] = await Promise.all([ + db.general.findMany({ orderBy: { id: 'asc' } }), + db.nation.findMany({ orderBy: { id: 'asc' } }), + db.city.findMany({ orderBy: { id: 'asc' } }), + ]); if (source.length === 0) throw new Error('scenario seed produced no generals'); + const existingViewerNation = nations.find((nation) => nation.id > 0); + const viewerCity = + (existingViewerNation ? cities.find((city) => city.nationId === existingViewerNation.id) : null) ?? cities[0]; + if (!viewerCity) throw new Error('scenario seed produced no city for privileged page load'); + const viewerNationId = existingViewerNation?.id ?? Math.max(...nations.map((nation) => nation.id), 0) + 1; const expectedNpc = config.capacity.npcGenerals; const expectedHuman = config.capacity.humanGenerals; if (expectedHuman !== config.capacity.authenticatedViewers) { @@ -246,20 +273,43 @@ const resizeSeededGenerals = async (db: GamePrismaClient, config: LoadConfig): P } await db.$transaction(async (transaction) => { await transaction.general.deleteMany(); + if (!existingViewerNation) { + await transaction.nation.create({ + data: { + id: viewerNationId, + name: '부하측정국', + color: '#334466', + capitalCityId: viewerCity.id, + gold: 100_000, + rice: 100_000, + tech: 1_000, + level: 1, + typeCode: 'che_중립', + meta: { cityIds: [viewerCity.id], infoText: null, secretlimit: 3 }, + }, + }); + } + await transaction.city.update({ where: { id: viewerCity.id }, data: { nationId: viewerNationId } }); const rows: GamePrisma.GeneralCreateManyInput[] = []; for (let index = 0; index < expectedNpc; index += 1) { rows.push(cloneGeneral(source[index % source.length]!, { id: index + 1, userId: null, npcState: 2 })); } for (let index = 0; index < expectedHuman; index += 1) { + const placement = privilegedViewerPlacement(index, viewerNationId, viewerCity.id); rows.push( cloneGeneral(source[(expectedNpc + index) % source.length]!, { id: expectedNpc + index + 1, userId: `load-user-${String(index + 1).padStart(4, '0')}`, npcState: 0, + ...placement, }) ); } await transaction.general.createMany({ data: rows }); + await transaction.nation.update({ + where: { id: viewerNationId }, + data: { chiefGeneralId: expectedNpc + 1 }, + }); const world = await transaction.worldState.findFirstOrThrow({ select: { id: true, meta: true, config: true } }); await transaction.worldState.update({ where: { id: world.id }, @@ -435,10 +485,17 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc const redisVersion = /^redis_version:(.+)$/mu.exec(redisInfo)?.[1]?.trim() ?? 'unknown'; const npcGenerals = state.generals.filter((general) => general.npcState >= 2).length; const humanGenerals = state.generals.filter((general) => general.npcState === 0 && general.userId).length; + const privilegedHumanGenerals = state.generals.filter( + (general) => + general.npcState === 0 && general.userId && general.nationId > 0 && general.officerLevel >= 5 + ); + const viewerNationIds = [...new Set(privilegedHumanGenerals.map((general) => general.nationId))]; const valid = state.generals.length === config.capacity.npcGenerals + config.capacity.humanGenerals && npcGenerals === config.capacity.npcGenerals && humanGenerals === config.capacity.humanGenerals && + privilegedHumanGenerals.length === config.capacity.humanGenerals && + viewerNationIds.length === 1 && accessTokens === config.capacity.authenticatedViewers && manifestFixtureSha256 === fixtureSha256; return { @@ -447,6 +504,8 @@ export const verifyCapacityFixture = async (config: LoadConfig, env: NodeJS.Proc generals: state.generals.length, npcGenerals, humanGenerals, + privilegedHumanGenerals: privilegedHumanGenerals.length, + viewerNations: viewerNationIds.length, accessTokens, redisManifestPresent: rawManifest !== null, redisManifestMatches: manifestFixtureSha256 === fixtureSha256, diff --git a/tools/load-tests/src/pageNavigation.ts b/tools/load-tests/src/pageNavigation.ts new file mode 100644 index 00000000..c86ee631 --- /dev/null +++ b/tools/load-tests/src/pageNavigation.ts @@ -0,0 +1,380 @@ +import { execFile } from 'node:child_process'; +import { readFile, realpath } from 'node:fs/promises'; +import { promisify } from 'node:util'; + +import { chromium, type Page } from '@playwright/test'; + +import { isPrivateTargetHost, type LoadConfig } from './config.js'; +import { summarizeDistribution } from './metrics.js'; +import { PhaseMetrics } from './metrics.js'; +import { runSseConnection } from './sse.js'; + +const execFileAsync = promisify(execFile); + +const PAGE_ROUTES = [ + { name: 'nation-secret', path: 'nation/secret' }, + { name: 'chief-center', path: 'chief-center' }, + { name: 'current-city', path: 'current-city' }, + { name: 'battle-center', path: 'battle-center' }, + { name: 'nation-finance', path: 'nation/finance' }, +] as const; + +const wait = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const increment = (target: Map, key: string): void => { + target.set(key, (target.get(key) ?? 0) + 1); +}; + +const mapToObject = (target: ReadonlyMap): Record => + Object.fromEntries([...target.entries()].sort(([left], [right]) => left.localeCompare(right))); + +export const parseTrpcProcedures = (requestUrl: string, trpcPath: string): string[] => { + const pathname = new URL(requestUrl).pathname; + const prefix = `${trpcPath.replace(/\/$/u, '')}/`; + if (!pathname.startsWith(prefix)) return []; + return decodeURIComponent(pathname.slice(prefix.length)) + .split(',') + .filter((procedure) => /^[A-Za-z][A-Za-z0-9_.]+$/u.test(procedure)); +}; + +type ProcessStat = { cpuTicks: number; rssPages: number; startTicks: number }; + +const readProcessStat = async (pid: number): Promise => { + const raw = await readFile(`/proc/${pid}/stat`, 'utf8'); + const commandEnd = raw.lastIndexOf(')'); + if (commandEnd < 0) throw new Error('LOAD_TEST_API_PID has an invalid /proc stat record'); + const fields = raw.slice(commandEnd + 2).trim().split(/\s+/u); + const cpuTicks = Number(fields[11]) + Number(fields[12]); + const startTicks = Number(fields[19]); + const rssPages = Number(fields[21]); + if (![cpuTicks, startTicks, rssPages].every(Number.isFinite)) { + throw new Error('LOAD_TEST_API_PID has an incomplete /proc stat record'); + } + return { cpuTicks, startTicks, rssPages }; +}; + +const startApiProcessSampler = async (pid: number, workspaceRoot: string) => { + const [commandLine, processCwd] = await Promise.all([ + readFile(`/proc/${pid}/cmdline`, 'utf8').then((value) => value.replaceAll('\0', ' ')), + realpath(`/proc/${pid}/cwd`), + ]); + if (!commandLine.includes('game-api') || processCwd !== workspaceRoot) { + throw new Error('LOAD_TEST_API_PID must be the game-api process from this workspace'); + } + const [{ stdout: clockText }, { stdout: pageText }, initial] = await Promise.all([ + execFileAsync('getconf', ['CLK_TCK']), + execFileAsync('getconf', ['PAGESIZE']), + readProcessStat(pid), + ]); + const clockTicks = Number(clockText.trim()); + const pageSize = Number(pageText.trim()); + if (!Number.isFinite(clockTicks) || !Number.isFinite(pageSize)) { + throw new Error('could not determine process accounting units'); + } + let sampling = true; + let maxRssPages = initial.rssPages; + let samples = 1; + const startedNs = process.hrtime.bigint(); + const loop = (async () => { + while (sampling) { + await wait(250); + if (!sampling) break; + const sample = await readProcessStat(pid); + if (sample.startTicks !== initial.startTicks) throw new Error('game-api process changed during measurement'); + maxRssPages = Math.max(maxRssPages, sample.rssPages); + samples += 1; + } + })(); + return async () => { + sampling = false; + await loop; + const final = await readProcessStat(pid); + if (final.startTicks !== initial.startTicks) throw new Error('game-api process changed during measurement'); + const elapsedSeconds = Number(process.hrtime.bigint() - startedNs) / 1_000_000_000; + const cpuSeconds = (final.cpuTicks - initial.cpuTicks) / clockTicks; + return { + pid, + samples, + elapsedMs: Math.round(elapsedSeconds * 1000), + cpuSeconds: Math.round(cpuSeconds * 1000) / 1000, + cpuPercentOfOneCore: Math.round((cpuSeconds / Math.max(elapsedSeconds, 0.001)) * 1000) / 10, + rssBytes: { + initial: initial.rssPages * pageSize, + final: final.rssPages * pageSize, + max: maxRssPages * pageSize, + }, + }; + }; +}; + +const readHealth = async (baseUrl: string) => { + const response = await fetch(new URL('/healthz', baseUrl)); + if (!response.ok) throw new Error(`game-api health returned HTTP ${response.status}`); + const body = (await response.json()) as Record; + const pool = body.postgresPool; + return { + ok: body.ok === true, + postgresPool: + typeof pool === 'object' && pool !== null + ? Object.fromEntries( + ['max', 'total', 'active', 'idle', 'waiting'] + .map((key) => [key, (pool as Record)[key]]) + .filter(([, value]) => typeof value === 'number') + ) + : {}, + }; +}; + +const assertFrontendUrl = (value: string, config: LoadConfig): URL => { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || !isPrivateTargetHost(url.hostname)) { + throw new Error('LOAD_TEST_FRONTEND_URL must use HTTP(S) on a private or loopback host'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('LOAD_TEST_FRONTEND_URL must not contain credentials, query, or fragment'); + } + const expectedSuffix = `/${config.isolation.postgresSchema.split('_')[2] ?? ''}/`; + if (!url.pathname.endsWith(expectedSuffix)) { + throw new Error(`LOAD_TEST_FRONTEND_URL must end with the configured profile path ${expectedSuffix}`); + } + return url; +}; + +export const measurePageNavigation = async (options: { + config: LoadConfig; + tokens: readonly string[]; + workspaceRoot: string; + env?: NodeJS.ProcessEnv; +}) => { + const env = options.env ?? process.env; + const frontendUrl = assertFrontendUrl(env.LOAD_TEST_FRONTEND_URL ?? '', options.config); + const apiPid = Number(env.LOAD_TEST_API_PID); + if (!Number.isSafeInteger(apiPid) || apiPid <= 1) throw new Error('LOAD_TEST_API_PID must be a positive process id'); + if (options.tokens.length !== options.config.capacity.authenticatedViewers) { + throw new Error('page navigation requires exactly one token per authenticated viewer'); + } + const dwellMs = Number(env.LOAD_TEST_PAGE_DWELL_MS ?? '1000'); + if (!Number.isSafeInteger(dwellMs) || dwellMs < 250 || dwellMs > 10_000) { + throw new Error('LOAD_TEST_PAGE_DWELL_MS must be an integer from 250 to 10000'); + } + + const routeLatencyMs = new Map(); + const routeSuccess = new Map(); + const routeErrors = new Map(); + const procedureRequests = new Map(); + const procedureErrors = new Map(); + const procedureLatencyMs = new Map(); + let pageErrors = 0; + let permissionErrors = 0; + const pendingResponses = new Set>(); + const browser = await chromium.launch({ headless: true }); + const viewers: Array<{ page: Page; close: () => Promise }> = []; + const activeRouteByPage = new Map(); + let activeSseController: AbortController | null = null; + let activeSseTasks: Promise[] = []; + let activeSseSampleTimer: NodeJS.Timeout | null = null; + const profile = options.config.isolation.postgresSchema.split('_')[2] ?? ''; + try { + for (const token of options.tokens) { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await context.addInitScript( + ({ accessToken, profileName }) => { + const storage = ( + globalThis as unknown as { localStorage: { setItem: (key: string, value: string) => void } } + ).localStorage; + storage.setItem('sammo-game-token', accessToken); + storage.setItem('sammo-game-profile', profileName); + }, + { accessToken: token, profileName: profile } + ); + const page = await context.newPage(); + page.setDefaultTimeout(20_000); + page.setDefaultNavigationTimeout(20_000); + await page.goto(new URL('current-city', frontendUrl).toString(), { waitUntil: 'networkidle' }); + const warmupText = await page.locator('body').innerText(); + if (warmupText.includes('권한이 부족합니다')) { + throw new Error('privileged viewer fixture failed the current-city warmup'); + } + viewers.push({ page, close: () => context.close() }); + } + + const sseMetrics = new PhaseMetrics(); + const sseController = new AbortController(); + activeSseController = sseController; + let activeSse = 0; + sseMetrics.sseActiveConnections.push(0); + const sseUrl = new URL(options.config.target.ssePath, options.config.target.baseUrl).toString(); + const sseTasks = options.tokens.map((token) => + runSseConnection({ + url: sseUrl, + token, + signal: sseController.signal, + metrics: sseMetrics, + onActiveChange: (delta) => { + activeSse += delta; + sseMetrics.sseActiveConnections.push(activeSse); + }, + }) + ); + activeSseTasks = sseTasks; + const connectionDeadline = Date.now() + 10_000; + while (activeSse !== options.tokens.length && Date.now() < connectionDeadline) await wait(50); + if (activeSse !== options.tokens.length) { + sseController.abort(); + await Promise.allSettled(sseTasks); + throw new Error(`only ${activeSse} of ${options.tokens.length} SSE connections became active`); + } + + const healthBefore = await readHealth(options.config.target.baseUrl); + let scheduledStartAtEpochMs: number | null = null; + if (env.LOAD_TEST_START_AT_EPOCH_MS) { + scheduledStartAtEpochMs = Number(env.LOAD_TEST_START_AT_EPOCH_MS); + if (!Number.isSafeInteger(scheduledStartAtEpochMs)) { + throw new Error('LOAD_TEST_START_AT_EPOCH_MS must be an integer epoch in milliseconds'); + } + const waitMs = scheduledStartAtEpochMs - Date.now(); + if (waitMs > 120_000) throw new Error('LOAD_TEST_START_AT_EPOCH_MS must be within the next two minutes'); + if (waitMs > 0) await wait(waitMs); + } + const stopApiSampler = await startApiProcessSampler(apiPid, options.workspaceRoot); + const startedAt = new Date().toISOString(); + const started = performance.now(); + const deadline = started + options.config.capacity.turnIntervalMs; + const activeSseDuringMeasurement = [activeSse]; + activeSseSampleTimer = setInterval(() => activeSseDuringMeasurement.push(activeSse), 250); + + for (const [viewerIndex, viewer] of viewers.entries()) { + activeRouteByPage.set(viewer.page, 'warmup'); + viewer.page.on('pageerror', () => { + pageErrors += 1; + }); + viewer.page.on('response', (response) => { + const procedures = parseTrpcProcedures(response.url(), options.config.target.trpcPath); + if (procedures.length === 0) return; + const task = (async () => { + await response.finished().catch(() => null); + const timing = response.request().timing(); + const latencyMs = timing.responseEnd; + for (const procedure of procedures) { + const key = `${activeRouteByPage.get(viewer.page) ?? 'unknown'}:${procedure}`; + increment(procedureRequests, key); + if (response.status() >= 400) increment(procedureErrors, `${key}:http-${response.status()}`); + if (latencyMs >= 0) { + const values = procedureLatencyMs.get(key) ?? []; + values.push(latencyMs); + procedureLatencyMs.set(key, values); + } + } + })().finally(() => pendingResponses.delete(task)); + pendingResponses.add(task); + }); + void viewerIndex; + } + + await Promise.all( + viewers.map(async ({ page }, viewerIndex) => { + let iteration = 0; + while (performance.now() < deadline) { + const route = PAGE_ROUTES[(viewerIndex + iteration) % PAGE_ROUTES.length]!; + activeRouteByPage.set(page, route.name); + const routeStarted = performance.now(); + try { + await page.goto(new URL(route.path, frontendUrl).toString(), { waitUntil: 'networkidle' }); + const bodyText = await page.locator('body').innerText(); + if (bodyText.includes('권한이 부족합니다')) { + permissionErrors += 1; + increment(routeErrors, `${route.name}:permission`); + } else { + increment(routeSuccess, route.name); + } + } catch { + increment(routeErrors, `${route.name}:navigation`); + } finally { + const values = routeLatencyMs.get(route.name) ?? []; + values.push(performance.now() - routeStarted); + routeLatencyMs.set(route.name, values); + } + iteration += 1; + if (performance.now() < deadline) await wait(dwellMs); + } + }) + ); + clearInterval(activeSseSampleTimer); + activeSseSampleTimer = null; + activeSseDuringMeasurement.push(activeSse); + await Promise.allSettled([...pendingResponses]); + const apiProcess = await stopApiSampler(); + const elapsedMs = Math.round(performance.now() - started); + const healthAfter = await readHealth(options.config.target.baseUrl); + sseController.abort(); + await Promise.allSettled(sseTasks); + sseMetrics.sseActiveConnections.push(activeSse); + + return { + formatVersion: 1, + startedAt, + finishedAt: new Date().toISOString(), + configuredDurationMs: options.config.capacity.turnIntervalMs, + elapsedMs, + scheduling: { + scheduledStartAtEpochMs, + actualStartAtEpochMs: Date.parse(startedAt), + startDelayMs: + scheduledStartAtEpochMs === null ? null : Math.max(0, Date.parse(startedAt) - scheduledStartAtEpochMs), + }, + fixture: { + name: options.config.name, + viewers: options.tokens.length, + npcGenerals: options.config.capacity.npcGenerals, + pageDwellMs: dwellMs, + }, + browser: { + name: 'chromium', + contexts: viewers.length, + viewport: { width: 1280, height: 900 }, + routes: Object.fromEntries( + PAGE_ROUTES.map((route) => [ + route.name, + { + success: routeSuccess.get(route.name) ?? 0, + latencyMs: summarizeDistribution(routeLatencyMs.get(route.name) ?? []), + }, + ]) + ), + routeErrors: mapToObject(routeErrors), + pageErrors, + permissionErrors, + }, + trpc: { + requests: mapToObject(procedureRequests), + errors: mapToObject(procedureErrors), + latencyMs: Object.fromEntries( + [...procedureLatencyMs.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => [key, summarizeDistribution(values)]) + ), + }, + sse: { + attempts: sseMetrics.sseAttempts, + opened: sseMetrics.sseOpened, + closed: sseMetrics.sseClosed, + reconnects: sseMetrics.sseReconnects, + failures: sseMetrics.sseFailures, + privacyViolations: sseMetrics.ssePrivacyViolations, + events: mapToObject(sseMetrics.sseEvents), + activeConnections: summarizeDistribution(activeSseDuringMeasurement), + }, + server: { + healthBefore, + healthAfter, + apiProcess, + }, + }; + } finally { + if (activeSseSampleTimer) clearInterval(activeSseSampleTimer); + activeSseController?.abort(); + await Promise.allSettled(activeSseTasks); + await Promise.allSettled(viewers.map((viewer) => viewer.close())); + await browser.close(); + } +}; diff --git a/tools/load-tests/src/turnFlush.ts b/tools/load-tests/src/turnFlush.ts index 74765594..258469b1 100644 --- a/tools/load-tests/src/turnFlush.ts +++ b/tools/load-tests/src/turnFlush.ts @@ -105,6 +105,7 @@ const includeSubMillisecondGameTick = (turnTime: Date): Date => export const measureTurnFlush = async (options: { config: LoadConfig; confirmation: string; + paced?: boolean; env?: NodeJS.ProcessEnv; }) => { const env = options.env ?? process.env; @@ -153,9 +154,8 @@ export const measureTurnFlush = async (options: { const activityPromise = sampleActivity(); const histogram = monitorEventLoopDelay({ resolution: 20 }); - histogram.enable(); - const cpuStart = process.cpuUsage(); - const wallStartNs = process.hrtime.bigint(); + let cpuStart: NodeJS.CpuUsage | null = null; + let wallStartNs: bigint | null = null; let maxRssBytes = process.memoryUsage().rss; const generalTransactionMs: number[] = []; const monthlyTransactionMs: number[] = []; @@ -167,6 +167,10 @@ export const measureTurnFlush = async (options: { let endYearMonth: string | null = null; let initialGeneralCount: number | null = null; let finalGeneralCount: number | null = null; + let scheduledStartAtEpochMs: number | null = null; + let actualStartAtEpochMs: number | null = null; + const scheduleLagMs: number[] = []; + let backdatedGeneralTurns = 0; let runError: unknown; try { @@ -189,6 +193,21 @@ export const measureTurnFlush = async (options: { const boundary = getNextTickTime(initialState.lastTurnTime, tickMinutes); let checkpoint: TurnCheckpoint | undefined = await runtime.stateStore.loadCheckpoint(); + if (env.LOAD_TEST_START_AT_EPOCH_MS) { + scheduledStartAtEpochMs = Number(env.LOAD_TEST_START_AT_EPOCH_MS); + if (!Number.isSafeInteger(scheduledStartAtEpochMs)) { + throw new Error('LOAD_TEST_START_AT_EPOCH_MS must be an integer epoch in milliseconds'); + } + const waitMs = scheduledStartAtEpochMs - Date.now(); + if (waitMs > 120_000) throw new Error('LOAD_TEST_START_AT_EPOCH_MS must be within the next two minutes'); + if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + actualStartAtEpochMs = Date.now(); + const pacingStarted = performance.now(); + histogram.enable(); + cpuStart = process.cpuUsage(); + wallStartNs = process.hrtime.bigint(); + const execute = async (target: Date, maxGenerals: number): Promise => { const started = performance.now(); const result = await runtime!.stateManager.transaction(async () => { @@ -219,6 +238,16 @@ export const measureTurnFlush = async (options: { while (true) { const nextGeneral = await runtime.stateStore.loadNextGeneralTurnTime(); if (!nextGeneral || nextGeneral.getTime() >= boundary.getTime()) break; + if (options.paced) { + const targetElapsedMs = nextGeneral.getTime() - initialState.lastTurnTime.getTime(); + if (targetElapsedMs < 0) { + backdatedGeneralTurns += 1; + } else { + const remainingMs = targetElapsedMs - (performance.now() - pacingStarted); + if (remainingMs > 0) await new Promise((resolve) => setTimeout(resolve, remainingMs)); + scheduleLagMs.push(Math.max(0, performance.now() - pacingStarted - targetElapsedMs)); + } + } const result = await execute(includeSubMillisecondGameTick(nextGeneral), 1); if (result.processedGenerals !== 1 || result.processedTurns !== 0) { throw new Error( @@ -227,6 +256,11 @@ export const measureTurnFlush = async (options: { } } + if (options.paced) { + const remainingMs = + options.config.capacity.turnIntervalMs - (performance.now() - pacingStarted); + if (remainingMs > 0) await new Promise((resolve) => setTimeout(resolve, remainingMs)); + } const monthly = await execute(boundary, 200); if (monthly.processedTurns !== 1) { throw new Error('turn-flush measurement did not cross exactly one monthly boundary'); @@ -275,6 +309,9 @@ export const measureTurnFlush = async (options: { } await observer.disconnect(); histogram.disable(); + if (cpuStart === null || wallStartNs === null || actualStartAtEpochMs === null) { + throw new Error('turn-flush measurement did not start its measured interval'); + } const elapsedMs = Number(process.hrtime.bigint() - wallStartNs) / 1_000_000; const cpu = process.cpuUsage(cpuStart); const cpuMs = (cpu.user + cpu.system) / 1_000; @@ -287,7 +324,9 @@ export const measureTurnFlush = async (options: { fixtureSha256: fixture.fixtureSha256, capacity: options.config.capacity, }, - mode: 'chronological-one-general-per-transaction-plus-month-boundary', + mode: options.paced + ? 'wall-clock-paced-one-general-per-transaction-plus-month-boundary' + : 'chronological-one-general-per-transaction-plus-month-boundary', startYearMonth, endYearMonth, elapsedMs: round(elapsedMs), @@ -306,6 +345,16 @@ export const measureTurnFlush = async (options: { monthlyTransaction: summarizeDistribution(monthlyTransactionMs), redisPublication: summarizeDistribution(publicationMs), }, + scheduling: { + paced: options.paced === true, + configuredTurnIntervalMs: options.config.capacity.turnIntervalMs, + scheduledStartAtEpochMs, + actualStartAtEpochMs, + startDelayMs: + scheduledStartAtEpochMs === null ? null : Math.max(0, actualStartAtEpochMs - scheduledStartAtEpochMs), + backdatedGeneralTurns, + generalScheduleLagMs: summarizeDistribution(scheduleLagMs), + }, postgres: { statsScope: 'database-wide-including-observer-sampler', statsDelta: subtractDatabaseStats(beforeStats, afterStats), diff --git a/tools/load-tests/test/fixture.test.ts b/tools/load-tests/test/fixture.test.ts index 3a32b598..3d2a0766 100644 --- a/tools/load-tests/test/fixture.test.ts +++ b/tools/load-tests/test/fixture.test.ts @@ -5,10 +5,22 @@ import path from 'node:path'; import test from 'node:test'; import { validateLoadConfig } from '../src/config.js'; -import { activateCapacityCoverage, assertFixtureIsolation, prepareCapacitySecrets } from '../src/fixture.js'; +import { + activateCapacityCoverage, + assertFixtureIsolation, + prepareCapacitySecrets, + privilegedViewerPlacement, +} from '../src/fixture.js'; const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url); +void test('viewer placement stays in one nation and covers every privileged chief level deterministically', () => { + assert.deepEqual( + Array.from({ length: 10 }, (_, index) => privilegedViewerPlacement(index, 3, 7)), + [12, 10, 8, 6, 11, 9, 7, 5, 12, 10].map((officerLevel) => ({ nationId: 3, cityId: 7, officerLevel })) + ); +}); + void test('fixture accepts only the configured private schema and dedicated Redis database', async () => { const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8'))); assert.doesNotThrow(() => diff --git a/tools/load-tests/test/pageNavigation.test.ts b/tools/load-tests/test/pageNavigation.test.ts new file mode 100644 index 00000000..c0cbbd4a --- /dev/null +++ b/tools/load-tests/test/pageNavigation.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseTrpcProcedures } from '../src/pageNavigation.js'; + +void test('tRPC page-load requests are attributed for single and batched procedures', () => { + assert.deepEqual( + parseTrpcProcedures('http://127.0.0.1:15001/api/trpc/auth.status?batch=1&input=%7B%7D', '/api/trpc'), + ['auth.status'] + ); + assert.deepEqual( + parseTrpcProcedures( + 'http://127.0.0.1:15001/api/trpc/world.getMap%2Cworld.getMapLayout?batch=1', + '/api/trpc' + ), + ['world.getMap', 'world.getMapLayout'] + ); + assert.deepEqual(parseTrpcProcedures('http://127.0.0.1:15001/healthz', '/api/trpc'), []); +});