인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -135,3 +135,10 @@ fence를 가진 채 ENGINE 결과를 기다리던 교착을 제거했다. 투표
|
||||
`adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`,
|
||||
`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한
|
||||
87개 route를 2026-08-24 기준으로 전수 분류한다. 이 목록은 “mutation transport를
|
||||
89개 route를 2026-09-29 기준으로 전수 분류한다. 이 목록은 “mutation transport를
|
||||
사용한다”와 “game DB를 변경한다”를 구분한다. 신규 route가 추가되거나 선언한 router가
|
||||
mount되지 않으면
|
||||
`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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||
| 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
|
||||
안전을 위해 0을 유지한다. 전체 writer binary 배포가 확인된 뒤에만 activation command를
|
||||
실행하며, 문제 시 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
|
||||
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.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.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
|
||||
|
||||
|
@@ -289,6 +289,21 @@
|
||||
],
|
||||
"projectionFields": [],
|
||||
"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": [
|
||||
|
||||
@@ -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
|
||||
@@ -33,3 +33,5 @@ export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
export * from './webPush/types.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 } };
|
||||
};
|
||||
@@ -222,6 +222,16 @@ export type TurnDaemonCommand =
|
||||
targetGeneralIds: 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';
|
||||
requestId?: string;
|
||||
@@ -599,6 +609,7 @@ export type TurnDaemonCommandResult =
|
||||
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'changePermission'; 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: 'tournamentRefund';
|
||||
|
||||
@@ -25,3 +25,5 @@ export * from './tournament/index.js';
|
||||
export * from './troop/management.js';
|
||||
export * from './world/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 };
|
||||
};
|
||||
Reference in New Issue
Block a user