diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 39043bad..b6a20aba 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -349,6 +349,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number city, nation, nationGenerals, + realNationCount: nations.filter((entry) => entry.id > 0).length, inputOptions, }); }; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index c37374b6..6e3fc6f3 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -315,6 +315,31 @@ const resolveOptionalString = (source: Record, keys: string[]): return null; }; +const DEFAULT_MAX_NATION = 55; + +const resolveMaxNation = (worldState: WorldStateRow): number => { + const config = asRecord(worldState.config); + const constValues = asRecord(config.const); + const meta = asRecord(worldState.meta); + const refGameEnv = asRecord(meta.refGameEnv); + const candidates = [ + refGameEnv.maxnation, + refGameEnv.maxNation, + config.maxnation, + config.maxNation, + constValues.defaultMaxNation, + constValues.maxNation, + ]; + for (const candidate of candidates) { + const parsed = + typeof candidate === 'number' ? candidate : typeof candidate === 'string' ? Number(candidate) : Number.NaN; + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.floor(parsed); + } + } + return DEFAULT_MAX_NATION; +}; + const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { const config = asRecord(worldState.config); const constValues = asRecord(config.const); @@ -630,7 +655,13 @@ const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): Availab type TurnCommandSpec = GeneralTurnCommandSpec | NationTurnCommandSpec; -const buildEntries = (env: CommandEnv, specs: TurnCommandSpec[]): CommandEntry[] => { +const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위건국']); + +const buildEntries = ( + env: CommandEnv, + specs: TurnCommandSpec[], + options: { foundingAvailable?: boolean } = {} +): CommandEntry[] => { const entries: CommandEntry[] = []; for (const spec of specs) { @@ -659,6 +690,14 @@ const buildEntries = (env: CommandEnv, specs: TurnCommandSpec[]): CommandEntry[] }; } + if (FOUNDING_COMMAND_KEYS.has(spec.key) && options.foundingAvailable === false) { + entry.evaluate = () => ({ + possible: false, + status: 'blocked', + reason: '더 이상 건국은 불가능합니다.', + }); + } + entries.push(entry); } @@ -740,6 +779,8 @@ export const buildTurnCommandTable = async (options: { city: CityRow | null; nation: NationRow | null; nationGenerals: GeneralRow[] | null; + /** Ref's nation-table row count. Core callers must exclude synthetic id=0. */ + realNationCount?: number; inputOptions?: TurnCommandInputOptions; }): Promise => { // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. @@ -761,7 +802,12 @@ export const buildTurnCommandTable = async (options: { const env = buildCommandEnv(options.worldState); const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs(); - const generalEntries = buildEntries(env, generalSpecs); + const generalEntries = buildEntries(env, generalSpecs, { + foundingAvailable: + options.realNationCount === undefined + ? undefined + : options.realNationCount < resolveMaxNation(options.worldState), + }); const nationEntries = buildEntries(env, nationSpecs); return { diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 5c4de26c..0634464a 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -213,9 +213,10 @@ describe('buildTurnCommandTable', () => { for (const field of command.inputFields) { expect(supportedKinds.has(field.kind), `${scope}:${command.key}:${field.key}`).toBe(true); if (field.kind === 'select') { - expect(Boolean(field.options?.length || field.optionSource), `${scope}:${command.key}:${field.key}`).toBe( - true - ); + expect( + Boolean(field.options?.length || field.optionSource), + `${scope}:${command.key}:${field.key}` + ).toBe(true); } } } @@ -340,6 +341,34 @@ describe('buildTurnCommandTable', () => { }); }); + it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => { + const worldState = buildWorldState(); + worldState.meta = { + ...((worldState.meta ?? {}) as Record), + refGameEnv: { maxnation: '2' }, + }; + const buildAtCount = (realNationCount: number) => + buildTurnCommandTable({ + worldState, + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + realNationCount, + }); + const findFounding = (table: Awaited>) => + table.general.flatMap((group) => group.values).find((command) => command.key === 'che_건국'); + + expect(findFounding(await buildAtCount(1))).not.toMatchObject({ + reason: '더 이상 건국은 불가능합니다.', + }); + expect(findFounding(await buildAtCount(2))).toMatchObject({ + possible: false, + status: 'blocked', + reason: '더 이상 건국은 불가능합니다.', + }); + }); + it('projects Ref recruitment availability, combat values, descriptions, and adjusted costs', () => { const general = buildGeneral(); general.injury = 3; diff --git a/app/game-engine/src/auction/neutralRegistrar.ts b/app/game-engine/src/auction/neutralRegistrar.ts index 599f06bf..2d71d44e 100644 --- a/app/game-engine/src/auction/neutralRegistrar.ts +++ b/app/game-engine/src/auction/neutralRegistrar.ts @@ -119,7 +119,8 @@ export const createNeutralAuctionRegistrar = async (options: { hiddenSeed, seedYear: context.previousYear, seedMonth: context.previousMonth, - nationCount: options.getNationPowerRollCount?.() ?? world.listNations().length, + nationCount: + options.getNationPowerRollCount?.() ?? world.listNations().filter((nation) => nation.id > 0).length, consumeTournamentRoll, averageGold: average(eligibleGenerals.map((general) => general.gold)), averageRice: average(eligibleGenerals.map((general) => general.rice)), diff --git a/app/game-engine/src/turn/monthlyScoutBlockAction.ts b/app/game-engine/src/turn/monthlyScoutBlockAction.ts index bebe9900..6512fba0 100644 --- a/app/game-engine/src/turn/monthlyScoutBlockAction.ts +++ b/app/game-engine/src/turn/monthlyScoutBlockAction.ts @@ -25,7 +25,10 @@ export const createScoutBlockHandler = (options: { // changing nation or game state. No provided scenario invokes it. throw new Error('update(): at least 3 arguments expected'); } - for (const nation of world.listNations()) { + // Ref updates every row in its nation table. Core additionally keeps a + // synthetic id=0 neutral row, which is not a Ref nation row and must + // remain outside the last-nation scout policy. + for (const nation of world.listNations().filter((entry) => entry.id > 0)) { world.updateNation(nation.id, { meta: { ...nation.meta, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index f2221850..1ac57442 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -414,7 +414,8 @@ const createMonthlyCalendarRuntime = async (options: { clock: Clock; }) => { const cache: MonthlyRuntimeCache = { - nationPowerRollCount: options.snapshot.nations.length, + // Ref's monthly nation query has no Core-only id=0 neutral row. + nationPowerRollCount: options.snapshot.nations.filter((nation) => nation.id > 0).length, tournamentRollConsumed: false, }; const unification = options.calendarHandlerOverride diff --git a/app/game-engine/test/monthlyScoutBlockAction.test.ts b/app/game-engine/test/monthlyScoutBlockAction.test.ts index 7b81156a..35b75524 100644 --- a/app/game-engine/test/monthlyScoutBlockAction.test.ts +++ b/app/game-engine/test/monthlyScoutBlockAction.test.ts @@ -87,16 +87,18 @@ const buildWorld = ( describe('monthly scout block actions', () => { it('blocks joining for all nations and globally locks policy changes', async () => { const world = buildWorld(0); + world.addNation(buildNation(0, 0)); await createScoutBlockHandler({ actionName: 'BlockScoutAction', getWorld: () => world })( [true], { year: 200, month: 1, startyear: 190, currentEventID: 1, turnTime: new Date() }, event ); - expect(world.listNations().map((nation) => nation.meta)).toEqual([ - { scout: 1, marker: 1 }, - { scout: 1, marker: 2 }, - ]); + expect(Object.fromEntries(world.listNations().map((nation) => [nation.id, nation.meta]))).toEqual({ + 0: { scout: 0, marker: 0 }, + 1: { scout: 1, marker: 1 }, + 2: { scout: 1, marker: 2 }, + }); expect(world.getState().meta.block_change_scout).toBe(true); }); diff --git a/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts b/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts index a34b13b7..9b46e3b6 100644 --- a/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts @@ -11,6 +11,7 @@ import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/t const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const nationIds = [990_081, 990_082] as const; +const neutralNationId = 0; const buildNation = (id: number): Nation => ({ id, @@ -49,7 +50,7 @@ integration('monthly scout block persistence', () => { OR: [{ srcNationId: { in: [...nationIds] } }, { destNationId: { in: [...nationIds] } }], }, }); - await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + await db.nation.deleteMany({ where: { id: { in: [neutralNationId, ...nationIds] } } }); }); afterAll(async () => { @@ -58,12 +59,13 @@ integration('monthly scout block persistence', () => { OR: [{ srcNationId: { in: [...nationIds] } }, { destNationId: { in: [...nationIds] } }], }, }); - await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + await db.nation.deleteMany({ where: { id: { in: [neutralNationId, ...nationIds] } } }); await closeDb?.(); }); it('persists every nation scout flag and the global policy lock in one flush', async () => { - const nations = nationIds.map(buildNation); + const neutralNation = { ...buildNation(neutralNationId), name: '재야', level: 0 }; + const nations = [neutralNation, ...nationIds.map(buildNation)]; await db.nation.createMany({ data: nations.map((nation) => ({ id: nation.id, @@ -146,6 +148,9 @@ integration('monthly scout block persistence', () => { { power: 0, scout: 1 }, { power: 0, scout: 1 }, ]); + expect(await db.nation.findUniqueOrThrow({ where: { id: neutralNationId } })).toMatchObject({ + meta: { scout: 0 }, + }); expect(await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).toMatchObject({ meta: { block_change_scout: true }, }); diff --git a/app/game-engine/test/neutralAuctionRegistrar.test.ts b/app/game-engine/test/neutralAuctionRegistrar.test.ts index 9bffc3ef..468796cf 100644 --- a/app/game-engine/test/neutralAuctionRegistrar.test.ts +++ b/app/game-engine/test/neutralAuctionRegistrar.test.ts @@ -41,7 +41,9 @@ const buildSnapshot = (): TurnWorldSnapshot => ({ buildGeneral(2, 2, 99_999, 99_999), ], cities: [], - nations: [1, 2, 3].map((id) => ({ + // Core persists a synthetic id=0 row that Ref's nation-power loop never + // sees. The registrar fallback must therefore still consume three rolls. + nations: [0, 1, 2, 3].map((id) => ({ id, name: `Nation_${id}`, color: '#000000', @@ -151,7 +153,7 @@ describe('neutral auction monthly registrar', () => { loadNeutralAuctionCounts: async () => [], }); const snapshot = buildSnapshot(); - snapshot.nations = snapshot.nations.slice(0, 2); + snapshot.nations = snapshot.nations.slice(1, 3); snapshot.generals = [buildGeneral(1, 0, 5_000, 7_000), buildGeneral(2, 0, 6_000, 8_000)]; const state: TurnWorldState = { id: 1,