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