인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
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,
|
||||
|
||||
Reference in New Issue
Block a user