인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가

This commit is contained in:
2026-09-29 09:41:01 +00:00
parent cd6ded4970
commit 5bafdfc1e7
36 changed files with 2238 additions and 20 deletions
+24 -2
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic'; 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 type { GameApiContext } from '../../context.js';
import { import {
@@ -989,7 +989,7 @@ export const generalRouter = router({
const now = new Date(); const now = new Date();
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta); 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({ ctx.db.generalAccessLog.findMany({
where: { where: {
lastActionAt: { lastActionAt: {
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
}, },
}), }),
loadCurrentGameTime(ctx.db, now), 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 // vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock advances through negative ticks before the opening anchor, // logical clock advances through negative ticks before the opening anchor,
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
return { return {
serverId, 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, onlineUserCount: onlineGenerals.length,
onlineNations, onlineNations,
onlineGenerals: myOnlineGenerals, onlineGenerals: myOnlineGenerals,
@@ -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,
};
}),
};
});
+3
View File
@@ -1,3 +1,4 @@
import { cityOfficeRequest, getCityOfficeRequests } from './endpoints/cityOfficeRequests.js';
import { router } from '../../trpc.js'; import { router } from '../../trpc.js';
import { appoint } from './endpoints/appoint.js'; import { appoint } from './endpoints/appoint.js';
import { changePermission } from './endpoints/changePermission.js'; import { changePermission } from './endpoints/changePermission.js';
@@ -25,6 +26,8 @@ export const nationRouter = router({
getSecretGeneralList, getSecretGeneralList,
getCityOverview, getCityOverview,
getPersonnelInfo, getPersonnelInfo,
getCityOfficeRequests,
cityOfficeRequest,
getStratFinan, getStratFinan,
setNotice, setNotice,
setScoutMsg, setScoutMsg,
@@ -59,6 +59,7 @@ const classifications = {
'join.reselectPoolGeneral', 'join.reselectPoolGeneral',
'join.selectPoolGeneral', 'join.selectPoolGeneral',
'nation.appoint', 'nation.appoint',
'nation.cityOfficeRequest',
'nation.changePermission', 'nation.changePermission',
'nation.kick', 'nation.kick',
'nation.setBill', '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. // count independently catches mutations that were added to a router but never mounted.
expect(declaredCount).toBe(actual.length); expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length); expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(88);
expect(actual).toEqual(classified); expect(actual).toEqual(classified);
}); });
@@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = {
}; };
const buildContext = ( 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<string, unknown> }>;
} = {}
) => ) =>
({ ({
auth: options.auth === undefined ? auth : options.auth, auth: options.auth === undefined ? auth : options.auth,
@@ -30,14 +36,19 @@ const buildContext = (
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
id: 7, id: 7,
userId: 'owner', userId: 'owner',
officerLevel: options.officerLevel ?? 1,
nationId: 2, nationId: 2,
})), })),
findMany: vi.fn(async () => [ findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
{ id: 7, name: '유비', nationId: 2 }, input.where?.meta
{ id: 8, name: '관우', nationId: 2 }, ? (options.officeRows ?? [])
{ id: 9, name: '조조', nationId: 3 }, : [
{ id: 10, name: '재야장수', nationId: 0 }, { id: 7, name: '유비', nationId: 2 },
]), { id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]
),
}, },
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
@@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({ expect(result).toEqual({
serverId: 'che_260819_front', serverId: 'che_260819_front',
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
onlineUserCount: 4, onlineUserCount: 4,
onlineNations: '【촉】, 【위】', onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우', onlineGenerals: '유비, 관우',
@@ -173,3 +185,31 @@ describe('general.getFrontStatus', () => {
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); 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([]);
});
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
const expectedOwnerCounts: Record<string, number> = { const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19, 'durable-journal': 19,
'engine-owned': 38, 'engine-owned': 39,
'explicit-no-realtime-consumer': 7, 'explicit-no-realtime-consumer': 7,
'external-upload': 1, 'external-upload': 1,
'mixed-saga': 9, 'mixed-saga': 9,
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
const rows = parseManifest(); const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route); const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(88);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length); expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort()); expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames()); expect(manifestRoutes).toEqual(mountedMutationNames());
@@ -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 { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common'; import { ChangeJournal } from '@sammo-ts/common';
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
expect(memberCommand).not.toHaveBeenCalled(); 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<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
@@ -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<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
acceptedAt: Date
): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => {
const fail = (reason: string): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => ({
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 };
};
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { import {
GAME_TICKS_PER_TURN, GAME_TICKS_PER_TURN,
readOfficeRequest,
MAX_SAFE_GAME_TICK, MAX_SAFE_GAME_TICK,
GameClock, GameClock,
buildClockAlignmentPlan, buildClockAlignmentPlan,
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : []; 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( snapshot(
'auction-open-occurrence', 'auction-open-occurrence',
'KEEP', 'KEEP',
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => { const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks); const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift; 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.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql` db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick" SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
_max: { pickMoreFromTick: true }, _max: { pickMoreFromTick: true },
where: { pickMoreFromTick: { not: null } }, where: { pickMoreFromTick: { not: null } },
}), }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(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]> = [ const values: Array<[string, bigint | null]> = [
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
['general.turn_tick', general._max.turnTick], ['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null], ['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick], ['auction.close_tick', auction._max.closeTick],
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
AND (meta->>'next_change_tick')::bigint >= ${cutTick} 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( affected.set(
'auction-deadline', 'auction-deadline',
await db.$executeRaw(GamePrisma.sql` await db.$executeRaw(GamePrisma.sql`
@@ -185,6 +185,16 @@ const zKick = z.object({
destGeneralId: zFiniteNumber, 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({ const zAppoint = z.object({
type: z.literal('appoint'), type: z.literal('appoint'),
userId: z.string().min(1), userId: z.string().min(1),
@@ -666,6 +676,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
return { ...command, requestId: envelope.requestId }; 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 normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
const command = parseWith(zAppoint, envelope.command); const command = parseWith(zAppoint, envelope.command);
if (!command) { if (!command) {
@@ -883,6 +898,7 @@ const normalizers: CommandNormalizerMap = {
changePermission: normalizeChangePermission, changePermission: normalizeChangePermission,
kick: normalizeKick, kick: normalizeKick,
appoint: normalizeAppoint, appoint: normalizeAppoint,
cityOfficeRequest: normalizeCityOfficeRequest,
tournamentRefund: normalizeTournamentRefund, tournamentRefund: normalizeTournamentRefund,
tournamentBettingPayout: normalizeTournamentBettingPayout, tournamentBettingPayout: normalizeTournamentBettingPayout,
tournamentReward: normalizeTournamentReward, tournamentReward: normalizeTournamentReward,
+14 -2
View File
@@ -208,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
permission: asRecord(general.meta).permission, permission: asRecord(general.meta).permission,
penalty: general.penalty, 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: '', frontStatusGlobal: '',
lobbyCount: signature({ npcState: general.npcState }), lobbyCount: signature({ npcState: general.npcState }),
lobbyPersonal: signature({ lobbyPersonal: signature({
@@ -1406,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
GamePrisma.sql` GamePrisma.sql`
UPDATE general UPDATE general
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END, 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( await prisma.$executeRaw(
@@ -22,6 +22,7 @@ import type {
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { import {
GAME_TICKS_PER_TURN, GAME_TICKS_PER_TURN,
shiftOfficeRequestDeadline,
GameClock, GameClock,
assertGameplayCommitAllowed, assertGameplayCommitAllowed,
inferClockPhase, inferClockPhase,
@@ -819,6 +820,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks); const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
this.generals.set(generalId, { this.generals.set(generalId, {
...general, ...general,
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
turnTick, turnTick,
turnTime: clock.tickToDate(turnTick), turnTime: clock.tickToDate(turnTick),
}); });
@@ -1044,6 +1046,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks); const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
this.generals.set(generalId, { this.generals.set(generalId, {
...general, ...general,
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
turnTick, turnTick,
turnTime: clock.tickToDate(turnTick), turnTime: clock.tickToDate(turnTick),
}); });
+2
View File
@@ -1,3 +1,4 @@
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js'; import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
import { initializeAuditPolicies } from '../playAudit/policy.js'; import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
const calendarHandler = composeCalendarHandlers( const calendarHandler = composeCalendarHandlers(
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler, createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
options.monthlyEventHandler, options.monthlyEventHandler,
createCityOfficeRequestCalendarHandler(options.getWorld),
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler, options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler, createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
createPlayAuditHandler(options.getWorld), createPlayAuditHandler(options.getWorld),
@@ -1,3 +1,4 @@
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
import type { import type {
TurnDaemonCommandHandler, TurnDaemonCommandHandler,
TurnDaemonCommand, TurnDaemonCommand,
@@ -187,6 +188,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'changePermission', 'changePermission',
'kick', 'kick',
'appoint', 'appoint',
'cityOfficeRequest',
'voteReward', 'voteReward',
'syncDiplomaticResponse', 'syncDiplomaticResponse',
] as const satisfies readonly TurnDaemonCommand['type'][]; ] as const satisfies readonly TurnDaemonCommand['type'][];
@@ -2587,6 +2589,7 @@ async function handleAppoint(
} else { } else {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' }; return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
} }
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
refreshActorKillturn(world, general); refreshActorKillturn(world, general);
return { type: 'appoint', ok: true, generalId: command.generalId }; return { type: 'appoint', ok: true, generalId: command.generalId };
} }
@@ -3243,6 +3246,12 @@ export const createTurnDaemonCommandHandler = (options: {
changePermission: (command) => changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>), handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>), kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
cityOfficeRequest: async (command) =>
handleCityOfficeRequest(
ctx.world,
command as Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
ctx.commandDb ? await resolveOperationalAcceptedAt(ctx.commandDb, command) : new Date()
),
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>), appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
tournamentRefund: (command) => tournamentRefund: (command) =>
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>), handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
@@ -45,6 +45,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
destCityId: 1, destCityId: 1,
officerLevel: 4, 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: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
{ {
type: 'syncDiplomaticResponse', type: 'syncDiplomaticResponse',
@@ -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<void>) | 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<TurnDaemonCommand, { type: 'cityOfficeRequest' }> = {
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);
});
});
@@ -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<typeof buildWorld>['world'], generalId = 3) =>
handleCityOfficeRequest(
world,
{ type: 'cityOfficeRequest', userId: `user-${generalId}`, generalId, action: 'request', officerLevel: 4 },
acceptedAt
);
const pending = (world: ReturnType<typeof buildWorld>['world']) => readOfficeRequest(world.getGeneralById(3)?.meta)!;
const decide = (
world: ReturnType<typeof buildWorld>['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');
});
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; 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 { import {
createGamePostgresConnector, createGamePostgresConnector,
readTurnRuntimeReady, readTurnRuntimeReady,
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
meta: meta:
index === 0 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_tick: reselectionTick,
next_change: clock.tickToDate(reselectionTick).toISOString(), next_change: clock.tickToDate(reselectionTick).toISOString(),
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(), nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
@@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => {
); );
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>; const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks); 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( expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250 clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
); );
@@ -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> = {}): 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<string, TriggerValue>;
nationChiefGeneralId?: number | null;
cityMeta?: Record<string, TriggerValue>;
currentYear?: number;
scenarioConst?: Record<string, unknown>;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
clock?: Pick<TurnWorldState, 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'>;
}) => {
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 }),
};
};
@@ -504,11 +504,11 @@ describe('summarizeRealtimeReadModelChanges', () => {
mapGeneralIds: [], mapGeneralIds: [],
mapCityIds: [1], mapCityIds: [1],
mapNationIds: [], mapNationIds: [],
frontStatusGeneralIds: [], frontStatusGeneralIds: [7],
frontStatusNationIds: [1], frontStatusNationIds: [1],
lobbyGeneralIds: [], lobbyGeneralIds: [],
contactsChanged: true, contactsChanged: true,
frontStatusChanged: false, frontStatusChanged: true,
lobbyChanged: false, lobbyChanged: false,
}); });
}); });
@@ -33,6 +33,7 @@ const operationNames = (route: Route) =>
type NavigationFixture = { type NavigationFixture = {
pageExitAudit?: boolean; pageExitAudit?: boolean;
cityOfficeRequestIds?: string[];
decoratedNpcChiefs?: boolean; decoratedNpcChiefs?: boolean;
officerLevel: number; officerLevel: number;
permission: number; permission: number;
@@ -646,6 +647,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
return response({ return response({
myGeneral: { id: 7, name: '메뉴검증장수' }, myGeneral: { id: 7, name: '메뉴검증장수' },
serverId: state.serverId ?? 'che_fixture_season', serverId: state.serverId ?? 'che_fixture_season',
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
profile: state.profile ?? 'che', profile: state.profile ?? 'che',
gameIdx: state.gameIdx ?? 101, gameIdx: state.gameIdx ?? 101,
year: state.currentYear ?? 185, year: state.currentYear ?? 185,
@@ -815,6 +817,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
if (operation === 'general.getFrontStatus') { if (operation === 'general.getFrontStatus') {
return response({ return response({
serverId: state.serverId ?? 'che_fixture_season', serverId: state.serverId ?? 'che_fixture_season',
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
onlineUserCount: 1, onlineUserCount: 1,
onlineNations: '위(1)', onlineNations: '위(1)',
onlineGenerals: '메뉴검증장수', onlineGenerals: '메뉴검증장수',
@@ -7135,3 +7138,53 @@ test.describe('page exit navigation', () => {
expect(context.pages()).toHaveLength(1); 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$/);
});
}
+136
View File
@@ -10,6 +10,8 @@ import { acceptAppConfirmation } from './appConfirmation.js';
type Role = 'leader' | 'head' | 'member'; type Role = 'leader' | 'head' | 'member';
type FixtureState = { type FixtureState = {
role: Role; role: Role;
officeRequests?: Record<string, unknown>;
officeMutation?: Record<string, unknown>;
failNextRate?: boolean; failNextRate?: boolean;
failPersonnelLoad?: boolean; failPersonnelLoad?: boolean;
testIcons?: boolean; testIcons?: boolean;
@@ -238,6 +240,24 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } });
if (operation === 'join.getConfig') return response({}); 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') { if (operation === 'nation.getPersonnelInfo') {
return state.failPersonnelLoad return state.failPersonnelLoad
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') ? 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']); 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 });
});
}
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{
serverId?: string;
summary?: { generalId: number; nationId: number; ids: string[] };
}>();
const visible = ref(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let identity = '';
let sessionSeen = new Set<string>();
const count = computed(() => props.summary?.ids.length ?? 0);
const dismiss = () => {
visible.value = false;
clearTimeout(timer);
};
watch(
() => [props.serverId, props.summary] as const,
() => {
const summary = props.summary;
if (!summary || !props.serverId) {
dismiss();
return;
}
const key = `state.${props.serverId}.${summary.generalId}.${summary.nationId}.officeRequests`;
if (identity !== key) {
dismiss();
identity = key;
sessionSeen = new Set();
}
if (!summary.ids.length) {
dismiss();
return;
}
let seen: string[] = [];
try {
const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
if (Array.isArray(stored)) seen = stored.filter((id): id is string => typeof id === 'string');
} catch {
/* 저장소를 사용할 수 없어도 요청 목록은 표시한다. */
}
const fresh = summary.ids.some((id) => !seen.includes(id) && !sessionSeen.has(id));
if (!fresh) return;
try {
localStorage.setItem(key, JSON.stringify([...new Set([...seen, ...summary.ids])].slice(-500)));
} catch {
/* private browsing */
}
summary.ids.forEach((id) => sessionSeen.add(id));
visible.value = true;
clearTimeout(timer);
timer = setTimeout(dismiss, 60_000);
},
{ immediate: true, deep: true }
);
onUnmounted(() => clearTimeout(timer));
</script>
<template>
<RouterLink v-if="count" class="office-request-link" to="/nation/personnel#city-office-requests"
>인사부 · 관직 요청 {{ count }}건</RouterLink
>
<aside v-if="visible && count" class="office-request-notice" role="status" aria-live="polite">
<div>
<strong>새 관직 요청</strong
><button type="button" aria-label="관직 요청 알림 닫기" @click="dismiss">×</button>
</div>
<RouterLink to="/nation/personnel#city-office-requests" @click="dismiss"
>대기 {{ count }}건 · 인사부에서 확인</RouterLink
>
</aside>
</template>
<style scoped>
.office-request-link {
display: block;
padding: 5px 8px;
background: #30302c;
color: #ffe7a3;
text-align: center;
}
.office-request-notice {
position: fixed;
z-index: 1050;
bottom: 100px;
left: 12px;
width: 280px;
max-width: calc(100vw - 24px);
padding: 12px;
background: #282823;
color: #fff;
border: 1px solid #d4bd77;
box-shadow: 0 3px 12px #0008;
}
.office-request-notice > div {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.office-request-notice button {
background: none;
border: 0;
color: #fff;
cursor: pointer;
min-width: 30px;
min-height: 30px;
}
.office-request-notice a {
color: #ffe7a3;
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { trpc } from '../../utils/trpc';
import { useGameFeedback } from '../../composables/useGameFeedback';
type Response = Awaited<ReturnType<typeof trpc.nation.getCityOfficeRequests.query>>;
type Request = Response['requests'][number];
const emit = defineEmits<{ changed: [] }>();
const data = ref<Response | null>(null);
const busy = ref(false);
const error = ref('');
const feedback = useGameFeedback();
const labels = { 2: '종사', 3: '군사', 4: '태수' };
const defaults = { approve: '미처리 시 자동 승인', reject: '미처리 시 자동 거부' };
const reasons = { vacant: '공석', npc: 'M장·N장 재직', absent: '재직자 부재', secret: '기밀 열람 승인 필요' };
const states = { approved: '임명 완료', rejected: '거부', withdrawn: '철회', cancelled: '취소', pending: '대기' };
let timer: ReturnType<typeof setInterval> | undefined;
let loading = false;
let disposed = false;
const load = async () => {
if (loading || disposed) return;
loading = true;
try {
const result = await trpc.nation.getCityOfficeRequests.query();
if (!disposed) {
data.value = result;
error.value = '';
}
} catch {
if (!disposed) error.value = '관직 요청을 불러오지 못했습니다.';
} finally {
loading = false;
}
};
const remaining = (request: Request) => {
if (request.remainingSeconds === null) return '시계 준비 중';
if (!data.value?.running) return '시간 정지';
if (request.remainingSeconds <= 0) return '월 처리 대기';
return `${Math.ceil(request.remainingSeconds / 60)}분 남음`;
};
const mutate = async (input: Parameters<typeof trpc.nation.cityOfficeRequest.mutate>[0]) => {
if (busy.value) return;
busy.value = true;
try {
await trpc.nation.cityOfficeRequest.mutate(input);
await load();
emit('changed');
feedback.success(
input.action === 'request'
? '자원했습니다.'
: input.action === 'approve'
? '임명했습니다.'
: input.action === 'reject'
? '거부했습니다.'
: '철회했습니다.'
);
} catch (err) {
feedback.error(err instanceof Error ? err.message : '요청 처리에 실패했습니다.');
await load();
emit('changed');
} finally {
busy.value = false;
}
};
const request = async (option: Response['options'][number]) => {
if (
!data.value ||
!(await feedback.confirm({
title: `${data.value.city?.name ?? ''} ${labels[option.level]} 자원`,
message: `${defaults[option.defaultDecision]}\n분기 1회 · 게임 한 달 이상 대기`,
acknowledgeLabel: '자원',
}))
)
return;
await mutate({ action: 'request', officerLevel: option.level });
};
const decide = async (entry: Request, action: 'approve' | 'reject' | 'withdraw') => {
const verb = action === 'approve' ? '승인' : action === 'reject' ? '거부' : '철회';
const message = `${entry.generalName} · ${entry.cityName} ${labels[entry.officerLevel]}${action === 'approve' && entry.defaultReason === 'secret' ? '\n승인 시 기밀 열람 가능' : ''}`;
if (!(await feedback.confirm({ title: `관직 요청 ${verb}`, message, acknowledgeLabel: verb }))) return;
await mutate({ action, targetGeneralId: entry.generalId, officeRequestId: entry.id });
};
const refreshVisible = () => {
if (!document.hidden) void load();
};
onMounted(() => {
void load();
timer = setInterval(refreshVisible, 15_000);
window.addEventListener('focus', refreshVisible);
});
onUnmounted(() => {
disposed = true;
clearInterval(timer);
window.removeEventListener('focus', refreshVisible);
});
</script>
<template>
<section id="city-office-requests" class="office-requests" aria-label="도시 관직 자원">
<div class="request-heading">
<strong>도시 관직 자원</strong><span>분기 1회</span
><button type="button" class="legacy-button" :disabled="busy" @click="load">새로고침</button>
</div>
<div v-if="error" role="alert">{{ error }}</div>
<template v-if="data">
<div v-if="!data.canManage" class="request-options">
<strong>{{ data.city?.name ?? '아국 도시 체류 필요' }}</strong>
<div v-for="option in data.options" :key="option.level" class="request-option">
<strong>{{ labels[option.level] }}</strong>
<div>
<span :class="['default-decision', option.defaultDecision]">{{
defaults[option.defaultDecision]
}}</span
><small>{{ option.reason ?? reasons[option.defaultReason] }}</small>
</div>
<button
type="button"
class="legacy-button"
:disabled="busy || !option.allowed"
@click="request(option)"
>
자원
</button>
</div>
</div>
<div class="request-list-heading">
{{
data.canManage
? `대기 요청 ${data.requests.filter((entry) => entry.status === 'pending').length}건`
: '내 요청'
}}
</div>
<div v-if="!data.requests.length" class="empty">
{{ data.canManage ? '대기 중인 요청 없음' : '요청 없음' }}
</div>
<article v-for="entry in data.requests" :key="entry.id" class="request-row">
<div class="request-title">
<strong>{{ entry.generalName }}</strong
><span>{{ entry.cityName }} {{ labels[entry.officerLevel] }}</span>
</div>
<template v-if="entry.status === 'pending'">
<strong :class="['default-decision', entry.defaultDecision]">{{
entry.invalidReason ? '조건 변경 · 취소 예정' : defaults[entry.defaultDecision]
}}</strong>
<small>{{ reasons[entry.defaultReason] }}</small>
<span>{{ entry.dueYear }}년 {{ entry.dueMonth }}월 · {{ remaining(entry) }}</span>
<small v-if="entry.invalidReason" class="invalid">{{ entry.invalidReason }} · 처리 시 취소</small>
<div class="request-actions">
<template v-if="data.canManage"
><button
type="button"
class="legacy-button"
:disabled="busy || !!entry.invalidReason"
@click="decide(entry, 'approve')"
>
승인</button
><button
type="button"
class="legacy-button"
:disabled="busy"
@click="decide(entry, 'reject')"
>
거부
</button></template
>
<button
v-else
type="button"
class="legacy-button"
:disabled="busy"
@click="decide(entry, 'withdraw')"
>
철회
</button>
</div>
</template>
<span v-else
>{{ states[entry.status] }}<small v-if="entry.resultReason">{{ entry.resultReason }}</small></span
>
</article>
</template>
</section>
</template>
<style scoped>
.office-requests {
margin: 8px 0;
border: 1px solid #777;
background: #242424;
padding: 8px;
}
.request-heading,
.request-title,
.request-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.request-heading {
margin-bottom: 8px;
}
.request-heading > span {
color: #ccc;
}
.request-heading > button {
margin-left: auto;
}
.request-option {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 7px 0;
border-top: 1px solid #555;
}
.request-option small,
.request-row small {
display: block;
color: #ccc;
}
.default-decision.approve {
color: #9ae2a3;
}
.default-decision.reject,
.request-row .invalid {
color: #ffd28b;
}
.request-list-heading {
margin-top: 10px;
font-weight: bold;
}
.request-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(180px, 1.3fr) minmax(150px, 1fr) auto;
align-items: center;
gap: 5px 12px;
padding: 10px 0;
border-top: 1px solid #555;
}
.request-title {
grid-column: 1;
grid-row: 1 / span 2;
}
.request-row > .default-decision {
grid-column: 2;
grid-row: 1;
}
.request-row > small {
grid-column: 2;
grid-row: 2;
}
.request-row > span {
grid-column: 3;
grid-row: 1 / span 2;
}
.request-row > .invalid {
grid-column: 1 / -1;
grid-row: 3;
}
.request-actions {
grid-column: 4;
grid-row: 1 / span 2;
justify-content: end;
}
.request-actions button {
min-width: 56px;
}
.empty {
padding: 8px 0;
color: #ccc;
}
@media (max-width: 600px) {
.request-row {
grid-template-columns: minmax(0, 1fr);
}
.request-row > * {
grid-column: 1;
grid-row: auto;
}
.request-actions {
justify-content: start;
}
}
</style>
+2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import CityOfficeRequestNotice from '../components/personnel/CityOfficeRequestNotice.vue';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig'; import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
@@ -335,6 +336,7 @@ watch(
/> />
</div> </div>
<CityOfficeRequestNotice :server-id="frontStatus?.serverId" :summary="frontStatus?.cityOfficeRequests" />
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite"> <aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
<div class="survey-notice-title"> <div class="survey-notice-title">
<strong>설문조사 안내</strong> <strong>설문조사 안내</strong>
@@ -5,6 +5,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import CityOfficeRequests from '../components/personnel/CityOfficeRequests.vue';
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue'; import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue'; import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
@@ -322,6 +323,8 @@ onMounted(() => void loadPersonnel());
</tbody> </tbody>
</table> </table>
<CityOfficeRequests @changed="loadPersonnel" />
<div v-if="error" class="feedback error" role="alert">{{ error }}</div> <div v-if="error" class="feedback error" role="alert">{{ error }}</div>
<div v-if="loading" class="loading">불러오는 중...</div> <div v-if="loading" class="loading">불러오는 중...</div>
@@ -135,3 +135,10 @@ fence를 가진 채 ENGINE 결과를 기다리던 교착을 제거했다. 투표
`adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`, `adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`,
`requestNpcPossessionCommand` caller 검색을 함께 실행해 helper 경유 route를 놓치지 `requestNpcPossessionCommand` caller 검색을 함께 실행해 helper 경유 route를 놓치지
않는다. 않는다.
### 도시 관직 자원 (2026-09-29)
`nation.cityOfficeRequest`는 `engineAuthedProcedure`로 session actor를 전달한다.
`cityOfficeRequests.ts`의 ENGINE handler가 현재 소유자·소속·수뇌 권한·대상과
기한을 다시 검사하고 요청/임명/결과 로그를 같은 durable transaction으로 저장한다.
세부 기본값과 clock 정책은 [도시 관직 자원](../design/city-office-requests.md)을 따른다.
@@ -3,7 +3,7 @@
## 범위와 판정 규칙 ## 범위와 판정 규칙
`app/game-api/src/router/**`에서 `.mutation()`으로 선언하고 실제 `appRouter`에 mount한 `app/game-api/src/router/**`에서 `.mutation()`으로 선언하고 실제 `appRouter`에 mount한
87개 route를 2026-08-24 기준으로 전수 분류한다. 이 목록은 “mutation transport를 89개 route를 2026-09-29 기준으로 전수 분류한다. 이 목록은 “mutation transport를
사용한다”와 “game DB를 변경한다”를 구분한다. 신규 route가 추가되거나 선언한 router가 사용한다”와 “game DB를 변경한다”를 구분한다. 신규 route가 추가되거나 선언한 router가
mount되지 않으면 mount되지 않으면
`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과 `app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과
@@ -37,7 +37,7 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| durable journal | 19 | `betting.bet`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.reserved.repeatGeneral`, `turns.reserved.setGeneral`, `turns.reserved.setGeneralBulk`, `turns.reserved.setNation`, `turns.reserved.setNationBulk`, `turns.reserved.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` | | durable journal | 19 | `betting.bet`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.reserved.repeatGeneral`, `turns.reserved.setGeneral`, `turns.reserved.setGeneralBulk`, `turns.reserved.setNation`, `turns.reserved.setNationBulk`, `turns.reserved.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
| separate access journal | 1 | `public.recordAccess` | | separate access journal | 1 | `public.recordAccess` |
| explicit no realtime consumer | 7 | `board.writeArticle`, `board.writeComment`; `join.listPossessCandidates`; `messages.readLatest`; `turns.reserved.repeatNation`, `turns.reserved.shiftNation`; `vote.addComment` | | explicit no realtime consumer | 7 | `board.writeArticle`, `board.writeComment`; `join.listPossessCandidates`; `messages.readLatest`; `turns.reserved.repeatNation`, `turns.reserved.shiftNation`; `vote.addComment` |
| engine owned | 38 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.getSelectionPool`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`, `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` | | engine owned | 39 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.getSelectionPool`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.cityOfficeRequest`, `nation.kick`, `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` | | mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` | | Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` | | operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
@@ -95,3 +95,7 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
4. 따라서 dashboard/map coverage v1 코드 조건은 충족했지만 migration은 rolling deploy 4. 따라서 dashboard/map coverage v1 코드 조건은 충족했지만 migration은 rolling deploy
안전을 위해 0을 유지한다. 전체 writer binary 배포가 확인된 뒤에만 activation command를 안전을 위해 0을 유지한다. 전체 writer binary 배포가 확인된 뒤에만 activation command를
실행하며, 문제 시 meta를 0으로 내려 즉시 full-compute fallback한다. 실행하며, 문제 시 meta를 0으로 내려 즉시 full-compute fallback한다.
도시 관직 자원은 ENGINE 입력 이벤트로 접수·처리하며, `general.meta.cityOfficeRequest`
변경을 front-status journal/SSE에 연결한다. [처리 정책](../design/city-office-requests.md)을
따르며, HTTP actor 검증과 실제 ENGINE PostgreSQL rollback/reload를 별도로 확인한다.
@@ -44,6 +44,7 @@ messages.respond durable-journal direct-endpoint session-user-db-general dynamic
messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.cityOfficeRequest engine-owned core-only session-user-engine-general actual-db app/game-engine/test/cityOfficeRequestPersistence.integration.test.ts no-route-actual-db
nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db
1 route owner_boundary ref_basis actor_source strongest_evidence evidence_path remaining_gap
44 messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
45 nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
46 nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
47 nation.cityOfficeRequest engine-owned core-only session-user-engine-general actual-db app/game-engine/test/cityOfficeRequestPersistence.integration.test.ts no-route-actual-db
48 nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
49 nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
50 nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db
@@ -289,6 +289,21 @@
], ],
"projectionFields": [], "projectionFields": [],
"owner": "game-engine/clock-operation" "owner": "game-engine/clock-operation"
},
{
"key": "city-office-request-occurrence",
"policy": "KEEP",
"authorityFields": ["general.meta.cityOfficeRequest.createdTick"],
"projectionFields": [],
"owner": "game-engine/city-office-requests"
},
{
"key": "city-office-request-deadline",
"policy": "SHIFT",
"authorityFields": ["general.meta.cityOfficeRequest.dueTick"],
"projectionFields": [],
"rowScope": "pending requests at or after the reconciliation cut",
"owner": "game-engine/city-office-requests"
} }
], ],
"redis": [ "redis": [
+76
View File
@@ -0,0 +1,76 @@
# 도시 관직 자원
## 정책
인사부에서 현재 체류 중인 아국 도시의 태수·군사·종사에 자원한다. 기존 수동
임명은 유지하며, 이 요청 절차는 Core 전용 기능이다.
| 상태 | 기한까지 미처리한 요청 |
| -------------------------------------------- | ------------------------- |
| 공석 | 승인 |
| 소유자가 없는 N장(npcState=2)·M장(3) 재직 | 승인 |
| 다른 도시에 체류하는 유저장·빙의장 재직 | 거부 |
| 신청자의 소속연수가 국가 기밀 공개 기준 미달 | 위 조건보다 우선하여 거부 |
| 유저장·빙의장이 해당 도시에 체류 | 신청 불가 |
수뇌는 기본값과 관계없이 승인·거부할 수 있다. 명시적 승인은 즉시 임명한다.
승인 시 기존 지방 관직의 P1 기밀 권한을 받는다. 기본 거부 요청은 소속연수가
늘어도 자동 승인으로 전환하지 않는다. 대기 중 국가의 기밀 공개 기준이 강화되면
자동 승인 요청도 자동 거부로 제한한다.
능력치 기준은 기존 임명과 같다(태수 무력, 군사 지력은 scenario chiefMin 이상,
종사는 별도 능력치 하한 없음). 일반·도시 관직의 유저장과 빙의장만 자원할 수 있고,
noChief 벌점 및 도시 임명 lock을 검사한다. 군주·수뇌는 기존 수동 임명을 이용한다.
계급·사관 비교로 다른 유저장을 자동 교체하지 않는다.
## 기한과 충돌
- 장수당 분기 1회 접수한다. 철회·거부도 횟수를 소비한다. 대기 요청은 장수당 1건,
도시·관직당 1건이다.
- 요청 생성의 논리 tick에서 최소 한 달(`GAME_TICKS_PER_TURN`) 뒤 첫 월 경계를
마감으로 저장한다. 월말 직전 요청도 검토 기간이 단축되지 않는다. 정지 중에는
논리 tick이 진행하지 않으며, 턴 시간 변경은 남은 tick을 유지한다. 운영 시계
재정렬과 backlog 복구는 접수 시각을 보존하고 대기 마감만 이동한다.
`game-clock-participants.json`에 occurrence KEEP/deadline SHIFT로 등록한다.
- 수뇌의 수동 임명은 요청 때문에 막히지 않는다. 수동 임명 후 조건이 바뀐 요청은
취소한다. 자동 임명도 기존 `officer_set`을 설정한다.
- 분기 lock 초기화 전에는 무효 요청을 취소하고, 월 처리 후에는 기한이 된 요청을
판정한다. 소속·소유자·체류 도시·도시 소유·재직자 ID·능력치·벌점·lock을 다시
확인한다. 같은 슬롯의 재직자가 바뀌면 새 재직자를 덮어쓰지 않는다.
- 신청·승인·거부·철회 명령도 실행 시점 actor와 durable 입력 이벤트 소유자를 검사한다.
## 저장과 알림
`general.meta.cityOfficeRequest`에 장수당 마지막 요청 하나만 저장한다. 요청에는
신청자·소유자·국가·도시·관직·기존 재직자, 접수 분기, 생성/마감 tick, 기본 처리와
이유, 상태 및 결과 이유가 포함된다. `pending`에서 `approved`, `rejected`,
`withdrawn`, `cancelled` 중 하나로 종료한다. 기존 JSON metadata의 additive 확장으로
새 테이블이나 migration은 없다. 기존 입력 이벤트 idempotency와 ENGINE
transaction, dirty state flush 및 worldLoader 재시작 경로를 사용한다.
API는 일반 장수에게 본인 요청만, 수뇌에게 같은 국가의 대기 요청도 제공한다.
userId와 원본 metadata는 응답에 포함하지 않는다. 처리 결과는 개인·국가 로그에
기록한다.
메인 `getFrontStatus.cityOfficeRequests`에는 수뇌에게 대기 요청 ID만 제공한다.
요청 metadata 및 수뇌 권한 변경은 front-status projection을 무효화하여 기존
SSE read-model 갱신을 따른다. 알림 cursor는 시즌·장수·국가별로 분리한다. 새 요청은
60초간 닫을 수 있는 알림과 인사부 링크로 표시하며, 닫은 같은 요청은 재알림하지
않는다. 대기 건수 링크는 알림을 닫아도 남는다. 외부 Web Push는 이 기능의 범위가
아니다.
인사부는 기본값·이유·마감과 버튼을 짧은 항목으로 표시한다. 긴 설명문을 상시
출력하지 않는다. 기밀 조건에 해당하는 명시적 승인 확인창에는 기밀 열람 가능을
표시한다. 목록은 활성 화면에서 15초 간격 및 focus 복귀 때 갱신한다.
## 구현과 검증 진입점
- `packages/common/src/personnel/officeRequest.ts`: 영속 요청 타입·검증
- `packages/logic/src/personnel/officeRequest.ts`: 공유 자원 조건
- `app/game-engine/src/turn/cityOfficeRequests.ts`: 명령·월 처리·결과 로그
- `app/game-api/src/router/nation/endpoints/cityOfficeRequests.ts`: 인증 API·read model
- `CityOfficeRequests.vue`, `CityOfficeRequestNotice.vue`: 인사부·메인 알림
- `cityOfficeRequests.test.ts`, `cityOfficeRequestPersistence.integration.test.ts`:
정책·기한·충돌·rollback·재시작
- `nationPersonnelRouter.test.ts`, `mainFrontStatusRouter.test.ts`: DTO 권한과 HTTP 경계
- `nationOffices.spec.ts`, `mainNavigation.spec.ts`: production Chromium 조작·알림·geometry
+2
View File
@@ -33,3 +33,5 @@ export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js'; export * from './http/trpcTransport.js';
export * from './webPush/types.js'; export * from './webPush/types.js';
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js'; export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
export * from './personnel/officeRequest.js';
@@ -0,0 +1,111 @@
import { asRecord } from '../util/parse.js';
export type OfficeRequest = {
id: string;
generalId: number;
userId: string;
nationId: number;
cityId: number;
officerLevel: 2 | 3 | 4;
incumbentId: number;
quarter: number;
createdTick: number;
dueTick: number;
defaultDecision: 'approve' | 'reject';
defaultReason: 'vacant' | 'npc' | 'absent' | 'secret';
status: 'pending' | 'approved' | 'rejected' | 'withdrawn' | 'cancelled';
resultReason?: string;
};
export type CityOfficeLevel = OfficeRequest['officerLevel'];
export const OFFICE_REQUEST_KEY = 'cityOfficeRequest';
export const readOfficeRequest = (meta: unknown): OfficeRequest | null => {
const value = asRecord(asRecord(meta)[OFFICE_REQUEST_KEY]);
const {
id,
generalId,
userId,
nationId,
cityId,
officerLevel,
incumbentId,
quarter,
createdTick,
dueTick,
defaultDecision,
defaultReason,
status,
resultReason,
} = value;
if (typeof id !== 'string' || typeof userId !== 'string') return null;
if (
typeof generalId !== 'number' ||
!Number.isSafeInteger(generalId) ||
typeof nationId !== 'number' ||
!Number.isSafeInteger(nationId) ||
typeof cityId !== 'number' ||
!Number.isSafeInteger(cityId)
)
return null;
if (
typeof incumbentId !== 'number' ||
!Number.isSafeInteger(incumbentId) ||
typeof quarter !== 'number' ||
!Number.isSafeInteger(quarter)
)
return null;
if (
typeof createdTick !== 'number' ||
!Number.isSafeInteger(createdTick) ||
typeof dueTick !== 'number' ||
!Number.isSafeInteger(dueTick)
)
return null;
if (officerLevel !== 2 && officerLevel !== 3 && officerLevel !== 4) return null;
if (defaultDecision !== 'approve' && defaultDecision !== 'reject') return null;
if (
defaultReason !== 'vacant' &&
defaultReason !== 'npc' &&
defaultReason !== 'absent' &&
defaultReason !== 'secret'
)
return null;
if (
status !== 'pending' &&
status !== 'approved' &&
status !== 'rejected' &&
status !== 'withdrawn' &&
status !== 'cancelled'
)
return null;
return {
id,
generalId,
userId,
nationId,
cityId,
officerLevel,
incumbentId,
quarter,
createdTick,
dueTick,
defaultDecision,
defaultReason,
status,
...(typeof resultReason === 'string' ? { resultReason } : {}),
};
};
export const officeRequestQuarter = (year: number, month: number): number => year * 4 + Math.floor((month - 1) / 3);
// 재정렬은 대기 중인 마감만 이동한다. 접수 시각과 완료된 요청은 과거 사실로 보존한다.
export const shiftOfficeRequestDeadline = <T extends Record<string, unknown>>(
meta: T,
deltaTicks: number,
cutTick = Number.NEGATIVE_INFINITY
): T => {
const request = readOfficeRequest(meta);
if (!request || request.status !== 'pending' || request.dueTick < cutTick || deltaTicks === 0) return meta;
const dueTick = request.dueTick + deltaTicks;
if (!Number.isSafeInteger(dueTick))
throw new Error('City office request deadline exceeds the safe game tick range.');
return { ...meta, [OFFICE_REQUEST_KEY]: { ...request, dueTick } };
};
+11
View File
@@ -222,6 +222,16 @@ export type TurnDaemonCommand =
targetGeneralIds: number[]; targetGeneralIds: number[];
} }
| { type: 'kick'; requestId?: string; userId: string; generalId: number; destGeneralId: number } | { type: 'kick'; requestId?: string; userId: string; generalId: number; destGeneralId: number }
| {
type: 'cityOfficeRequest';
requestId?: string;
userId: string;
generalId: number;
action: 'request' | 'approve' | 'reject' | 'withdraw';
officerLevel?: 2 | 3 | 4;
targetGeneralId?: number;
officeRequestId?: string;
}
| { | {
type: 'appoint'; type: 'appoint';
requestId?: string; requestId?: string;
@@ -599,6 +609,7 @@ export type TurnDaemonCommandResult =
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string } | { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
| { type: 'changePermission'; ok: boolean; generalId: number; reason?: string } | { type: 'changePermission'; ok: boolean; generalId: number; reason?: string }
| { type: 'kick'; ok: boolean; generalId: number; reason?: string } | { type: 'kick'; ok: boolean; generalId: number; reason?: string }
| { type: 'cityOfficeRequest'; ok: boolean; generalId: number; reason?: string }
| { type: 'appoint'; ok: boolean; generalId: number; reason?: string } | { type: 'appoint'; ok: boolean; generalId: number; reason?: string }
| { | {
type: 'tournamentRefund'; type: 'tournamentRefund';
+2
View File
@@ -25,3 +25,5 @@ export * from './tournament/index.js';
export * from './troop/management.js'; export * from './troop/management.js';
export * from './world/index.js'; export * from './world/index.js';
export * from './war/index.js'; export * from './war/index.js';
export * from './personnel/officeRequest.js';
@@ -0,0 +1,55 @@
import { asRecord, type CityOfficeLevel, type OfficeRequest } from '@sammo-ts/common';
export interface OfficeCandidate {
id: number;
nationId: number;
cityId: number;
officerLevel: number;
npcState: number;
userId?: string | null;
strength: number;
intelligence: number;
meta: unknown;
penalty?: unknown;
}
export interface OfficeRequestEligibility {
allowed: boolean;
reason: string | null;
defaultDecision: OfficeRequest['defaultDecision'];
defaultReason: OfficeRequest['defaultReason'];
}
const numberOr = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' || typeof value === 'string' ? Number(value) : Number.NaN;
return Number.isFinite(parsed) ? parsed : fallback;
};
// 자원 UI와 실행 시점 검사는 같은 규칙을 사용한다. 수동 임명 계약은 바꾸지 않는다.
export const resolveOfficeRequestEligibility = (input: {
applicant: OfficeCandidate;
city: { id: number; nationId: number; meta: unknown } | null;
level: CityOfficeLevel;
incumbent: OfficeCandidate | null;
nationMeta: unknown;
chiefStatMin: number;
}): OfficeRequestEligibility => {
const { applicant, city, level, incumbent } = input;
const secret =
numberOr(asRecord(applicant.meta).belong, 0) <
numberOr(asRecord(input.nationMeta).secretlimit ?? asRecord(input.nationMeta).secretLimit, 3);
const replaceNpc = incumbent !== null && !incumbent.userId && [2, 3].includes(incumbent.npcState);
const defaultReason = secret ? 'secret' : !incumbent ? 'vacant' : replaceNpc ? 'npc' : 'absent';
const defaultDecision = defaultReason === 'vacant' || defaultReason === 'npc' ? 'approve' : 'reject';
let reason: string | null = null;
if (!city || !applicant.nationId || city.nationId !== applicant.nationId || city.id !== applicant.cityId)
reason = '체류 중인 아국 도시에서만 자원할 수 있습니다.';
else if (applicant.officerLevel < 1 || applicant.officerLevel >= 5 || applicant.npcState >= 2)
reason = '일반·도시 관직 장수만 자원할 수 있습니다.';
else if (asRecord(applicant.penalty).noChief) reason = '관직을 맡을 수 없는 상태입니다.';
else if ((Number(asRecord(city.meta).officer_set ?? 0) & (1 << level)) !== 0) reason = '이번 분기 임명 완료';
else if (level === 4 && applicant.strength < input.chiefStatMin) reason = '무력 부족';
else if (level === 3 && applicant.intelligence < input.chiefStatMin) reason = '지력 부족';
else if (incumbent?.id === applicant.id) reason = '현재 재직 중';
else if (incumbent && !replaceNpc && incumbent.cityId === city.id) reason = '재직자가 도시에 체류 중';
return { allowed: reason === null, reason, defaultDecision, defaultReason };
};