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

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 { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import {
@@ -989,7 +989,7 @@ export const generalRouter = router({
const now = new Date();
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([
const [onlineAccess, ownNation, openPolls, gameTime, officeRequestRows] = await Promise.all([
ctx.db.generalAccessLog.findMany({
where: {
lastActionAt: {
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
},
}),
loadCurrentGameTime(ctx.db, now),
me.nationId > 0 && me.officerLevel >= 5 && !asRecord(me.penalty).noChief
? ctx.db.general.findMany({
where: {
nationId: me.nationId,
meta: { path: ['cityOfficeRequest', 'status'], equals: 'pending' },
},
select: { id: true, userId: true, meta: true },
})
: Promise.resolve([]),
]);
// vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock advances through negative ticks before the opening anchor,
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
return {
serverId,
cityOfficeRequests: {
generalId: me.id,
nationId: me.nationId,
ids: officeRequestRows.flatMap((row) => {
const request = readOfficeRequest(row.meta);
return request?.status === 'pending' &&
request.generalId === row.id &&
request.nationId === me.nationId &&
request.userId === row.userId
? [request.id]
: [];
}),
},
onlineUserCount: onlineGenerals.length,
onlineNations,
onlineGenerals: myOnlineGenerals,
@@ -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 { appoint } from './endpoints/appoint.js';
import { changePermission } from './endpoints/changePermission.js';
@@ -25,6 +26,8 @@ export const nationRouter = router({
getSecretGeneralList,
getCityOverview,
getPersonnelInfo,
getCityOfficeRequests,
cityOfficeRequest,
getStratFinan,
setNotice,
setScoutMsg,
@@ -59,6 +59,7 @@ const classifications = {
'join.reselectPoolGeneral',
'join.selectPoolGeneral',
'nation.appoint',
'nation.cityOfficeRequest',
'nation.changePermission',
'nation.kick',
'nation.setBill',
@@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => {
// count independently catches mutations that were added to a router but never mounted.
expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(88);
expect(actual).toEqual(classified);
});
@@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = {
};
const buildContext = (
options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {}
options: {
auth?: GameSessionTokenPayload | null;
hasVoted?: boolean;
preopenClock?: boolean;
officerLevel?: number;
officeRows?: Array<{ id: number; userId: string; meta: Record<string, unknown> }>;
} = {}
) =>
({
auth: options.auth === undefined ? auth : options.auth,
@@ -30,14 +36,19 @@ const buildContext = (
findFirst: vi.fn(async () => ({
id: 7,
userId: 'owner',
officerLevel: options.officerLevel ?? 1,
nationId: 2,
})),
findMany: vi.fn(async () => [
{ id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]),
findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
input.where?.meta
? (options.officeRows ?? [])
: [
{ id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]
),
},
worldState: {
findFirst: vi.fn(async () => ({
@@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({
serverId: 'che_260819_front',
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
onlineUserCount: 4,
onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우',
@@ -173,3 +185,31 @@ describe('general.getFrontStatus', () => {
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
});
it('only notifies chiefs about pending requests belonging to current members and owners', async () => {
const pending = {
id: '8:760',
generalId: 8,
userId: 'user-8',
nationId: 2,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: 0,
dueTick: 3600000,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
};
const officeRows = [
{ id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } },
{ id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } },
{ id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } },
{ id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } },
];
const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows }));
expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']);
const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows }));
expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]);
});
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19,
'engine-owned': 38,
'engine-owned': 39,
'explicit-no-realtime-consumer': 7,
'external-upload': 1,
'mixed-saga': 9,
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(88);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames());
@@ -1,3 +1,6 @@
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { once } from 'node:events';
import type { AddressInfo } from 'node:net';
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
expect(memberCommand).not.toHaveBeenCalled();
});
});
describe('city office requests', () => {
it.each(['approve', 'reject', 'withdraw'] as const)(
'binds %s to the session actor and durable request ID',
async (action) => {
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand }));
await expect(
caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' })
).resolves.toEqual({ ok: true });
expect(requestCommand).toHaveBeenCalledWith({
type: 'cityOfficeRequest',
userId: 'user-22',
generalId: 22,
requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest',
action,
targetGeneralId: 30,
officeRequestId: '30:760',
});
}
);
it('validates request fields and propagates engine rejection', async () => {
const requestCommand = vi
.fn()
.mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' });
const caller = appRouter.createCaller(createContext({ requestCommand }));
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({
message: '이번 분기 자원 완료',
});
// @ts-expect-error invalid officer must also be rejected at the HTTP boundary
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(requestCommand).toHaveBeenCalledTimes(1);
});
it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => {
const office = {
id: '30:760',
generalId: 30,
userId: 'user-30',
nationId: 1,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: 0,
dueTick: 3600000,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
};
const me = { ...baseGeneral, officerLevel };
const caller = appRouter.createCaller(
createContext({
me,
db: {
general: {
findFirst: vi.fn(async () => me),
findMany: vi.fn(async () => [
me,
{
...baseGeneral,
id: 30,
userId: 'user-30',
officerLevel: 1,
meta: { belong: 5, cityOfficeRequest: office },
},
]),
},
nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) },
city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) },
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
meta: {},
config: { stat: { chiefMin: 65 } },
clockBaseTime: new Date('2026-01-01'),
clockTick: 0n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-01-01'),
clockPhase: 'RUNNING',
})),
},
},
})
);
const result = await caller.nation.getCityOfficeRequests();
expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0);
expect(JSON.stringify(result)).not.toContain('user-30');
expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false);
});
});
it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => {
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
const server = createHTTPServer({
router: appRouter,
createContext: ({ req }): GameApiContext => ({
...createContext({ requestId: 'office-http-wire', requestCommand }),
auth: req.headers['x-test-session'] ? auth : null,
}),
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address() as AddressInfo;
const send = (body: unknown, authenticated = true) =>
fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) },
body: JSON.stringify(body),
});
try {
expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401);
expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400);
expect(requestCommand).not.toHaveBeenCalled();
expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200);
expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' }));
requestCommand.mockResolvedValueOnce({
type: 'cityOfficeRequest',
ok: false,
generalId: 22,
reason: '이미 처리된 요청',
});
expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400);
} finally {
server.closeAllConnections();
await new Promise<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 {
GAME_TICKS_PER_TURN,
readOfficeRequest,
MAX_SAFE_GAME_TICK,
GameClock,
buildClockAlignmentPlan,
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
})
),
snapshot(
'city-office-request-occurrence',
'KEEP',
generals.flatMap(({ id, meta }) => {
const request = readOfficeRequest(meta);
return request ? [{ id, createdTick: request.createdTick }] : [];
})
),
snapshot(
'city-office-request-deadline',
'SHIFT',
generals.flatMap(({ id, meta }) => {
const request = readOfficeRequest(meta);
return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick
? [{ id, dueTick: request.dueTick }]
: [];
})
),
snapshot(
'auction-open-occurrence',
'KEEP',
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
_max: { pickMoreFromTick: true },
where: { pickMoreFromTick: { not: null } },
}),
db.$queryRaw<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]> = [
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick],
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
`)
);
affected.set(
'city-office-request-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}',
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks}))
WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick}
`)
);
affected.set(
'auction-deadline',
await db.$executeRaw(GamePrisma.sql`
@@ -185,6 +185,16 @@ const zKick = z.object({
destGeneralId: zFiniteNumber,
});
const zCityOfficeRequest = z.object({
type: z.literal('cityOfficeRequest'),
userId: z.string().min(1),
generalId: z.number().int().positive(),
action: z.enum(['request', 'approve', 'reject', 'withdraw']),
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
targetGeneralId: z.number().int().positive().optional(),
officeRequestId: z.string().min(1).optional(),
});
const zAppoint = z.object({
type: z.literal('appoint'),
userId: z.string().min(1),
@@ -666,6 +676,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeCityOfficeRequest: CommandNormalizer<'cityOfficeRequest'> = (envelope) => {
const command = parseWith(zCityOfficeRequest, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
const command = parseWith(zAppoint, envelope.command);
if (!command) {
@@ -883,6 +898,7 @@ const normalizers: CommandNormalizerMap = {
changePermission: normalizeChangePermission,
kick: normalizeKick,
appoint: normalizeAppoint,
cityOfficeRequest: normalizeCityOfficeRequest,
tournamentRefund: normalizeTournamentRefund,
tournamentBettingPayout: normalizeTournamentBettingPayout,
tournamentReward: normalizeTournamentReward,
+14 -2
View File
@@ -208,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
permission: asRecord(general.meta).permission,
penalty: general.penalty,
}),
frontStatus: signature({ name: general.name, nationId: general.nationId }),
frontStatus: signature({
name: general.name,
nationId: general.nationId,
officerLevel: general.officerLevel,
userId: general.userId,
penalty: general.penalty,
cityOfficeRequest: asRecord(general.meta).cityOfficeRequest,
}),
frontStatusGlobal: '',
lobbyCount: signature({ npcState: general.npcState }),
lobbyPersonal: signature({
@@ -1406,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
GamePrisma.sql`
UPDATE general
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second')
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second'),
meta = CASE WHEN meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
THEN jsonb_set(meta, '{cityOfficeRequest,dueTick}',
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${deltaTicks}))
ELSE meta END
`
);
await prisma.$executeRaw(
@@ -22,6 +22,7 @@ import type {
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import {
GAME_TICKS_PER_TURN,
shiftOfficeRequestDeadline,
GameClock,
assertGameplayCommitAllowed,
inferClockPhase,
@@ -819,6 +820,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
this.generals.set(generalId, {
...general,
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
turnTick,
turnTime: clock.tickToDate(turnTick),
});
@@ -1044,6 +1046,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
this.generals.set(generalId, {
...general,
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
turnTick,
turnTime: clock.tickToDate(turnTick),
});
+2
View File
@@ -1,3 +1,4 @@
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
const calendarHandler = composeCalendarHandlers(
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
options.monthlyEventHandler,
createCityOfficeRequestCalendarHandler(options.getWorld),
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
createPlayAuditHandler(options.getWorld),
@@ -1,3 +1,4 @@
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
import type {
TurnDaemonCommandHandler,
TurnDaemonCommand,
@@ -187,6 +188,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'changePermission',
'kick',
'appoint',
'cityOfficeRequest',
'voteReward',
'syncDiplomaticResponse',
] as const satisfies readonly TurnDaemonCommand['type'][];
@@ -2587,6 +2589,7 @@ async function handleAppoint(
} else {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
}
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
refreshActorKillturn(world, general);
return { type: 'appoint', ok: true, generalId: command.generalId };
}
@@ -3243,6 +3246,12 @@ export const createTurnDaemonCommandHandler = (options: {
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
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' }>),
tournamentRefund: (command) =>
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
@@ -45,6 +45,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
destCityId: 1,
officerLevel: 4,
},
{
type: 'cityOfficeRequest',
requestId: 'cityOfficeRequest',
userId,
generalId: 7,
action: 'request',
officerLevel: 4,
},
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
{
type: 'syncDiplomaticResponse',
@@ -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 { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery, readOfficeRequest } from '@sammo-ts/common';
import {
createGamePostgresConnector,
readTurnRuntimeReady,
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
meta:
index === 0
? {
cityOfficeRequest: {
id: '1:760',
generalId: 1,
userId: 'clock-user',
nationId: 1,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: initialTick - 100,
dueTick: reselectionTick,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
},
next_change_tick: reselectionTick,
next_change: clock.tickToDate(reselectionTick).toISOString(),
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
@@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => {
);
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
expect(readOfficeRequest(shiftedReselectionMeta)).toMatchObject({
createdTick: initialTick - 100,
dueTick: reselectionTick + reconciled.shiftTicks,
status: 'pending',
});
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
);
@@ -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: [],
mapCityIds: [1],
mapNationIds: [],
frontStatusGeneralIds: [],
frontStatusGeneralIds: [7],
frontStatusNationIds: [1],
lobbyGeneralIds: [],
contactsChanged: true,
frontStatusChanged: false,
frontStatusChanged: true,
lobbyChanged: false,
});
});
@@ -33,6 +33,7 @@ const operationNames = (route: Route) =>
type NavigationFixture = {
pageExitAudit?: boolean;
cityOfficeRequestIds?: string[];
decoratedNpcChiefs?: boolean;
officerLevel: number;
permission: number;
@@ -646,6 +647,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
return response({
myGeneral: { id: 7, name: '메뉴검증장수' },
serverId: state.serverId ?? 'che_fixture_season',
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
profile: state.profile ?? 'che',
gameIdx: state.gameIdx ?? 101,
year: state.currentYear ?? 185,
@@ -815,6 +817,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
if (operation === 'general.getFrontStatus') {
return response({
serverId: state.serverId ?? 'che_fixture_season',
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
onlineUserCount: 1,
onlineNations: '위(1)',
onlineGenerals: '메뉴검증장수',
@@ -7135,3 +7138,53 @@ test.describe('page exit navigation', () => {
expect(context.pages()).toHaveLength(1);
});
});
for (const width of [1000, 390]) {
test(`city office request toast announces new IDs without repeat at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
cityOfficeRequestIds: [],
latestVote: null,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await waitForMain(page);
await waitForMainRealtime(page);
await expect(page.locator('.office-request-notice')).toHaveCount(0);
state.cityOfficeRequestIds = ['30:760'];
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
const toast = page.locator('.office-request-notice');
await expect(toast).toContainText('새 관직 요청');
const rect = await toast.boundingBox();
expect(rect).not.toBeNull();
expect(rect!.x).toBeGreaterThanOrEqual(0);
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
await page.screenshot({ path: testInfo.outputPath(`office-request-toast-${width}.png`), fullPage: true });
await writeFile(testInfo.outputPath(`office-request-toast-${width}.json`), JSON.stringify(rect));
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
await expect(toast).toBeVisible();
await toast.getByRole('button', { name: '관직 요청 알림 닫기' }).click();
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
await expect(toast).toHaveCount(0);
await expect(page.locator('.office-request-link')).toContainText('관직 요청 1건');
await page.reload();
await waitForMainRealtime(page);
await expect(toast).toHaveCount(0);
state.cityOfficeRequestIds = ['30:760', '31:760'];
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
await expect(toast).toContainText('대기 2건');
await writeFile(
testInfo.outputPath(`office-request-toast-${width}.html`),
await toast.evaluate((element) => element.outerHTML)
);
await toast.getByRole('link').click();
await expect(page).toHaveURL(/\/nation\/personnel#city-office-requests$/);
});
}
+136
View File
@@ -10,6 +10,8 @@ import { acceptAppConfirmation } from './appConfirmation.js';
type Role = 'leader' | 'head' | 'member';
type FixtureState = {
role: Role;
officeRequests?: Record<string, unknown>;
officeMutation?: Record<string, unknown>;
failNextRate?: boolean;
failPersonnelLoad?: boolean;
testIcons?: boolean;
@@ -238,6 +240,24 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getCityOfficeRequests')
return response(
state.officeRequests ?? {
canManage: state.role !== 'member',
generalId: 6,
nationId: 1,
serverId: 'office-season',
city: { id: 1, name: '허창' },
options: [],
requests: [],
running: true,
tickSeconds: 600,
}
);
if (operation === 'nation.cityOfficeRequest') {
state.officeMutation = jsonInput;
return response({ ok: true });
}
if (operation === 'nation.getPersonnelInfo') {
return state.failPersonnelLoad
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
@@ -1099,3 +1119,119 @@ for (const width of [500, 1000]) {
await verifyVerticalAlignment(page, testInfo, ['.diplomacy-row > div', '.budget-row > span', '.blue-heading']);
});
}
for (const width of [1000, 390]) {
test(`city office requests show defaults and both decisions at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const state: FixtureState = {
role: 'head',
rate: 20,
officeRequests: {
canManage: true,
generalId: 2,
nationId: 1,
serverId: 'office-season',
city: { id: 1, name: '허창' },
options: [],
running: true,
tickSeconds: 600,
requests: ['approve', 'reject'].map((defaultDecision, index) => ({
id: `3:${index}`,
generalId: 3 + index,
generalName: index ? '장료' : '허저',
cityId: 1,
cityName: '허창',
officerLevel: index ? 3 : 4,
status: 'pending',
defaultDecision,
defaultReason: index ? 'secret' : 'vacant',
dueYear: 190,
dueMonth: 2,
remainingSeconds: 540,
invalidReason: null,
})),
},
};
await installFixture(page, state);
await page.goto('nation/personnel');
const panel = page.getByRole('region', { name: '도시 관직 자원' });
await expect(panel.getByText('미처리 시 자동 승인', { exact: true })).toBeVisible();
await expect(panel.getByText('미처리 시 자동 거부', { exact: true })).toBeVisible();
await expect(panel.getByRole('button', { name: '승인', exact: true })).toHaveCount(2);
await expect(panel.getByRole('button', { name: '거부', exact: true })).toHaveCount(2);
await page.evaluate(() => document.fonts.ready);
const geometry = await panel.evaluate((element) => ({
width: element.getBoundingClientRect().width,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
font: getComputedStyle(element).fontSize,
}));
expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth);
await page.screenshot({ path: testInfo.outputPath(`office-requests-${width}.png`), fullPage: true });
await writeFile(testInfo.outputPath(`office-requests-${width}.json`), JSON.stringify(geometry));
await writeFile(
testInfo.outputPath(`office-requests-${width}.html`),
await panel.evaluate((element) => element.outerHTML)
);
const approval = panel.getByRole('button', { name: '승인', exact: true }).first();
await approval.hover();
await approval.focus();
await expect(approval).toBeFocused();
await writeFile(
testInfo.outputPath(`office-requests-button-${width}.json`),
JSON.stringify(
await approval.evaluate((element) => ({
color: getComputedStyle(element).color,
background: getComputedStyle(element).backgroundColor,
lineHeight: getComputedStyle(element).lineHeight,
pointer: getComputedStyle(element).cursor,
focused: document.activeElement === element,
}))
)
);
await panel.getByRole('button', { name: '승인', exact: true }).nth(1).click();
await expect(page.getByTestId('game-notice-dialog')).toContainText('승인 시 기밀 열람 가능');
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '승인', exact: true }).click();
await expect.poll(() => state.officeMutation?.action).toBe('approve');
expect(state.officeMutation).toMatchObject({ targetGeneralId: 4, officeRequestId: '3:1' });
await panel.getByRole('button', { name: '거부', exact: true }).first().click();
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '거부', exact: true }).click();
await expect.poll(() => state.officeMutation?.action).toBe('reject');
});
test(`city office volunteer confirms the default at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const state: FixtureState = {
role: 'member',
rate: 20,
officeRequests: {
canManage: false,
generalId: 6,
nationId: 1,
serverId: 'office-season',
city: { id: 1, name: '허창' },
requests: [],
running: true,
tickSeconds: 600,
options: [
{ level: 4, allowed: true, reason: null, defaultDecision: 'approve', defaultReason: 'vacant' },
{
level: 3,
allowed: false,
reason: '지력 부족',
defaultDecision: 'approve',
defaultReason: 'vacant',
},
],
},
};
await installFixture(page, state);
await page.goto('nation/personnel');
const panel = page.getByRole('region', { name: '도시 관직 자원' });
await expect(panel.getByRole('button', { name: '자원', exact: true }).nth(1)).toBeDisabled();
await panel.getByRole('button', { name: '자원', exact: true }).first().click();
await expect(page.getByTestId('game-notice-dialog')).toContainText('미처리 시 자동 승인');
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '자원', exact: true }).click();
await expect.poll(() => state.officeMutation?.action).toBe('request');
expect(state.officeMutation).toEqual({ action: 'request', officerLevel: 4 });
});
}
@@ -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">
import CityOfficeRequestNotice from '../components/personnel/CityOfficeRequestNotice.vue';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
@@ -335,6 +336,7 @@ watch(
/>
</div>
<CityOfficeRequestNotice :server-id="frontStatus?.serverId" :summary="frontStatus?.cityOfficeRequests" />
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
<div class="survey-notice-title">
<strong>설문조사 안내</strong>
@@ -5,6 +5,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import CityOfficeRequests from '../components/personnel/CityOfficeRequests.vue';
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
@@ -322,6 +323,8 @@ onMounted(() => void loadPersonnel());
</tbody>
</table>
<CityOfficeRequests @changed="loadPersonnel" />
<div v-if="error" class="feedback error" role="alert">{{ error }}</div>
<div v-if="loading" class="loading">불러오는 중...</div>