인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
This commit is contained in:
@@ -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