diff --git a/app/game-api/src/router/nation/endpoints/setBlockScout.ts b/app/game-api/src/router/nation/endpoints/setBlockScout.ts index 3cc5a39..457fec6 100644 --- a/app/game-api/src/router/nation/endpoints/setBlockScout.ts +++ b/app/game-api/src/router/nation/endpoints/setBlockScout.ts @@ -16,14 +16,26 @@ export const setBlockScout = authedProcedure .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); - const nation = await ctx.db.nation.findUnique({ - where: { id: me.nationId }, - select: { meta: true }, - }); + const [nation, worldState] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }), + ctx.db.worldState.findFirst({ + select: { meta: true }, + }), + ]); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const worldMeta = asRecord(worldState?.meta); + if (worldMeta.block_change_scout === true) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', + }); + } const nationMeta = asRecord(nation.meta); await updateNationMeta( ctx, diff --git a/app/game-api/test/scoutBlockRouter.test.ts b/app/game-api/test/scoutBlockRouter.test.ts new file mode 100644 index 0000000..89dccae --- /dev/null +++ b/app/game-api/test/scoutBlockRouter.test.ts @@ -0,0 +1,115 @@ +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 general: 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: 5, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00.000Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +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 = (blockChangeScout: boolean) => { + const requestCommand = vi.fn(); + const db = { + general: { + findFirst: vi.fn(async () => general), + }, + nation: { + findUnique: vi.fn(async () => ({ meta: {} })), + }, + worldState: { + findFirst: vi.fn(async () => ({ meta: { block_change_scout: blockChangeScout } })), + }, + }; + const redisClient = { + get: async () => null, + set: async () => null, + }; + 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, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand }; +}; + +describe('nation scout policy lock', () => { + it('rejects policy changes before daemon dispatch while the scenario lock is enabled', async () => { + const fixture = buildContext(true); + await expect(appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })).rejects.toMatchObject( + { + code: 'FORBIDDEN', + message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', + } + ); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-engine/src/turn/monthlyScoutBlockAction.ts b/app/game-engine/src/turn/monthlyScoutBlockAction.ts new file mode 100644 index 0000000..bebe990 --- /dev/null +++ b/app/game-engine/src/turn/monthlyScoutBlockAction.ts @@ -0,0 +1,41 @@ +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +const readOptionalBlockFlag = (value: unknown, actionName: string): boolean | null => { + if (value === undefined || value === null) { + return null; + } + if (typeof value !== 'boolean') { + throw new Error(`${actionName} blockChangeScout must be a boolean or null.`); + } + return value; +}; + +export const createScoutBlockHandler = (options: { + actionName: 'BlockScoutAction' | 'UnblockScoutAction'; + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (args) => { + const world = options.getWorld(); + if (!world) { + return; + } + if (options.actionName === 'UnblockScoutAction') { + // ref omits MeekroDB's required WHERE argument and fails before + // 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()) { + world.updateNation(nation.id, { + meta: { + ...nation.meta, + scout: 1, + }, + }); + } + const blockChangeScout = readOptionalBlockFlag(args[0], options.actionName); + if (blockChangeScout !== null) { + world.updateWorldMeta({ block_change_scout: blockChangeScout }); + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 33b4cdc..27955dd 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -65,6 +65,7 @@ import { createFinishNationBettingHandler, createOpenNationBettingHandler, } from './monthlyNationBettingAction.js'; +import { createScoutBlockHandler } from './monthlyScoutBlockAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -348,6 +349,15 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, }) ); + for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) { + eventActions.set( + actionName, + createScoutBlockHandler({ + actionName, + getWorld: () => worldRef, + }) + ); + } eventActions.set('ProcessIncome', async (_args, environment) => { await incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/test/monthlyScoutBlockAction.test.ts b/app/game-engine/test/monthlyScoutBlockAction.test.ts new file mode 100644 index 0000000..84ba83d --- /dev/null +++ b/app/game-engine/test/monthlyScoutBlockAction.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import type { Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createScoutBlockHandler } from '../src/turn/monthlyScoutBlockAction.js'; +import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildNation = (id: number, scout: number): Nation => ({ + id, + name: `국가${id}`, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 2, + typeCode: 'che_중립', + meta: { scout, marker: id }, +}); + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [], + meta: {}, +}; + +const buildWorld = (scout: number, blockChangeScout?: boolean) => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: blockChangeScout === undefined ? {} : { block_change_scout: blockChangeScout }, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + return new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals: [], + cities: [], + nations: [buildNation(1, scout), buildNation(2, scout)], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); +}; + +describe('monthly scout block actions', () => { + it('blocks joining for all nations and globally locks policy changes', async () => { + const world = buildWorld(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(world.getState().meta.block_change_scout).toBe(true); + }); + + it('preserves the legacy UnblockScoutAction missing-WHERE failure without mutations', async () => { + const world = buildWorld(1, true); + expect(() => + createScoutBlockHandler({ actionName: 'UnblockScoutAction', getWorld: () => world })( + [false], + { year: 200, month: 1, startyear: 190, currentEventID: 1, turnTime: new Date() }, + event + ) + ).toThrow('update(): at least 3 arguments expected'); + + expect(world.listNations().map((nation) => nation.meta.scout)).toEqual([1, 1]); + expect(world.getState().meta.block_change_scout).toBe(true); + expect(world.peekDirtyState().nations).toEqual([]); + }); + + it('rejects non-boolean global flag arguments', async () => { + const world = buildWorld(0); + expect(() => + createScoutBlockHandler({ actionName: 'BlockScoutAction', getWorld: () => world })( + [1], + { year: 200, month: 1, startyear: 190, currentEventID: 1, turnTime: new Date() }, + event + ) + ).toThrow('BlockScoutAction blockChangeScout must be a boolean or null.'); + }); +}); diff --git a/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts b/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts new file mode 100644 index 0000000..dcdcd71 --- /dev/null +++ b/app/game-engine/test/monthlyScoutBlockPersistence.integration.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Nation } from '@sammo-ts/logic'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createScoutBlockHandler } from '../src/turn/monthlyScoutBlockAction.js'; +import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationIds = [990_081, 990_082] as const; + +const buildNation = (id: number): Nation => ({ + id, + name: `임관정책국${id}`, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 2, + typeCode: 'che_중립', + meta: { scout: 0 }, +}); + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [], + meta: {}, +}; + +integration('monthly scout block persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + }); + + afterAll(async () => { + await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + await closeDb?.(); + }); + + it('persists every nation scout flag and the global policy lock in one flush', async () => { + const nations = nationIds.map(buildNation); + await db.nation.createMany({ + data: nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + typeCode: nation.typeCode, + meta: nation.meta, + })), + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'monthly-scout-block-persistence', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { block_change_scout: false }, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:00:00.000Z'), + meta: { block_change_scout: false }, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const world = new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals: [], + cities: [], + nations, + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + + try { + await createScoutBlockHandler({ actionName: 'BlockScoutAction', getWorld: () => world })( + [true], + { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + event + ); + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect( + (await db.nation.findMany({ where: { id: { in: [...nationIds] } }, orderBy: { id: 'asc' } })).map( + (nation) => nation.meta + ) + ).toEqual([{ scout: 1 }, { scout: 1 }]); + expect(await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).toMatchObject({ + meta: { block_change_scout: true }, + }); + } finally { + await hooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +});