diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 98046be3..b62e31e6 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic'; -import { asRecord, type RankDataType } from '@sammo-ts/common'; +import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common'; import type { GameApiContext } from '../../context.js'; import { @@ -989,7 +989,7 @@ export const generalRouter = router({ const now = new Date(); const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta); - const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([ + const [onlineAccess, ownNation, openPolls, gameTime, officeRequestRows] = await Promise.all([ ctx.db.generalAccessLog.findMany({ where: { lastActionAt: { @@ -1020,6 +1020,15 @@ export const generalRouter = router({ }, }), loadCurrentGameTime(ctx.db, now), + me.nationId > 0 && me.officerLevel >= 5 && !asRecord(me.penalty).noChief + ? ctx.db.general.findMany({ + where: { + nationId: me.nationId, + meta: { path: ['cityOfficeRequest', 'status'], equals: 'pending' }, + }, + select: { id: true, userId: true, meta: true }, + }) + : Promise.resolve([]), ]); // vote_poll timestamps are logical game-wall values. During PREOPEN the // logical clock advances through negative ticks before the opening anchor, @@ -1088,6 +1097,19 @@ export const generalRouter = router({ return { serverId, + cityOfficeRequests: { + generalId: me.id, + nationId: me.nationId, + ids: officeRequestRows.flatMap((row) => { + const request = readOfficeRequest(row.meta); + return request?.status === 'pending' && + request.generalId === row.id && + request.nationId === me.nationId && + request.userId === row.userId + ? [request.id] + : []; + }), + }, onlineUserCount: onlineGenerals.length, onlineNations, onlineGenerals: myOnlineGenerals, diff --git a/app/game-api/src/router/nation/endpoints/cityOfficeRequests.ts b/app/game-api/src/router/nation/endpoints/cityOfficeRequests.ts new file mode 100644 index 00000000..d6036122 --- /dev/null +++ b/app/game-api/src/router/nation/endpoints/cityOfficeRequests.ts @@ -0,0 +1,173 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { + asRecord, + GAME_TICKS_PER_TURN, + officeRequestQuarter, + readOfficeRequest, + type CityOfficeLevel, +} from '@sammo-ts/common'; +import { resolveOfficeRequestEligibility, type OfficeCandidate } from '@sammo-ts/logic'; +import { accessAuthedProcedure, engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { throwIfCommandRejected } from '../../shared/turnDaemon.js'; +import { assertNationAccess, resolveChiefStatMin } from '../shared.js'; +import { loadCurrentGameTime } from '../../../services/gameClock.js'; + +export const cityOfficeRequest = engineAuthedProcedure + .input( + z.discriminatedUnion('action', [ + z.object({ + action: z.literal('request'), + officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]), + }), + z.object({ + action: z.enum(['approve', 'reject', 'withdraw']), + targetGeneralId: z.number().int().positive(), + officeRequestId: z.string().min(1), + }), + ]) + ) + .mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + const result = await ctx.turnDaemon.requestCommand({ + ...input, + type: 'cityOfficeRequest', + userId: getAuthenticatedUserId(ctx), + generalId: general.id, + ...(ctx.requestId + ? { requestId: `${ctx.requestId}:nation.cityOfficeRequest:engine:0:cityOfficeRequest` } + : {}), + }); + throwIfCommandRejected(result); + if (!result || result.type !== 'cityOfficeRequest') + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + if (!result.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); + return { ok: true }; + }); + +export const getCityOfficeRequests = accessAuthedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const [nation, cities, rows, state, clock] = await Promise.all([ + ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { meta: true } }), + ctx.db.city.findMany({ + where: { nationId: me.nationId }, + select: { id: true, nationId: true, name: true, meta: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + select: { + id: true, + userId: true, + name: true, + nationId: true, + cityId: true, + officerLevel: true, + npcState: true, + strength: true, + intel: true, + meta: true, + penalty: true, + }, + }), + ctx.db.worldState.findFirst(), + loadCurrentGameTime(ctx.db), + ]); + if (!nation || !state) + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가 또는 게임 상태를 찾을 수 없습니다.' }); + const candidates = rows.map((row) => ({ ...row, intelligence: row.intel })); + const applicant: OfficeCandidate = { ...me, intelligence: me.intel }; + const city = cities.find((entry) => entry.id === me.cityId) ?? null; + const quarter = officeRequestQuarter(state.currentYear, state.currentMonth); + const own = readOfficeRequest(me.meta); + const requests = candidates.flatMap((general) => { + const request = readOfficeRequest(general.meta); + if ( + !request || + request.generalId !== general.id || + request.nationId !== me.nationId || + request.userId !== general.userId + ) + return []; + return [{ general, request }]; + }); + const evaluate = (general: OfficeCandidate, cityId: number, level: CityOfficeLevel) => + resolveOfficeRequestEligibility({ + applicant: general, + city: cities.find((entry) => entry.id === cityId) ?? null, + level, + incumbent: + candidates.find( + (entry) => + entry.officerLevel === level && + Number(asRecord(entry.meta).officerCity ?? asRecord(entry.meta).officer_city ?? 0) === cityId + ) ?? null, + nationMeta: nation.meta, + chiefStatMin: resolveChiefStatMin(state), + }); + const canManage = me.officerLevel >= 5 && !asRecord(me.penalty).noChief; + const nowTick = clock.tick; + const options = ([4, 3, 2] as const).map((level) => { + const check = evaluate(applicant, me.cityId, level); + const occupied = requests.some( + ({ request }) => + request.status === 'pending' && request.cityId === me.cityId && request.officerLevel === level + ); + const reason = + check.reason ?? + (own?.status === 'pending' + ? '요청 대기 중' + : own?.quarter === quarter + ? '이번 분기 자원 완료' + : occupied + ? '다른 장수 자원 중' + : nowTick === null + ? '게임 시계 준비 중' + : null); + return { level, ...check, allowed: !reason, reason }; + }); + return { + canManage, + generalId: me.id, + nationId: me.nationId, + serverId: String(asRecord(state.meta).serverId ?? ctx.profile?.name ?? 'game'), + city: city ? { id: city.id, name: city.name } : null, + options, + running: clock.running, + tickSeconds: state.tickSeconds, + requests: requests + .filter(({ general, request }) => general.id === me.id || (canManage && request.status === 'pending')) + .map(({ general, request }) => { + const check = evaluate(general, request.cityId, request.officerLevel); + const defaultDecision = + request.defaultDecision === 'reject' || check.defaultDecision === 'reject' + ? ('reject' as const) + : ('approve' as const); + const months = Math.max( + 0, + Math.ceil((request.dueTick - Number(state.lastTurnTick ?? 0)) / GAME_TICKS_PER_TURN) + ); + const monthIndex = state.currentYear * 12 + state.currentMonth - 1 + months; + return { + id: request.id, + generalId: general.id, + generalName: general.name, + cityId: request.cityId, + cityName: cities.find((entry) => entry.id === request.cityId)?.name ?? '-', + officerLevel: request.officerLevel, + status: request.status, + defaultDecision, + defaultReason: check.defaultReason === 'secret' ? ('secret' as const) : request.defaultReason, + resultReason: request.resultReason ?? null, + dueYear: Math.floor(monthIndex / 12), + dueMonth: (monthIndex % 12) + 1, + remainingSeconds: + nowTick === null + ? null + : Math.max(0, ((request.dueTick - nowTick) / GAME_TICKS_PER_TURN) * state.tickSeconds), + invalidReason: request.status === 'pending' ? check.reason : null, + }; + }), + }; +}); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index 04ff4813..db8d28ee 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -1,3 +1,4 @@ +import { cityOfficeRequest, getCityOfficeRequests } from './endpoints/cityOfficeRequests.js'; import { router } from '../../trpc.js'; import { appoint } from './endpoints/appoint.js'; import { changePermission } from './endpoints/changePermission.js'; @@ -25,6 +26,8 @@ export const nationRouter = router({ getSecretGeneralList, getCityOverview, getPersonnelInfo, + getCityOfficeRequests, + cityOfficeRequest, getStratFinan, setNotice, setScoutMsg, diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index 6142a40d..40a78163 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -59,6 +59,7 @@ const classifications = { 'join.reselectPoolGeneral', 'join.selectPoolGeneral', 'nation.appoint', + 'nation.cityOfficeRequest', 'nation.changePermission', 'nation.kick', 'nation.setBill', @@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => { // count independently catches mutations that were added to a router but never mounted. expect(declaredCount).toBe(actual.length); expect(new Set(classified).size).toBe(classified.length); - expect(classified).toHaveLength(88); expect(actual).toEqual(classified); }); diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index e6ebbcba..4a722d20 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = { }; const buildContext = ( - options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {} + options: { + auth?: GameSessionTokenPayload | null; + hasVoted?: boolean; + preopenClock?: boolean; + officerLevel?: number; + officeRows?: Array<{ id: number; userId: string; meta: Record }>; + } = {} ) => ({ auth: options.auth === undefined ? auth : options.auth, @@ -30,14 +36,19 @@ const buildContext = ( findFirst: vi.fn(async () => ({ id: 7, userId: 'owner', + officerLevel: options.officerLevel ?? 1, nationId: 2, })), - findMany: vi.fn(async () => [ - { id: 7, name: '유비', nationId: 2 }, - { id: 8, name: '관우', nationId: 2 }, - { id: 9, name: '조조', nationId: 3 }, - { id: 10, name: '재야장수', nationId: 0 }, - ]), + findMany: vi.fn(async (input: { where?: { meta?: unknown } }) => + input.where?.meta + ? (options.officeRows ?? []) + : [ + { id: 7, name: '유비', nationId: 2 }, + { id: 8, name: '관우', nationId: 2 }, + { id: 9, name: '조조', nationId: 3 }, + { id: 10, name: '재야장수', nationId: 0 }, + ] + ), }, worldState: { findFirst: vi.fn(async () => ({ @@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => { expect(result).toEqual({ serverId: 'che_260819_front', + cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] }, onlineUserCount: 4, onlineNations: '【촉】, 【위】', onlineGenerals: '유비, 관우', @@ -173,3 +185,31 @@ describe('general.getFrontStatus', () => { await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); }); + +it('only notifies chiefs about pending requests belonging to current members and owners', async () => { + const pending = { + id: '8:760', + generalId: 8, + userId: 'user-8', + nationId: 2, + cityId: 1, + officerLevel: 4, + incumbentId: 0, + quarter: 760, + createdTick: 0, + dueTick: 3600000, + defaultDecision: 'approve', + defaultReason: 'vacant', + status: 'pending', + }; + const officeRows = [ + { id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } }, + { id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } }, + { id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } }, + { id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } }, + ]; + const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows })); + expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']); + const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows })); + expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]); +}); diff --git a/app/game-api/test/mutationEvidenceManifest.test.ts b/app/game-api/test/mutationEvidenceManifest.test.ts index 76ae4b16..c1ebfbf4 100644 --- a/app/game-api/test/mutationEvidenceManifest.test.ts +++ b/app/game-api/test/mutationEvidenceManifest.test.ts @@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end const expectedOwnerCounts: Record = { 'durable-journal': 19, - 'engine-owned': 38, + 'engine-owned': 39, 'explicit-no-realtime-consumer': 7, 'external-upload': 1, 'mixed-saga': 9, @@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => { const rows = parseManifest(); const manifestRoutes = rows.map(({ route }) => route); - expect(rows).toHaveLength(88); expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length); expect(manifestRoutes).toEqual([...manifestRoutes].sort()); expect(manifestRoutes).toEqual(mountedMutationNames()); diff --git a/app/game-api/test/nationPersonnelRouter.test.ts b/app/game-api/test/nationPersonnelRouter.test.ts index 3ae5133b..01e9c05c 100644 --- a/app/game-api/test/nationPersonnelRouter.test.ts +++ b/app/game-api/test/nationPersonnelRouter.test.ts @@ -1,3 +1,6 @@ +import { createHTTPServer } from '@trpc/server/adapters/standalone'; +import { once } from 'node:events'; +import type { AddressInfo } from 'node:net'; import { describe, expect, it, vi } from 'vitest'; import { ChangeJournal } from '@sammo-ts/common'; @@ -347,3 +350,134 @@ describe('nation personnel router', () => { expect(memberCommand).not.toHaveBeenCalled(); }); }); + +describe('city office requests', () => { + it.each(['approve', 'reject', 'withdraw'] as const)( + 'binds %s to the session actor and durable request ID', + async (action) => { + const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 }); + const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand })); + await expect( + caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' }) + ).resolves.toEqual({ ok: true }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'cityOfficeRequest', + userId: 'user-22', + generalId: 22, + requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest', + action, + targetGeneralId: 30, + officeRequestId: '30:760', + }); + } + ); + it('validates request fields and propagates engine rejection', async () => { + const requestCommand = vi + .fn() + .mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' }); + const caller = appRouter.createCaller(createContext({ requestCommand })); + await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({ + message: '이번 분기 자원 완료', + }); + // @ts-expect-error invalid officer must also be rejected at the HTTP boundary + await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(requestCommand).toHaveBeenCalledTimes(1); + }); + it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => { + const office = { + id: '30:760', + generalId: 30, + userId: 'user-30', + nationId: 1, + cityId: 1, + officerLevel: 4, + incumbentId: 0, + quarter: 760, + createdTick: 0, + dueTick: 3600000, + defaultDecision: 'approve', + defaultReason: 'vacant', + status: 'pending', + }; + const me = { ...baseGeneral, officerLevel }; + const caller = appRouter.createCaller( + createContext({ + me, + db: { + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async () => [ + me, + { + ...baseGeneral, + id: 30, + userId: 'user-30', + officerLevel: 1, + meta: { belong: 5, cityOfficeRequest: office }, + }, + ]), + }, + nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) }, + city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + meta: {}, + config: { stat: { chiefMin: 65 } }, + clockBaseTime: new Date('2026-01-01'), + clockTick: 0n, + clockMode: 'manual', + clockWallAnchor: new Date('2026-01-01'), + clockPhase: 'RUNNING', + })), + }, + }, + }) + ); + const result = await caller.nation.getCityOfficeRequests(); + expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0); + expect(JSON.stringify(result)).not.toContain('user-30'); + expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false); + }); +}); + +it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => { + const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 }); + const server = createHTTPServer({ + router: appRouter, + createContext: ({ req }): GameApiContext => ({ + ...createContext({ requestId: 'office-http-wire', requestCommand }), + auth: req.headers['x-test-session'] ? auth : null, + }), + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address() as AddressInfo; + const send = (body: unknown, authenticated = true) => + fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) }, + body: JSON.stringify(body), + }); + try { + expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401); + expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400); + expect(requestCommand).not.toHaveBeenCalled(); + expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200); + expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' })); + requestCommand.mockResolvedValueOnce({ + type: 'cityOfficeRequest', + ok: false, + generalId: 22, + reason: '이미 처리된 요청', + }); + expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/app/game-engine/src/turn/cityOfficeRequests.ts b/app/game-engine/src/turn/cityOfficeRequests.ts new file mode 100644 index 00000000..2f4c6846 --- /dev/null +++ b/app/game-engine/src/turn/cityOfficeRequests.ts @@ -0,0 +1,199 @@ +import { + asRecord, + GAME_TICKS_PER_TURN, + OFFICE_REQUEST_KEY, + officeRequestQuarter, + readOfficeRequest, + type OfficeRequest, + type TurnDaemonCommand, + type TurnDaemonCommandResult, +} from '@sammo-ts/common'; +import { + LogCategory, + LogFormat, + LogScope, + resolveOfficeRequestEligibility, + type OfficeCandidate, +} from '@sammo-ts/logic'; +import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; +import type { TurnGeneral } from './types.js'; + +const candidate = (general: TurnGeneral): OfficeCandidate => ({ + ...general, + strength: general.stats.strength, + intelligence: general.stats.intelligence, +}); +const incumbentFor = (world: InMemoryTurnWorld, cityId: number, level: number): TurnGeneral | undefined => + world + .listGenerals() + .find((g) => g.officerLevel === level && Number(g.meta.officerCity ?? g.meta.officer_city ?? 0) === cityId); +const eligibility = (world: InMemoryTurnWorld, general: TurnGeneral, cityId: number, level: 2 | 3 | 4) => { + const incumbent = incumbentFor(world, cityId, level); + return resolveOfficeRequestEligibility({ + applicant: candidate(general), + city: world.getCityById(cityId) ?? null, + level, + incumbent: incumbent ? candidate(incumbent) : null, + nationMeta: world.getNationById(general.nationId)?.meta, + chiefStatMin: world.getScenarioConfig().stat.chiefMin, + }); +}; +const save = (world: InMemoryTurnWorld, generalId: number, request: OfficeRequest) => { + const general = world.getGeneralById(generalId); + if (general) world.updateGeneral(generalId, { meta: { ...general.meta, [OFFICE_REQUEST_KEY]: { ...request } } }); +}; +const finish = (world: InMemoryTurnWorld, request: OfficeRequest, status: OfficeRequest['status'], reason: string) => { + save(world, request.generalId, { ...request, status, resultReason: reason }); + const general = world.getGeneralById(request.generalId); + if (!general) return; + const city = world.getCityById(request.cityId); + const title = { 2: '종사', 3: '군사', 4: '태수' }[request.officerLevel]; + const text = `${general.name} · ${city?.name ?? '도시'} ${title} 자원: ${reason}`; + world.pushLog({ + scope: LogScope.GENERAL, + generalId: general.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + text, + }); + world.pushLog({ + scope: LogScope.NATION, + nationId: request.nationId, + category: LogCategory.HISTORY, + format: LogFormat.PLAIN, + text, + }); +}; +const invalidReason = (world: InMemoryTurnWorld, request: OfficeRequest): string | null => { + const general = world.getGeneralById(request.generalId); + if (!general || general.nationId !== request.nationId || general.userId !== request.userId) + return '소속 또는 장수 소유자 변경'; + if ((incumbentFor(world, request.cityId, request.officerLevel)?.id ?? 0) !== request.incumbentId) + return '재직자 변경'; + return eligibility(world, general, request.cityId, request.officerLevel).reason; +}; +const approve = (world: InMemoryTurnWorld, request: OfficeRequest) => { + const incumbent = incumbentFor(world, request.cityId, request.officerLevel); + if (incumbent) + world.updateGeneral(incumbent.id, { + officerLevel: 1, + meta: { ...incumbent.meta, officerCity: 0, officer_city: 0 }, + }); + const general = world.getGeneralById(request.generalId)!; + world.updateGeneral(general.id, { + officerLevel: request.officerLevel, + meta: { ...general.meta, officerCity: request.cityId, officer_city: request.cityId }, + }); + const city = world.getCityById(request.cityId)!; + world.updateCity(city.id, { + meta: { ...city.meta, officer_set: Number(city.meta.officer_set ?? 0) | (1 << request.officerLevel) }, + }); + finish(world, request, 'approved', '임명'); +}; + +// 분기 lock 초기화 뒤 실행한다. 저장된 기본 거부는 조건이 완화되어도 자동 승인으로 바꾸지 않는다. +export const settleCityOfficeRequests = (world: InMemoryTurnWorld, tick: number): void => { + for (const general of world.listGenerals()) { + const request = readOfficeRequest(general.meta); + if (!request || request.status !== 'pending') continue; + const invalid = invalidReason(world, request); + if (invalid) { + finish(world, request, 'cancelled', invalid); + continue; + } + if (tick < request.dueTick) continue; + const current = eligibility(world, general, request.cityId, request.officerLevel); + if (request.defaultDecision === 'approve' && current.defaultDecision === 'approve') approve(world, request); + else finish(world, request, 'rejected', '기한 만료 · 자동 거부'); + } +}; +export const createCityOfficeRequestCalendarHandler = ( + getWorld: () => InMemoryTurnWorld | null +): TurnCalendarHandler => ({ + beforeMonthChanged: () => { + const world = getWorld(); + if (world) settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY); + }, + onMonthChanged: (context) => { + const world = getWorld(); + if (world) settleCityOfficeRequests(world, world.dateToGameTick(context.turnTime)); + }, +}); + +export const handleCityOfficeRequest = ( + world: InMemoryTurnWorld, + command: Extract, + acceptedAt: Date +): Extract => { + const fail = (reason: string): Extract => ({ + type: 'cityOfficeRequest', + generalId: command.generalId, + ok: false, + reason, + }); + const actor = world.getGeneralById(command.generalId); + if (!actor || actor.userId !== command.userId || !actor.nationId) + return fail('장수 소유권 또는 소속이 변경되었습니다.'); + const tick = Math.max(world.dateToGameTick(world.getGameNow(acceptedAt)), world.getState().lastTurnTick ?? 0); + settleCityOfficeRequests(world, tick); + if (command.action === 'request') { + if (!command.officerLevel) return fail('관직을 선택해 주세요.'); + const currentActor = world.getGeneralById(actor.id)!; + const state = world.getState(); + const quarter = officeRequestQuarter(state.currentYear, state.currentMonth); + const previous = readOfficeRequest(currentActor.meta); + if (previous?.status === 'pending') return fail('이미 대기 중인 요청이 있습니다.'); + if (previous?.quarter === quarter) return fail('이번 분기에 이미 자원했습니다.'); + const check = eligibility(world, currentActor, currentActor.cityId, command.officerLevel); + if (!check.allowed) return fail(check.reason!); + const occupied = world.listGenerals().some((g) => { + const pending = readOfficeRequest(g.meta); + return ( + pending?.status === 'pending' && + pending.cityId === actor.cityId && + pending.officerLevel === command.officerLevel + ); + }); + if (occupied) return fail('이미 다른 장수가 자원한 관직입니다.'); + const boundary = state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime); + const dueTick = + boundary + + Math.max(1, Math.ceil((tick - boundary + GAME_TICKS_PER_TURN) / GAME_TICKS_PER_TURN)) * GAME_TICKS_PER_TURN; + save(world, actor.id, { + id: `${actor.id}:${quarter}`, + generalId: actor.id, + userId: command.userId, + nationId: actor.nationId, + cityId: actor.cityId, + officerLevel: command.officerLevel, + incumbentId: incumbentFor(world, actor.cityId, command.officerLevel)?.id ?? 0, + quarter, + createdTick: tick, + dueTick, + defaultDecision: check.defaultDecision, + defaultReason: check.defaultReason, + status: 'pending', + }); + } else { + const target = world.getGeneralById(command.targetGeneralId ?? actor.id); + const request = readOfficeRequest(target?.meta); + if (!request || request.id !== command.officeRequestId || request.status !== 'pending') + return fail('이미 처리되었거나 없는 요청입니다.'); + if (request.nationId !== actor.nationId) return fail('다른 국가의 요청입니다.'); + if (command.action === 'withdraw') { + if (request.generalId !== actor.id) return fail('본인의 요청만 철회할 수 있습니다.'); + finish(world, request, 'withdrawn', '본인 철회'); + } else { + if (actor.officerLevel < 5 || asRecord(actor.penalty).noChief) return fail('수뇌만 처리할 수 있습니다.'); + if (command.action === 'reject') finish(world, request, 'rejected', '수뇌 거부'); + else { + if (asRecord(actor.penalty).noChiefChange && target && target.officerLevel >= 4) + return fail('수뇌인 장수를 변경할 수 없는 상태입니다.'); + const invalid = invalidReason(world, request); + if (invalid) return fail(invalid); + approve(world, request); + } + } + } + return { type: 'cityOfficeRequest', generalId: actor.id, ok: true }; +}; diff --git a/app/game-engine/src/turn/clockReconciliation.ts b/app/game-engine/src/turn/clockReconciliation.ts index f09eefbb..9abf584c 100644 --- a/app/game-engine/src/turn/clockReconciliation.ts +++ b/app/game-engine/src/turn/clockReconciliation.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { GAME_TICKS_PER_TURN, + readOfficeRequest, MAX_SAFE_GAME_TICK, GameClock, buildClockAlignmentPlan, @@ -335,6 +336,24 @@ const readParticipantSnapshots = async ( return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : []; }) ), + snapshot( + 'city-office-request-occurrence', + 'KEEP', + generals.flatMap(({ id, meta }) => { + const request = readOfficeRequest(meta); + return request ? [{ id, createdTick: request.createdTick }] : []; + }) + ), + snapshot( + 'city-office-request-deadline', + 'SHIFT', + generals.flatMap(({ id, meta }) => { + const request = readOfficeRequest(meta); + return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick + ? [{ id, dueTick: request.dueTick }] + : []; + }) + ), snapshot( 'auction-open-occurrence', 'KEEP', @@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise => { const shift = BigInt(shiftTicks); const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift; - const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([ + const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([ db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }), db.$queryRaw>(GamePrisma.sql` SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick" @@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks _max: { pickMoreFromTick: true }, where: { pickMoreFromTick: { not: null } }, }), + db.$queryRaw>(GamePrisma.sql` + SELECT MAX((meta #>> '{cityOfficeRequest,dueTick}')::bigint) AS "maxTick" + FROM general WHERE meta #>> '{cityOfficeRequest,status}' = 'pending' + AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$' + `), ]); const values: Array<[string, bigint | null]> = [ + ['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null], ['general.turn_tick', general._max.turnTick], ['general.meta.next_change_tick', reselection[0]?.maxTick ?? null], ['auction.close_tick', auction._max.closeTick], @@ -632,6 +657,16 @@ const applyParticipantShift = async ( AND (meta->>'next_change_tick')::bigint >= ${cutTick} `) ); + affected.set( + 'city-office-request-deadline', + await db.$executeRaw(GamePrisma.sql` + UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}', + to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks})) + WHERE meta #>> '{cityOfficeRequest,status}' = 'pending' + AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$' + AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick} + `) + ); affected.set( 'auction-deadline', await db.$executeRaw(GamePrisma.sql` diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index daa4f049..5a8ac8c0 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -185,6 +185,16 @@ const zKick = z.object({ destGeneralId: zFiniteNumber, }); +const zCityOfficeRequest = z.object({ + type: z.literal('cityOfficeRequest'), + userId: z.string().min(1), + generalId: z.number().int().positive(), + action: z.enum(['request', 'approve', 'reject', 'withdraw']), + officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(), + targetGeneralId: z.number().int().positive().optional(), + officeRequestId: z.string().min(1).optional(), +}); + const zAppoint = z.object({ type: z.literal('appoint'), userId: z.string().min(1), @@ -666,6 +676,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeCityOfficeRequest: CommandNormalizer<'cityOfficeRequest'> = (envelope) => { + const command = parseWith(zCityOfficeRequest, envelope.command); + return command ? { ...command, requestId: envelope.requestId } : null; +}; + const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => { const command = parseWith(zAppoint, envelope.command); if (!command) { @@ -883,6 +898,7 @@ const normalizers: CommandNormalizerMap = { changePermission: normalizeChangePermission, kick: normalizeKick, appoint: normalizeAppoint, + cityOfficeRequest: normalizeCityOfficeRequest, tournamentRefund: normalizeTournamentRefund, tournamentBettingPayout: normalizeTournamentBettingPayout, tournamentReward: normalizeTournamentReward, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 46023770..62c740b9 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -208,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({ permission: asRecord(general.meta).permission, penalty: general.penalty, }), - frontStatus: signature({ name: general.name, nationId: general.nationId }), + frontStatus: signature({ + name: general.name, + nationId: general.nationId, + officerLevel: general.officerLevel, + userId: general.userId, + penalty: general.penalty, + cityOfficeRequest: asRecord(general.meta).cityOfficeRequest, + }), frontStatusGlobal: '', lobbyCount: signature({ npcState: general.npcState }), lobbyPersonal: signature({ @@ -1406,7 +1413,12 @@ export const createDatabaseTurnHooks = async ( GamePrisma.sql` UPDATE general SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END, - turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second') + turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second'), + meta = CASE WHEN meta #>> '{cityOfficeRequest,status}' = 'pending' + AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$' + THEN jsonb_set(meta, '{cityOfficeRequest,dueTick}', + to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${deltaTicks})) + ELSE meta END ` ); await prisma.$executeRaw( diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 25bd4b1e..87013628 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -22,6 +22,7 @@ import type { import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { GAME_TICKS_PER_TURN, + shiftOfficeRequestDeadline, GameClock, assertGameplayCommitAllowed, inferClockPhase, @@ -819,6 +820,7 @@ export class InMemoryTurnWorld { const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks); this.generals.set(generalId, { ...general, + meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick), turnTick, turnTime: clock.tickToDate(turnTick), }); @@ -1044,6 +1046,7 @@ export class InMemoryTurnWorld { const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks); this.generals.set(generalId, { ...general, + meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks), turnTick, turnTime: clock.tickToDate(turnTick), }); diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 393fbd7c..d750adc0 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,3 +1,4 @@ +import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js'; import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js'; import { initializeAuditPolicies } from '../playAudit/policy.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; @@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: { const calendarHandler = composeCalendarHandlers( createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler, options.monthlyEventHandler, + createCityOfficeRequestCalendarHandler(options.getWorld), options.hasEventAction('ProcessIncome') ? null : options.incomeHandler, createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler, createPlayAuditHandler(options.getWorld), diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 657f5cdb..9ccd880b 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -1,3 +1,4 @@ +import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js'; import type { TurnDaemonCommandHandler, TurnDaemonCommand, @@ -187,6 +188,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ 'changePermission', 'kick', 'appoint', + 'cityOfficeRequest', 'voteReward', 'syncDiplomaticResponse', ] as const satisfies readonly TurnDaemonCommand['type'][]; @@ -2587,6 +2589,7 @@ async function handleAppoint( } else { return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' }; } + settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY); refreshActorKillturn(world, general); return { type: 'appoint', ok: true, generalId: command.generalId }; } @@ -3243,6 +3246,12 @@ export const createTurnDaemonCommandHandler = (options: { changePermission: (command) => handleChangePermission(ctx, command as Extract), kick: (command) => handleKick(ctx, command as Extract), + cityOfficeRequest: async (command) => + handleCityOfficeRequest( + ctx.world, + command as Extract, + ctx.commandDb ? await resolveOperationalAcceptedAt(ctx.commandDb, command) : new Date() + ), appoint: (command) => handleAppoint(ctx, command as Extract), tournamentRefund: (command) => handleTournamentRefund(ctx, command as Extract), diff --git a/app/game-engine/test/authenticatedActorCommand.test.ts b/app/game-engine/test/authenticatedActorCommand.test.ts index 6829ce53..e0b37da3 100644 --- a/app/game-engine/test/authenticatedActorCommand.test.ts +++ b/app/game-engine/test/authenticatedActorCommand.test.ts @@ -45,6 +45,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [ destCityId: 1, officerLevel: 4, }, + { + type: 'cityOfficeRequest', + requestId: 'cityOfficeRequest', + userId, + generalId: 7, + action: 'request', + officerLevel: 4, + }, { type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] }, { type: 'syncDiplomaticResponse', diff --git a/app/game-engine/test/cityOfficeRequestPersistence.integration.test.ts b/app/game-engine/test/cityOfficeRequestPersistence.integration.test.ts new file mode 100644 index 00000000..c189f1dc --- /dev/null +++ b/app/game-engine/test/cityOfficeRequestPersistence.integration.test.ts @@ -0,0 +1,247 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readOfficeRequest, type TurnDaemonCommand } from '@sammo-ts/common'; +import { createGamePostgresConnector, type GamePrismaClient, type GamePrisma } from '@sammo-ts/infra'; +import { buildWorld } from './officeRequestFixture.js'; +import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { EngineStateManager } from '../src/turn/engineStateManager.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { createCityOfficeRequestCalendarHandler } from '../src/turn/cityOfficeRequests.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 992951; +const generalId = 992953; +const requestId = 'integration:city-office-request'; +const constraint = 'city_office_request_rollback_test'; +integration('city office request durable persistence', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let hooks: DatabaseTurnHooks | undefined; + beforeAll(async () => { + const schema = new URL(databaseUrl!).searchParams.get('schema'); + if (!schema || !/^(ci_|office_requests)/.test(schema)) throw new Error('Dedicated integration schema required'); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${constraint}`); + await db.inputEvent.deleteMany({ where: { requestId } }); + await db.logEntry.deleteMany({ + where: { OR: [{ nationId: worldId }, { generalId: { in: [worldId, worldId + 1, generalId] } }] }, + }); + await db.rankData.deleteMany({ where: { generalId: { in: [worldId, worldId + 1, generalId] } } }); + await db.general.deleteMany({ where: { id: { in: [worldId, worldId + 1, generalId] } } }); + await db.city.deleteMany({ where: { id: worldId } }); + await db.nation.deleteMany({ where: { id: worldId } }); + await db.worldState.deleteMany(); + }); + afterAll(async () => { + await hooks?.close(); + await disconnect?.(); + }); + it('rolls back request and journal together, reloads pending state, then persists automatic appointment once', async () => { + const base = buildWorld({}); + const state = { + ...base.state, + id: worldId, + clockBaseTime: base.state.lastTurnTime, + clockTick: 0, + lastTurnTick: 0, + clockMode: 'manual' as const, + clockPhase: 'MANUAL' as const, + clockRevision: 1, + deadlineGeneration: 1, + clockWallAnchor: base.state.lastTurnTime, + }; + const snapshot = { + ...base.snapshot, + generals: base.snapshot.generals.map((g) => ({ + ...g, + id: 992950 + g.id, + nationId: worldId, + cityId: worldId, + userId: `user-${992950 + g.id}`, + })), + cities: base.snapshot.cities.map((c) => ({ ...c, id: worldId, nationId: worldId })), + nations: base.snapshot.nations.map((n) => ({ + ...n, + id: worldId, + capitalCityId: worldId, + chiefGeneralId: worldId, + })), + scenarioConfig: { ...base.snapshot.scenarioConfig!, environment: { mapName: 'che', unitSet: 'che' } }, + }; + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'office-requests', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: 600, + clockBaseTime: state.clockBaseTime, + clockTick: 0n, + lastTurnTick: 0n, + clockMode: 'manual', + clockPhase: 'MANUAL', + clockWallAnchor: state.clockWallAnchor, + clockRevision: 1n, + deadlineGeneration: 1n, + config: JSON.parse(JSON.stringify(snapshot.scenarioConfig)) as GamePrisma.InputJsonValue, + meta: state.meta as GamePrisma.InputJsonValue, + }, + }); + await db.nation.create({ + data: { + id: worldId, + name: '위', + color: '#777777', + level: 3, + gold: 10000, + rice: 10000, + tech: 0, + typeCode: 'che_중립', + capitalCityId: worldId, + meta: {}, + }, + }); + await db.city.create({ + data: { + id: worldId, + name: '허창', + nationId: worldId, + level: 7, + population: 1000, + populationMax: 2000, + agriculture: 1000, + agricultureMax: 2000, + commerce: 1000, + commerceMax: 2000, + security: 1000, + securityMax: 2000, + defence: 1000, + defenceMax: 2000, + wall: 1000, + wallMax: 2000, + trust: 80, + trade: 100, + region: 1, + meta: {}, + }, + }); + await db.general.createMany({ + data: snapshot.generals.map((g) => ({ + id: g.id, + userId: g.userId, + name: g.name, + nationId: g.nationId, + cityId: g.cityId, + npcState: g.npcState, + officerLevel: g.officerLevel, + leadership: g.stats.leadership, + strength: g.stats.strength, + intel: g.stats.intelligence, + gold: g.gold, + rice: g.rice, + crew: g.crew, + crewTypeId: g.crewTypeId, + train: g.train, + atmos: g.atmos, + age: g.age, + turnTime: g.turnTime, + meta: g.meta, + })), + }); + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const handler = createTurnDaemonCommandHandler({ world }); + hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const manager = new EngineStateManager(); + manager.register('world', { + capture: () => world.captureState(), + restore: (saved) => world.restoreState(saved), + }); + const command: Extract = { + type: 'cityOfficeRequest', + requestId, + userId: `user-${generalId}`, + generalId, + action: 'request', + officerLevel: 4, + }; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: command.type, + actorUserId: command.userId, + status: 'PROCESSING', + lockedBy: 'office-test', + leaseUntil: new Date('2099-01-01'), + attempts: 1, + payload: command, + acceptedGameTick: 0n, + acceptedClockRevision: 1n, + acceptedDeadlineGeneration: 1n, + processingGameTick: 0n, + processingClockRevision: 1n, + processingDeadlineGeneration: 1n, + }, + }); + const execute = () => + manager.transaction(() => + hooks!.hooks.executeCommand!(requestId, async (context) => { + const result = await handler.handle(command, context); + if (!result) throw new Error('Missing result'); + return result; + }) + ); + await db.$executeRawUnsafe( + `ALTER TABLE input_event ADD CONSTRAINT ${constraint} CHECK (request_id <> '${requestId}' OR status <> 'SUCCEEDED')` + ); + await expect(execute()).rejects.toThrow(); + expect(readOfficeRequest(world.getGeneralById(generalId)?.meta)).toBeNull(); + expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)).toBeNull(); + expect(hooks.takeCommittedReadModelChanges()).toBeNull(); + await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${constraint}`); + await expect(execute()).resolves.toMatchObject({ type: 'cityOfficeRequest', ok: true }); + expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('SUCCEEDED'); + expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)?.status).toBe( + 'pending' + ); + expect(hooks.takeCommittedReadModelChanges()).toMatchObject({ frontStatusChanged: true }); + await hooks.close(); + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const reloaded: InMemoryTurnWorld = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createCityOfficeRequestCalendarHandler(() => reloaded), + }); + const pending = readOfficeRequest(reloaded.getGeneralById(generalId)?.meta)!; + expect(pending.status).toBe('pending'); + hooks = await createDatabaseTurnHooks(databaseUrl!, reloaded); + const turnTime = reloaded.gameTickToDate(pending.dueTick); + await reloaded.advanceMonth(turnTime); + await hooks.hooks.flushChanges?.({ + lastTurnTime: turnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + const saved = await db.general.findUniqueOrThrow({ where: { id: generalId } }); + expect(saved.officerLevel).toBe(4); + expect(readOfficeRequest(saved.meta)?.status).toBe('approved'); + expect((await db.city.findUniqueOrThrow({ where: { id: worldId } })).meta).toMatchObject({ officer_set: 16 }); + const count = await db.logEntry.count({ where: { generalId } }); + await hooks.hooks.flushChanges?.({ + lastTurnTime: turnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + expect(await db.logEntry.count({ where: { generalId } })).toBe(count); + }); +}); diff --git a/app/game-engine/test/cityOfficeRequests.test.ts b/app/game-engine/test/cityOfficeRequests.test.ts new file mode 100644 index 00000000..fa4a39a8 --- /dev/null +++ b/app/game-engine/test/cityOfficeRequests.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import { GAME_TICKS_PER_TURN, readOfficeRequest, shiftOfficeRequestDeadline } from '@sammo-ts/common'; +import { buildGeneral, buildWorld } from './officeRequestFixture.js'; +import { + handleCityOfficeRequest, + settleCityOfficeRequests, + createCityOfficeRequestCalendarHandler, +} from '../src/turn/cityOfficeRequests.js'; +const acceptedAt = new Date('0185-01-01T00:00:00Z'); +const request = (world: ReturnType['world'], generalId = 3) => + handleCityOfficeRequest( + world, + { type: 'cityOfficeRequest', userId: `user-${generalId}`, generalId, action: 'request', officerLevel: 4 }, + acceptedAt + ); +const pending = (world: ReturnType['world']) => readOfficeRequest(world.getGeneralById(3)?.meta)!; +const decide = ( + world: ReturnType['world'], + action: 'approve' | 'reject' | 'withdraw', + generalId = 2 +) => + handleCityOfficeRequest( + world, + { + type: 'cityOfficeRequest', + userId: `user-${generalId}`, + generalId, + action, + targetGeneralId: 3, + officeRequestId: pending(world).id, + }, + acceptedAt + ); + +describe('city office request policy and lifecycle', () => { + it.each([null, 2, 3])('defaults vacancy / N / M (%s) to approval only at the deadline', (npc) => { + const { world } = buildWorld({ + generals: [ + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3), + ...(npc === null + ? [] + : [ + buildGeneral(4, { + userId: null, + npcState: npc, + officerLevel: 4, + meta: { killturn: 12, officerCity: 1 }, + }), + ]), + ], + }); + expect(request(world)).toMatchObject({ ok: true }); + const entry = pending(world); + expect(entry).toMatchObject({ defaultDecision: 'approve', status: 'pending' }); + expect(entry.dueTick - entry.createdTick).toBeGreaterThanOrEqual(GAME_TICKS_PER_TURN); + settleCityOfficeRequests(world, entry.dueTick - 1); + expect(world.getGeneralById(3)?.officerLevel).toBe(1); + settleCityOfficeRequests(world, entry.dueTick); + expect(pending(world).status).toBe('approved'); + expect(world.getGeneralById(3)).toMatchObject({ officerLevel: 4, meta: { officerCity: 1, officer_city: 1 } }); + expect(Number(world.getCityById(1)?.meta.officer_set) & 16).toBe(16); + if (npc !== null) expect(world.getGeneralById(4)?.officerLevel).toBe(1); + settleCityOfficeRequests(world, entry.dueTick + GAME_TICKS_PER_TURN); + expect(pending(world).status).toBe('approved'); + }); + it.each(['absent', 'secret'] as const)('defaults %s to rejection but lets a chief explicitly approve', (reason) => { + for (const manual of [false, true]) { + const { world } = buildWorld({ + generals: [ + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3, reason === 'secret' ? { meta: { killturn: 12, belong: 0 } } : {}), + ...(reason === 'absent' + ? [buildGeneral(4, { cityId: 2, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } })] + : []), + ], + }); + expect(request(world)).toMatchObject({ ok: true }); + expect(pending(world)).toMatchObject({ defaultDecision: 'reject', defaultReason: reason }); + if (manual) expect(decide(world, 'approve')).toMatchObject({ ok: true }); + else settleCityOfficeRequests(world, pending(world).dueTick); + expect(pending(world).status).toBe(manual ? 'approved' : 'rejected'); + } + }); + it.each([0, 1])('does not replace a stationed user / possessed general (%s)', (npcState) => { + const { world } = buildWorld({ + generals: [ + buildGeneral(3), + buildGeneral(4, { npcState, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } }), + ], + }); + expect(request(world)).toMatchObject({ ok: false, reason: '재직자가 도시에 체류 중' }); + }); + it('lets chiefs approve or reject default approval, and permits owner withdrawal', () => { + for (const action of ['approve', 'reject', 'withdraw'] as const) { + const { world } = buildWorld({}); + request(world); + expect(decide(world, action, action === 'withdraw' ? 3 : 2)).toMatchObject({ ok: true }); + expect(pending(world).status).toBe( + { approve: 'approved', reject: 'rejected', withdraw: 'withdrawn' }[action] + ); + expect(request(world).ok).toBe(false); + } + }); + it('rejects forged actors, ordinary approvers, foreign chiefs and stale request IDs', () => { + const { world } = buildWorld({}); + request(world); + expect(decide(world, 'approve', 3).ok).toBe(false); + world.updateGeneral(2, { nationId: 9 }); + expect(decide(world, 'approve').ok).toBe(false); + expect( + handleCityOfficeRequest( + world, + { + type: 'cityOfficeRequest', + userId: 'forged', + generalId: 3, + action: 'withdraw', + officeRequestId: pending(world).id, + }, + acceptedAt + ).ok + ).toBe(false); + expect( + handleCityOfficeRequest( + world, + { + type: 'cityOfficeRequest', + userId: 'user-3', + generalId: 3, + action: 'withdraw', + officeRequestId: 'stale', + }, + acceptedAt + ).ok + ).toBe(false); + expect(pending(world).status).toBe('pending'); + }); + it.each(['move', 'nation', 'owner', 'occupation', 'lock', 'stat', 'penalty'] as const)( + 'cancels after %s changes before settlement', + (change) => { + const { world } = buildWorld({}); + request(world); + const general = world.getGeneralById(3)!; + if (change === 'move') world.updateGeneral(3, { cityId: 2 }); + if (change === 'nation') world.updateGeneral(3, { nationId: 2 }); + if (change === 'owner') world.updateGeneral(3, { userId: 'other' }); + if (change === 'occupation') world.updateCity(1, { nationId: 2 }); + if (change === 'lock') world.updateCity(1, { meta: { officer_set: 16 } }); + if (change === 'stat') world.updateGeneral(3, { stats: { ...general.stats, strength: 0 } }); + if (change === 'penalty') world.updateGeneral(3, { penalty: { noChief: true } }); + settleCityOfficeRequests(world, pending(world).dueTick); + expect(pending(world).status).toBe('cancelled'); + expect(world.getGeneralById(3)?.officerLevel).toBe(1); + } + ); + it('does not auto-grant newly restricted secrets or upgrade a stored rejection', () => { + const { world } = buildWorld({}); + request(world); + world.updateNation(1, { meta: { secretlimit: 10 } }); + settleCityOfficeRequests(world, pending(world).dueTick); + expect(pending(world).status).toBe('rejected'); + }); + it('reserves a slot against other applicants but not a manual chief appointment', async () => { + const { world, handler } = buildWorld({ + generals: [buildGeneral(2, { officerLevel: 5 }), buildGeneral(3), buildGeneral(4)], + }); + request(world); + expect(request(world, 4).ok).toBe(false); + expect( + await handler.handle({ + type: 'appoint', + userId: 'user-2', + generalId: 2, + destGeneralId: 4, + destCityId: 1, + officerLevel: 4, + }) + ).toMatchObject({ ok: true }); + expect(pending(world).status).toBe('cancelled'); + expect(world.getGeneralById(4)?.officerLevel).toBe(4); + }); + it('uses the monthly calendar boundary and survives a world snapshot reload', async () => { + const { world } = buildWorld({}); + request(world); + const saved = JSON.parse(JSON.stringify(world.getGeneralById(3)?.meta)); + const { world: reloaded } = buildWorld({ generals: [buildGeneral(3, { meta: saved })] }); + await createCityOfficeRequestCalendarHandler(() => reloaded).onMonthChanged?.({ + previousYear: 185, + previousMonth: 1, + currentYear: 185, + currentMonth: 2, + turnTime: reloaded.gameTickToDate(pending(reloaded).dueTick), + }); + expect(pending(reloaded).status).toBe('approved'); + }); +}); + +it('preserves a full review month for requests accepted just before a month boundary', () => { + const { world } = buildWorld({ + clock: { + clockBaseTime: acceptedAt, + clockWallAnchor: acceptedAt, + clockMode: 'manual', + clockTick: GAME_TICKS_PER_TURN - 1, + lastTurnTick: 0, + }, + }); + expect(request(world)).toMatchObject({ ok: true }); + expect(pending(world).dueTick).toBe(2 * GAME_TICKS_PER_TURN); + settleCityOfficeRequests(world, GAME_TICKS_PER_TURN); + expect(pending(world).status).toBe('pending'); +}); + +it('releases the quarterly applicant limit next quarter while preserving same-quarter refusal', () => { + const { world } = buildWorld({}); + request(world); + decide(world, 'reject'); + expect(request(world).ok).toBe(false); + const previous = pending(world); + world.updateGeneral(3, { + meta: { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...previous, quarter: previous.quarter - 1 } }, + }); + expect(request(world).ok).toBe(true); +}); + +it('cancels pending requests before quarterly locks reset', async () => { + const { world } = buildWorld({}); + request(world); + world.updateCity(1, { meta: { officer_set: 16 } }); + await createCityOfficeRequestCalendarHandler(() => world).beforeMonthChanged?.({ + previousYear: 185, + previousMonth: 3, + currentYear: 185, + currentMonth: 4, + turnTime: acceptedAt, + }); + expect(pending(world).status).toBe('cancelled'); +}); + +it('does not relax a stored secret rejection when tenure increases', () => { + const { world } = buildWorld({ generals: [buildGeneral(3, { meta: { killturn: 12, belong: 0 } })] }); + request(world); + world.updateGeneral(3, { meta: { ...world.getGeneralById(3)!.meta, belong: 10 } }); + settleCityOfficeRequests(world, pending(world).dueTick); + expect(pending(world).status).toBe('rejected'); +}); + +it('moves pending deadlines on backlog rebase and preserves occurrence ticks', () => { + const anchor = new Date('2026-01-01T00:00:00Z'); + const { world } = buildWorld({ + clock: { clockBaseTime: anchor, clockWallAnchor: anchor, clockMode: 'realtime', clockTick: 0, lastTurnTick: 0 }, + }); + expect( + handleCityOfficeRequest( + world, + { type: 'cityOfficeRequest', userId: 'user-3', generalId: 3, action: 'request', officerLevel: 4 }, + anchor + ) + ).toMatchObject({ ok: true }); + const before = pending(world); + const shifted = world.rebaseRealtimeBacklog(new Date(anchor.getTime() + 13 * 600_000)); + expect(shifted?.shiftedTicks).toBe(12 * GAME_TICKS_PER_TURN); + expect(pending(world)).toMatchObject({ + createdTick: before.createdTick, + dueTick: before.dueTick + shifted!.shiftedTicks, + }); + const completed = { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...before, status: 'approved' } }; + expect(shiftOfficeRequestDeadline(completed, 10)).toEqual(completed); + expect(shiftOfficeRequestDeadline(world.getGeneralById(3)!.meta, 10, pending(world).dueTick + 1)).toEqual( + world.getGeneralById(3)!.meta + ); +}); + +it('uses the existing secret-limit fallback for malformed metadata', () => { + const { world } = buildWorld({ + nationMeta: { secretlimit: 'invalid' }, + generals: [buildGeneral(3, { meta: { killturn: 12, belong: 'invalid' } })], + }); + expect(request(world)).toMatchObject({ ok: true }); + expect(pending(world).defaultDecision).toBe('reject'); +}); diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index e041a176..1e65c07c 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common'; +import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery, readOfficeRequest } from '@sammo-ts/common'; import { createGamePostgresConnector, readTurnRuntimeReady, @@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => { meta: index === 0 ? { + cityOfficeRequest: { + id: '1:760', + generalId: 1, + userId: 'clock-user', + nationId: 1, + cityId: 1, + officerLevel: 4, + incumbentId: 0, + quarter: 760, + createdTick: initialTick - 100, + dueTick: reselectionTick, + defaultDecision: 'approve', + defaultReason: 'vacant', + status: 'pending', + }, next_change_tick: reselectionTick, next_change: clock.tickToDate(reselectionTick).toISOString(), nextChangeAt: clock.tickToDate(reselectionTick).toISOString(), @@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => { ); const shiftedReselectionMeta = generals[0]!.meta as Record; expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks); + expect(readOfficeRequest(shiftedReselectionMeta)).toMatchObject({ + createdTick: initialTick - 100, + dueTick: reselectionTick + reconciled.shiftTicks, + status: 'pending', + }); expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe( clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250 ); diff --git a/app/game-engine/test/officeRequestFixture.ts b/app/game-engine/test/officeRequestFixture.ts new file mode 100644 index 00000000..d5a6b4ac --- /dev/null +++ b/app/game-engine/test/officeRequestFixture.ts @@ -0,0 +1,140 @@ +import { type GeneralActionModule, type TriggerValue, type 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 }] }; + +export const buildGeneral = (id: number, overrides: Partial = {}): TurnGeneral => ({ + id, + userId: `user-${id}`, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + turnTime: new Date('0185-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: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 }, + penalty: {}, + officerLevel: 1, + experience: 1_000, + dedication: 2_000, + injury: 0, + gold: 1_500, + rice: 1_600, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + ...overrides, +}); + +export const buildWorld = (options: { + generals?: TurnGeneral[]; + nationMeta?: Record; + nationChiefGeneralId?: number | null; + cityMeta?: Record; + currentYear?: number; + scenarioConst?: Record; + generalActionModules?: ReadonlyArray; + clock?: Pick; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: options.currentYear ?? 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { killturn: 24, scenarioMeta: { startYear: 180 } }, + ...options.clock, + }; + const snapshot: TurnWorldSnapshot = { + generals: options.generals ?? [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3), + ], + cities: [ + { + id: 1, + name: '허창', + nationId: 1, + level: 7, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: options.cityMeta ?? {}, + }, + ], + nations: [ + { + id: 1, + name: '위', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: options.nationChiefGeneralId === undefined ? 1 : options.nationChiefGeneralId, + gold: 10_000, + rice: 20_000, + power: 0, + level: 3, + typeCode: 'che_법가', + meta: options.nationMeta ?? {}, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: { defaultGold: 1000, defaultRice: 1000, ...options.scenarioConst }, + environment: { mapName: 'test', unitSet: 'test' }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + return { + snapshot, + state, + world, + handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }), + }; +}; diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index ecce70eb..cb714888 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -504,11 +504,11 @@ describe('summarizeRealtimeReadModelChanges', () => { mapGeneralIds: [], mapCityIds: [1], mapNationIds: [], - frontStatusGeneralIds: [], + frontStatusGeneralIds: [7], frontStatusNationIds: [1], lobbyGeneralIds: [], contactsChanged: true, - frontStatusChanged: false, + frontStatusChanged: true, lobbyChanged: false, }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index a5ccaec6..e40ccf26 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -33,6 +33,7 @@ const operationNames = (route: Route) => type NavigationFixture = { pageExitAudit?: boolean; + cityOfficeRequestIds?: string[]; decoratedNpcChiefs?: boolean; officerLevel: number; permission: number; @@ -646,6 +647,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, serverId: state.serverId ?? 'che_fixture_season', + cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] }, profile: state.profile ?? 'che', gameIdx: state.gameIdx ?? 101, year: state.currentYear ?? 185, @@ -815,6 +817,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt if (operation === 'general.getFrontStatus') { return response({ serverId: state.serverId ?? 'che_fixture_season', + cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] }, onlineUserCount: 1, onlineNations: '위(1)', onlineGenerals: '메뉴검증장수', @@ -7135,3 +7138,53 @@ test.describe('page exit navigation', () => { expect(context.pages()).toHaveLength(1); }); }); + +for (const width of [1000, 390]) { + test(`city office request toast announces new IDs without repeat at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + cityOfficeRequestIds: [], + latestVote: null, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await waitForMain(page); + await waitForMainRealtime(page); + await expect(page.locator('.office-request-notice')).toHaveCount(0); + state.cityOfficeRequestIds = ['30:760']; + await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true })); + const toast = page.locator('.office-request-notice'); + await expect(toast).toContainText('새 관직 요청'); + const rect = await toast.boundingBox(); + expect(rect).not.toBeNull(); + expect(rect!.x).toBeGreaterThanOrEqual(0); + expect(rect!.x + rect!.width).toBeLessThanOrEqual(width); + await page.screenshot({ path: testInfo.outputPath(`office-request-toast-${width}.png`), fullPage: true }); + await writeFile(testInfo.outputPath(`office-request-toast-${width}.json`), JSON.stringify(rect)); + await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true })); + await expect(toast).toBeVisible(); + await toast.getByRole('button', { name: '관직 요청 알림 닫기' }).click(); + await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true })); + await expect(toast).toHaveCount(0); + await expect(page.locator('.office-request-link')).toContainText('관직 요청 1건'); + await page.reload(); + await waitForMainRealtime(page); + await expect(toast).toHaveCount(0); + state.cityOfficeRequestIds = ['30:760', '31:760']; + await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true })); + await expect(toast).toContainText('대기 2건'); + await writeFile( + testInfo.outputPath(`office-request-toast-${width}.html`), + await toast.evaluate((element) => element.outerHTML) + ); + await toast.getByRole('link').click(); + await expect(page).toHaveURL(/\/nation\/personnel#city-office-requests$/); + }); +} diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 8468dfcf..da51dcee 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -10,6 +10,8 @@ import { acceptAppConfirmation } from './appConfirmation.js'; type Role = 'leader' | 'head' | 'member'; type FixtureState = { role: Role; + officeRequests?: Record; + officeMutation?: Record; failNextRate?: boolean; failPersonnelLoad?: boolean; testIcons?: boolean; @@ -238,6 +240,24 @@ const installFixture = async (page: Page, state: FixtureState) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } }); if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getCityOfficeRequests') + return response( + state.officeRequests ?? { + canManage: state.role !== 'member', + generalId: 6, + nationId: 1, + serverId: 'office-season', + city: { id: 1, name: '허창' }, + options: [], + requests: [], + running: true, + tickSeconds: 600, + } + ); + if (operation === 'nation.cityOfficeRequest') { + state.officeMutation = jsonInput; + return response({ ok: true }); + } if (operation === 'nation.getPersonnelInfo') { return state.failPersonnelLoad ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') @@ -1099,3 +1119,119 @@ for (const width of [500, 1000]) { await verifyVerticalAlignment(page, testInfo, ['.diplomacy-row > div', '.budget-row > span', '.blue-heading']); }); } + +for (const width of [1000, 390]) { + test(`city office requests show defaults and both decisions at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + const state: FixtureState = { + role: 'head', + rate: 20, + officeRequests: { + canManage: true, + generalId: 2, + nationId: 1, + serverId: 'office-season', + city: { id: 1, name: '허창' }, + options: [], + running: true, + tickSeconds: 600, + requests: ['approve', 'reject'].map((defaultDecision, index) => ({ + id: `3:${index}`, + generalId: 3 + index, + generalName: index ? '장료' : '허저', + cityId: 1, + cityName: '허창', + officerLevel: index ? 3 : 4, + status: 'pending', + defaultDecision, + defaultReason: index ? 'secret' : 'vacant', + dueYear: 190, + dueMonth: 2, + remainingSeconds: 540, + invalidReason: null, + })), + }, + }; + await installFixture(page, state); + await page.goto('nation/personnel'); + const panel = page.getByRole('region', { name: '도시 관직 자원' }); + await expect(panel.getByText('미처리 시 자동 승인', { exact: true })).toBeVisible(); + await expect(panel.getByText('미처리 시 자동 거부', { exact: true })).toBeVisible(); + await expect(panel.getByRole('button', { name: '승인', exact: true })).toHaveCount(2); + await expect(panel.getByRole('button', { name: '거부', exact: true })).toHaveCount(2); + await page.evaluate(() => document.fonts.ready); + const geometry = await panel.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + font: getComputedStyle(element).fontSize, + })); + expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth); + await page.screenshot({ path: testInfo.outputPath(`office-requests-${width}.png`), fullPage: true }); + await writeFile(testInfo.outputPath(`office-requests-${width}.json`), JSON.stringify(geometry)); + await writeFile( + testInfo.outputPath(`office-requests-${width}.html`), + await panel.evaluate((element) => element.outerHTML) + ); + const approval = panel.getByRole('button', { name: '승인', exact: true }).first(); + await approval.hover(); + await approval.focus(); + await expect(approval).toBeFocused(); + await writeFile( + testInfo.outputPath(`office-requests-button-${width}.json`), + JSON.stringify( + await approval.evaluate((element) => ({ + color: getComputedStyle(element).color, + background: getComputedStyle(element).backgroundColor, + lineHeight: getComputedStyle(element).lineHeight, + pointer: getComputedStyle(element).cursor, + focused: document.activeElement === element, + })) + ) + ); + await panel.getByRole('button', { name: '승인', exact: true }).nth(1).click(); + await expect(page.getByTestId('game-notice-dialog')).toContainText('승인 시 기밀 열람 가능'); + await page.getByTestId('game-notice-dialog').getByRole('button', { name: '승인', exact: true }).click(); + await expect.poll(() => state.officeMutation?.action).toBe('approve'); + expect(state.officeMutation).toMatchObject({ targetGeneralId: 4, officeRequestId: '3:1' }); + await panel.getByRole('button', { name: '거부', exact: true }).first().click(); + await page.getByTestId('game-notice-dialog').getByRole('button', { name: '거부', exact: true }).click(); + await expect.poll(() => state.officeMutation?.action).toBe('reject'); + }); + test(`city office volunteer confirms the default at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + const state: FixtureState = { + role: 'member', + rate: 20, + officeRequests: { + canManage: false, + generalId: 6, + nationId: 1, + serverId: 'office-season', + city: { id: 1, name: '허창' }, + requests: [], + running: true, + tickSeconds: 600, + options: [ + { level: 4, allowed: true, reason: null, defaultDecision: 'approve', defaultReason: 'vacant' }, + { + level: 3, + allowed: false, + reason: '지력 부족', + defaultDecision: 'approve', + defaultReason: 'vacant', + }, + ], + }, + }; + await installFixture(page, state); + await page.goto('nation/personnel'); + const panel = page.getByRole('region', { name: '도시 관직 자원' }); + await expect(panel.getByRole('button', { name: '자원', exact: true }).nth(1)).toBeDisabled(); + await panel.getByRole('button', { name: '자원', exact: true }).first().click(); + await expect(page.getByTestId('game-notice-dialog')).toContainText('미처리 시 자동 승인'); + await page.getByTestId('game-notice-dialog').getByRole('button', { name: '자원', exact: true }).click(); + await expect.poll(() => state.officeMutation?.action).toBe('request'); + expect(state.officeMutation).toEqual({ action: 'request', officerLevel: 4 }); + }); +} diff --git a/app/game-frontend/src/components/personnel/CityOfficeRequestNotice.vue b/app/game-frontend/src/components/personnel/CityOfficeRequestNotice.vue new file mode 100644 index 00000000..1cdfb446 --- /dev/null +++ b/app/game-frontend/src/components/personnel/CityOfficeRequestNotice.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/app/game-frontend/src/components/personnel/CityOfficeRequests.vue b/app/game-frontend/src/components/personnel/CityOfficeRequests.vue new file mode 100644 index 00000000..4d70811b --- /dev/null +++ b/app/game-frontend/src/components/personnel/CityOfficeRequests.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 8081e4f7..896290cc 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -1,4 +1,5 @@