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

This commit is contained in:
2026-09-29 09:41:01 +00:00
parent cd6ded4970
commit 5bafdfc1e7
36 changed files with 2238 additions and 20 deletions
@@ -0,0 +1,199 @@
import {
asRecord,
GAME_TICKS_PER_TURN,
OFFICE_REQUEST_KEY,
officeRequestQuarter,
readOfficeRequest,
type OfficeRequest,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
} from '@sammo-ts/common';
import {
LogCategory,
LogFormat,
LogScope,
resolveOfficeRequestEligibility,
type OfficeCandidate,
} from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
const candidate = (general: TurnGeneral): OfficeCandidate => ({
...general,
strength: general.stats.strength,
intelligence: general.stats.intelligence,
});
const incumbentFor = (world: InMemoryTurnWorld, cityId: number, level: number): TurnGeneral | undefined =>
world
.listGenerals()
.find((g) => g.officerLevel === level && Number(g.meta.officerCity ?? g.meta.officer_city ?? 0) === cityId);
const eligibility = (world: InMemoryTurnWorld, general: TurnGeneral, cityId: number, level: 2 | 3 | 4) => {
const incumbent = incumbentFor(world, cityId, level);
return resolveOfficeRequestEligibility({
applicant: candidate(general),
city: world.getCityById(cityId) ?? null,
level,
incumbent: incumbent ? candidate(incumbent) : null,
nationMeta: world.getNationById(general.nationId)?.meta,
chiefStatMin: world.getScenarioConfig().stat.chiefMin,
});
};
const save = (world: InMemoryTurnWorld, generalId: number, request: OfficeRequest) => {
const general = world.getGeneralById(generalId);
if (general) world.updateGeneral(generalId, { meta: { ...general.meta, [OFFICE_REQUEST_KEY]: { ...request } } });
};
const finish = (world: InMemoryTurnWorld, request: OfficeRequest, status: OfficeRequest['status'], reason: string) => {
save(world, request.generalId, { ...request, status, resultReason: reason });
const general = world.getGeneralById(request.generalId);
if (!general) return;
const city = world.getCityById(request.cityId);
const title = { 2: '종사', 3: '군사', 4: '태수' }[request.officerLevel];
const text = `${general.name} · ${city?.name ?? '도시'} ${title} 자원: ${reason}`;
world.pushLog({
scope: LogScope.GENERAL,
generalId: general.id,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
text,
});
world.pushLog({
scope: LogScope.NATION,
nationId: request.nationId,
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
text,
});
};
const invalidReason = (world: InMemoryTurnWorld, request: OfficeRequest): string | null => {
const general = world.getGeneralById(request.generalId);
if (!general || general.nationId !== request.nationId || general.userId !== request.userId)
return '소속 또는 장수 소유자 변경';
if ((incumbentFor(world, request.cityId, request.officerLevel)?.id ?? 0) !== request.incumbentId)
return '재직자 변경';
return eligibility(world, general, request.cityId, request.officerLevel).reason;
};
const approve = (world: InMemoryTurnWorld, request: OfficeRequest) => {
const incumbent = incumbentFor(world, request.cityId, request.officerLevel);
if (incumbent)
world.updateGeneral(incumbent.id, {
officerLevel: 1,
meta: { ...incumbent.meta, officerCity: 0, officer_city: 0 },
});
const general = world.getGeneralById(request.generalId)!;
world.updateGeneral(general.id, {
officerLevel: request.officerLevel,
meta: { ...general.meta, officerCity: request.cityId, officer_city: request.cityId },
});
const city = world.getCityById(request.cityId)!;
world.updateCity(city.id, {
meta: { ...city.meta, officer_set: Number(city.meta.officer_set ?? 0) | (1 << request.officerLevel) },
});
finish(world, request, 'approved', '임명');
};
// 분기 lock 초기화 뒤 실행한다. 저장된 기본 거부는 조건이 완화되어도 자동 승인으로 바꾸지 않는다.
export const settleCityOfficeRequests = (world: InMemoryTurnWorld, tick: number): void => {
for (const general of world.listGenerals()) {
const request = readOfficeRequest(general.meta);
if (!request || request.status !== 'pending') continue;
const invalid = invalidReason(world, request);
if (invalid) {
finish(world, request, 'cancelled', invalid);
continue;
}
if (tick < request.dueTick) continue;
const current = eligibility(world, general, request.cityId, request.officerLevel);
if (request.defaultDecision === 'approve' && current.defaultDecision === 'approve') approve(world, request);
else finish(world, request, 'rejected', '기한 만료 · 자동 거부');
}
};
export const createCityOfficeRequestCalendarHandler = (
getWorld: () => InMemoryTurnWorld | null
): TurnCalendarHandler => ({
beforeMonthChanged: () => {
const world = getWorld();
if (world) settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
},
onMonthChanged: (context) => {
const world = getWorld();
if (world) settleCityOfficeRequests(world, world.dateToGameTick(context.turnTime));
},
});
export const handleCityOfficeRequest = (
world: InMemoryTurnWorld,
command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
acceptedAt: Date
): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => {
const fail = (reason: string): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => ({
type: 'cityOfficeRequest',
generalId: command.generalId,
ok: false,
reason,
});
const actor = world.getGeneralById(command.generalId);
if (!actor || actor.userId !== command.userId || !actor.nationId)
return fail('장수 소유권 또는 소속이 변경되었습니다.');
const tick = Math.max(world.dateToGameTick(world.getGameNow(acceptedAt)), world.getState().lastTurnTick ?? 0);
settleCityOfficeRequests(world, tick);
if (command.action === 'request') {
if (!command.officerLevel) return fail('관직을 선택해 주세요.');
const currentActor = world.getGeneralById(actor.id)!;
const state = world.getState();
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
const previous = readOfficeRequest(currentActor.meta);
if (previous?.status === 'pending') return fail('이미 대기 중인 요청이 있습니다.');
if (previous?.quarter === quarter) return fail('이번 분기에 이미 자원했습니다.');
const check = eligibility(world, currentActor, currentActor.cityId, command.officerLevel);
if (!check.allowed) return fail(check.reason!);
const occupied = world.listGenerals().some((g) => {
const pending = readOfficeRequest(g.meta);
return (
pending?.status === 'pending' &&
pending.cityId === actor.cityId &&
pending.officerLevel === command.officerLevel
);
});
if (occupied) return fail('이미 다른 장수가 자원한 관직입니다.');
const boundary = state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime);
const dueTick =
boundary +
Math.max(1, Math.ceil((tick - boundary + GAME_TICKS_PER_TURN) / GAME_TICKS_PER_TURN)) * GAME_TICKS_PER_TURN;
save(world, actor.id, {
id: `${actor.id}:${quarter}`,
generalId: actor.id,
userId: command.userId,
nationId: actor.nationId,
cityId: actor.cityId,
officerLevel: command.officerLevel,
incumbentId: incumbentFor(world, actor.cityId, command.officerLevel)?.id ?? 0,
quarter,
createdTick: tick,
dueTick,
defaultDecision: check.defaultDecision,
defaultReason: check.defaultReason,
status: 'pending',
});
} else {
const target = world.getGeneralById(command.targetGeneralId ?? actor.id);
const request = readOfficeRequest(target?.meta);
if (!request || request.id !== command.officeRequestId || request.status !== 'pending')
return fail('이미 처리되었거나 없는 요청입니다.');
if (request.nationId !== actor.nationId) return fail('다른 국가의 요청입니다.');
if (command.action === 'withdraw') {
if (request.generalId !== actor.id) return fail('본인의 요청만 철회할 수 있습니다.');
finish(world, request, 'withdrawn', '본인 철회');
} else {
if (actor.officerLevel < 5 || asRecord(actor.penalty).noChief) return fail('수뇌만 처리할 수 있습니다.');
if (command.action === 'reject') finish(world, request, 'rejected', '수뇌 거부');
else {
if (asRecord(actor.penalty).noChiefChange && target && target.officerLevel >= 4)
return fail('수뇌인 장수를 변경할 수 없는 상태입니다.');
const invalid = invalidReason(world, request);
if (invalid) return fail(invalid);
approve(world, request);
}
}
}
return { type: 'cityOfficeRequest', generalId: actor.id, ok: true };
};
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import {
GAME_TICKS_PER_TURN,
readOfficeRequest,
MAX_SAFE_GAME_TICK,
GameClock,
buildClockAlignmentPlan,
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
})
),
snapshot(
'city-office-request-occurrence',
'KEEP',
generals.flatMap(({ id, meta }) => {
const request = readOfficeRequest(meta);
return request ? [{ id, createdTick: request.createdTick }] : [];
})
),
snapshot(
'city-office-request-deadline',
'SHIFT',
generals.flatMap(({ id, meta }) => {
const request = readOfficeRequest(meta);
return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick
? [{ id, dueTick: request.dueTick }]
: [];
})
),
snapshot(
'auction-open-occurrence',
'KEEP',
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
_max: { pickMoreFromTick: true },
where: { pickMoreFromTick: { not: null } },
}),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta #>> '{cityOfficeRequest,dueTick}')::bigint) AS "maxTick"
FROM general WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
`),
]);
const values: Array<[string, bigint | null]> = [
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick],
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
`)
);
affected.set(
'city-office-request-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}',
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks}))
WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick}
`)
);
affected.set(
'auction-deadline',
await db.$executeRaw(GamePrisma.sql`
@@ -185,6 +185,16 @@ const zKick = z.object({
destGeneralId: zFiniteNumber,
});
const zCityOfficeRequest = z.object({
type: z.literal('cityOfficeRequest'),
userId: z.string().min(1),
generalId: z.number().int().positive(),
action: z.enum(['request', 'approve', 'reject', 'withdraw']),
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
targetGeneralId: z.number().int().positive().optional(),
officeRequestId: z.string().min(1).optional(),
});
const zAppoint = z.object({
type: z.literal('appoint'),
userId: z.string().min(1),
@@ -666,6 +676,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeCityOfficeRequest: CommandNormalizer<'cityOfficeRequest'> = (envelope) => {
const command = parseWith(zCityOfficeRequest, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
const command = parseWith(zAppoint, envelope.command);
if (!command) {
@@ -883,6 +898,7 @@ const normalizers: CommandNormalizerMap = {
changePermission: normalizeChangePermission,
kick: normalizeKick,
appoint: normalizeAppoint,
cityOfficeRequest: normalizeCityOfficeRequest,
tournamentRefund: normalizeTournamentRefund,
tournamentBettingPayout: normalizeTournamentBettingPayout,
tournamentReward: normalizeTournamentReward,
+14 -2
View File
@@ -208,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
permission: asRecord(general.meta).permission,
penalty: general.penalty,
}),
frontStatus: signature({ name: general.name, nationId: general.nationId }),
frontStatus: signature({
name: general.name,
nationId: general.nationId,
officerLevel: general.officerLevel,
userId: general.userId,
penalty: general.penalty,
cityOfficeRequest: asRecord(general.meta).cityOfficeRequest,
}),
frontStatusGlobal: '',
lobbyCount: signature({ npcState: general.npcState }),
lobbyPersonal: signature({
@@ -1406,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
GamePrisma.sql`
UPDATE general
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second')
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second'),
meta = CASE WHEN meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
THEN jsonb_set(meta, '{cityOfficeRequest,dueTick}',
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${deltaTicks}))
ELSE meta END
`
);
await prisma.$executeRaw(
@@ -22,6 +22,7 @@ import type {
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import {
GAME_TICKS_PER_TURN,
shiftOfficeRequestDeadline,
GameClock,
assertGameplayCommitAllowed,
inferClockPhase,
@@ -819,6 +820,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
this.generals.set(generalId, {
...general,
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
turnTick,
turnTime: clock.tickToDate(turnTick),
});
@@ -1044,6 +1046,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
this.generals.set(generalId, {
...general,
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
turnTick,
turnTime: clock.tickToDate(turnTick),
});
+2
View File
@@ -1,3 +1,4 @@
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
const calendarHandler = composeCalendarHandlers(
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
options.monthlyEventHandler,
createCityOfficeRequestCalendarHandler(options.getWorld),
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
createPlayAuditHandler(options.getWorld),
@@ -1,3 +1,4 @@
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
import type {
TurnDaemonCommandHandler,
TurnDaemonCommand,
@@ -187,6 +188,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'changePermission',
'kick',
'appoint',
'cityOfficeRequest',
'voteReward',
'syncDiplomaticResponse',
] as const satisfies readonly TurnDaemonCommand['type'][];
@@ -2587,6 +2589,7 @@ async function handleAppoint(
} else {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
}
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
refreshActorKillturn(world, general);
return { type: 'appoint', ok: true, generalId: command.generalId };
}
@@ -3243,6 +3246,12 @@ export const createTurnDaemonCommandHandler = (options: {
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
cityOfficeRequest: async (command) =>
handleCityOfficeRequest(
ctx.world,
command as Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
ctx.commandDb ? await resolveOperationalAcceptedAt(ctx.commandDb, command) : new Date()
),
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
tournamentRefund: (command) =>
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
@@ -45,6 +45,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
destCityId: 1,
officerLevel: 4,
},
{
type: 'cityOfficeRequest',
requestId: 'cityOfficeRequest',
userId,
generalId: 7,
action: 'request',
officerLevel: 4,
},
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
{
type: 'syncDiplomaticResponse',
@@ -0,0 +1,247 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { readOfficeRequest, type TurnDaemonCommand } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient, type GamePrisma } from '@sammo-ts/infra';
import { buildWorld } from './officeRequestFixture.js';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { createCityOfficeRequestCalendarHandler } from '../src/turn/cityOfficeRequests.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 992951;
const generalId = 992953;
const requestId = 'integration:city-office-request';
const constraint = 'city_office_request_rollback_test';
integration('city office request durable persistence', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let hooks: DatabaseTurnHooks | undefined;
beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema');
if (!schema || !/^(ci_|office_requests)/.test(schema)) throw new Error('Dedicated integration schema required');
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${constraint}`);
await db.inputEvent.deleteMany({ where: { requestId } });
await db.logEntry.deleteMany({
where: { OR: [{ nationId: worldId }, { generalId: { in: [worldId, worldId + 1, generalId] } }] },
});
await db.rankData.deleteMany({ where: { generalId: { in: [worldId, worldId + 1, generalId] } } });
await db.general.deleteMany({ where: { id: { in: [worldId, worldId + 1, generalId] } } });
await db.city.deleteMany({ where: { id: worldId } });
await db.nation.deleteMany({ where: { id: worldId } });
await db.worldState.deleteMany();
});
afterAll(async () => {
await hooks?.close();
await disconnect?.();
});
it('rolls back request and journal together, reloads pending state, then persists automatic appointment once', async () => {
const base = buildWorld({});
const state = {
...base.state,
id: worldId,
clockBaseTime: base.state.lastTurnTime,
clockTick: 0,
lastTurnTick: 0,
clockMode: 'manual' as const,
clockPhase: 'MANUAL' as const,
clockRevision: 1,
deadlineGeneration: 1,
clockWallAnchor: base.state.lastTurnTime,
};
const snapshot = {
...base.snapshot,
generals: base.snapshot.generals.map((g) => ({
...g,
id: 992950 + g.id,
nationId: worldId,
cityId: worldId,
userId: `user-${992950 + g.id}`,
})),
cities: base.snapshot.cities.map((c) => ({ ...c, id: worldId, nationId: worldId })),
nations: base.snapshot.nations.map((n) => ({
...n,
id: worldId,
capitalCityId: worldId,
chiefGeneralId: worldId,
})),
scenarioConfig: { ...base.snapshot.scenarioConfig!, environment: { mapName: 'che', unitSet: 'che' } },
};
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'office-requests',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: 600,
clockBaseTime: state.clockBaseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: 'manual',
clockPhase: 'MANUAL',
clockWallAnchor: state.clockWallAnchor,
clockRevision: 1n,
deadlineGeneration: 1n,
config: JSON.parse(JSON.stringify(snapshot.scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({
data: {
id: worldId,
name: '위',
color: '#777777',
level: 3,
gold: 10000,
rice: 10000,
tech: 0,
typeCode: 'che_중립',
capitalCityId: worldId,
meta: {},
},
});
await db.city.create({
data: {
id: worldId,
name: '허창',
nationId: worldId,
level: 7,
population: 1000,
populationMax: 2000,
agriculture: 1000,
agricultureMax: 2000,
commerce: 1000,
commerceMax: 2000,
security: 1000,
securityMax: 2000,
defence: 1000,
defenceMax: 2000,
wall: 1000,
wallMax: 2000,
trust: 80,
trade: 100,
region: 1,
meta: {},
},
});
await db.general.createMany({
data: snapshot.generals.map((g) => ({
id: g.id,
userId: g.userId,
name: g.name,
nationId: g.nationId,
cityId: g.cityId,
npcState: g.npcState,
officerLevel: g.officerLevel,
leadership: g.stats.leadership,
strength: g.stats.strength,
intel: g.stats.intelligence,
gold: g.gold,
rice: g.rice,
crew: g.crew,
crewTypeId: g.crewTypeId,
train: g.train,
atmos: g.atmos,
age: g.age,
turnTime: g.turnTime,
meta: g.meta,
})),
});
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const handler = createTurnDaemonCommandHandler({ world });
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const manager = new EngineStateManager();
manager.register('world', {
capture: () => world.captureState(),
restore: (saved) => world.restoreState(saved),
});
const command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }> = {
type: 'cityOfficeRequest',
requestId,
userId: `user-${generalId}`,
generalId,
action: 'request',
officerLevel: 4,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
status: 'PROCESSING',
lockedBy: 'office-test',
leaseUntil: new Date('2099-01-01'),
attempts: 1,
payload: command,
acceptedGameTick: 0n,
acceptedClockRevision: 1n,
acceptedDeadlineGeneration: 1n,
processingGameTick: 0n,
processingClockRevision: 1n,
processingDeadlineGeneration: 1n,
},
});
const execute = () =>
manager.transaction(() =>
hooks!.hooks.executeCommand!(requestId, async (context) => {
const result = await handler.handle(command, context);
if (!result) throw new Error('Missing result');
return result;
})
);
await db.$executeRawUnsafe(
`ALTER TABLE input_event ADD CONSTRAINT ${constraint} CHECK (request_id <> '${requestId}' OR status <> 'SUCCEEDED')`
);
await expect(execute()).rejects.toThrow();
expect(readOfficeRequest(world.getGeneralById(generalId)?.meta)).toBeNull();
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)).toBeNull();
expect(hooks.takeCommittedReadModelChanges()).toBeNull();
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${constraint}`);
await expect(execute()).resolves.toMatchObject({ type: 'cityOfficeRequest', ok: true });
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('SUCCEEDED');
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)?.status).toBe(
'pending'
);
expect(hooks.takeCommittedReadModelChanges()).toMatchObject({ frontStatusChanged: true });
await hooks.close();
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const reloaded: InMemoryTurnWorld = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: createCityOfficeRequestCalendarHandler(() => reloaded),
});
const pending = readOfficeRequest(reloaded.getGeneralById(generalId)?.meta)!;
expect(pending.status).toBe('pending');
hooks = await createDatabaseTurnHooks(databaseUrl!, reloaded);
const turnTime = reloaded.gameTickToDate(pending.dueTick);
await reloaded.advanceMonth(turnTime);
await hooks.hooks.flushChanges?.({
lastTurnTime: turnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
const saved = await db.general.findUniqueOrThrow({ where: { id: generalId } });
expect(saved.officerLevel).toBe(4);
expect(readOfficeRequest(saved.meta)?.status).toBe('approved');
expect((await db.city.findUniqueOrThrow({ where: { id: worldId } })).meta).toMatchObject({ officer_set: 16 });
const count = await db.logEntry.count({ where: { generalId } });
await hooks.hooks.flushChanges?.({
lastTurnTime: turnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
expect(await db.logEntry.count({ where: { generalId } })).toBe(count);
});
});
@@ -0,0 +1,282 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN, readOfficeRequest, shiftOfficeRequestDeadline } from '@sammo-ts/common';
import { buildGeneral, buildWorld } from './officeRequestFixture.js';
import {
handleCityOfficeRequest,
settleCityOfficeRequests,
createCityOfficeRequestCalendarHandler,
} from '../src/turn/cityOfficeRequests.js';
const acceptedAt = new Date('0185-01-01T00:00:00Z');
const request = (world: ReturnType<typeof buildWorld>['world'], generalId = 3) =>
handleCityOfficeRequest(
world,
{ type: 'cityOfficeRequest', userId: `user-${generalId}`, generalId, action: 'request', officerLevel: 4 },
acceptedAt
);
const pending = (world: ReturnType<typeof buildWorld>['world']) => readOfficeRequest(world.getGeneralById(3)?.meta)!;
const decide = (
world: ReturnType<typeof buildWorld>['world'],
action: 'approve' | 'reject' | 'withdraw',
generalId = 2
) =>
handleCityOfficeRequest(
world,
{
type: 'cityOfficeRequest',
userId: `user-${generalId}`,
generalId,
action,
targetGeneralId: 3,
officeRequestId: pending(world).id,
},
acceptedAt
);
describe('city office request policy and lifecycle', () => {
it.each([null, 2, 3])('defaults vacancy / N / M (%s) to approval only at the deadline', (npc) => {
const { world } = buildWorld({
generals: [
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3),
...(npc === null
? []
: [
buildGeneral(4, {
userId: null,
npcState: npc,
officerLevel: 4,
meta: { killturn: 12, officerCity: 1 },
}),
]),
],
});
expect(request(world)).toMatchObject({ ok: true });
const entry = pending(world);
expect(entry).toMatchObject({ defaultDecision: 'approve', status: 'pending' });
expect(entry.dueTick - entry.createdTick).toBeGreaterThanOrEqual(GAME_TICKS_PER_TURN);
settleCityOfficeRequests(world, entry.dueTick - 1);
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
settleCityOfficeRequests(world, entry.dueTick);
expect(pending(world).status).toBe('approved');
expect(world.getGeneralById(3)).toMatchObject({ officerLevel: 4, meta: { officerCity: 1, officer_city: 1 } });
expect(Number(world.getCityById(1)?.meta.officer_set) & 16).toBe(16);
if (npc !== null) expect(world.getGeneralById(4)?.officerLevel).toBe(1);
settleCityOfficeRequests(world, entry.dueTick + GAME_TICKS_PER_TURN);
expect(pending(world).status).toBe('approved');
});
it.each(['absent', 'secret'] as const)('defaults %s to rejection but lets a chief explicitly approve', (reason) => {
for (const manual of [false, true]) {
const { world } = buildWorld({
generals: [
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3, reason === 'secret' ? { meta: { killturn: 12, belong: 0 } } : {}),
...(reason === 'absent'
? [buildGeneral(4, { cityId: 2, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } })]
: []),
],
});
expect(request(world)).toMatchObject({ ok: true });
expect(pending(world)).toMatchObject({ defaultDecision: 'reject', defaultReason: reason });
if (manual) expect(decide(world, 'approve')).toMatchObject({ ok: true });
else settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe(manual ? 'approved' : 'rejected');
}
});
it.each([0, 1])('does not replace a stationed user / possessed general (%s)', (npcState) => {
const { world } = buildWorld({
generals: [
buildGeneral(3),
buildGeneral(4, { npcState, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } }),
],
});
expect(request(world)).toMatchObject({ ok: false, reason: '재직자가 도시에 체류 중' });
});
it('lets chiefs approve or reject default approval, and permits owner withdrawal', () => {
for (const action of ['approve', 'reject', 'withdraw'] as const) {
const { world } = buildWorld({});
request(world);
expect(decide(world, action, action === 'withdraw' ? 3 : 2)).toMatchObject({ ok: true });
expect(pending(world).status).toBe(
{ approve: 'approved', reject: 'rejected', withdraw: 'withdrawn' }[action]
);
expect(request(world).ok).toBe(false);
}
});
it('rejects forged actors, ordinary approvers, foreign chiefs and stale request IDs', () => {
const { world } = buildWorld({});
request(world);
expect(decide(world, 'approve', 3).ok).toBe(false);
world.updateGeneral(2, { nationId: 9 });
expect(decide(world, 'approve').ok).toBe(false);
expect(
handleCityOfficeRequest(
world,
{
type: 'cityOfficeRequest',
userId: 'forged',
generalId: 3,
action: 'withdraw',
officeRequestId: pending(world).id,
},
acceptedAt
).ok
).toBe(false);
expect(
handleCityOfficeRequest(
world,
{
type: 'cityOfficeRequest',
userId: 'user-3',
generalId: 3,
action: 'withdraw',
officeRequestId: 'stale',
},
acceptedAt
).ok
).toBe(false);
expect(pending(world).status).toBe('pending');
});
it.each(['move', 'nation', 'owner', 'occupation', 'lock', 'stat', 'penalty'] as const)(
'cancels after %s changes before settlement',
(change) => {
const { world } = buildWorld({});
request(world);
const general = world.getGeneralById(3)!;
if (change === 'move') world.updateGeneral(3, { cityId: 2 });
if (change === 'nation') world.updateGeneral(3, { nationId: 2 });
if (change === 'owner') world.updateGeneral(3, { userId: 'other' });
if (change === 'occupation') world.updateCity(1, { nationId: 2 });
if (change === 'lock') world.updateCity(1, { meta: { officer_set: 16 } });
if (change === 'stat') world.updateGeneral(3, { stats: { ...general.stats, strength: 0 } });
if (change === 'penalty') world.updateGeneral(3, { penalty: { noChief: true } });
settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe('cancelled');
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
}
);
it('does not auto-grant newly restricted secrets or upgrade a stored rejection', () => {
const { world } = buildWorld({});
request(world);
world.updateNation(1, { meta: { secretlimit: 10 } });
settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe('rejected');
});
it('reserves a slot against other applicants but not a manual chief appointment', async () => {
const { world, handler } = buildWorld({
generals: [buildGeneral(2, { officerLevel: 5 }), buildGeneral(3), buildGeneral(4)],
});
request(world);
expect(request(world, 4).ok).toBe(false);
expect(
await handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 4,
destCityId: 1,
officerLevel: 4,
})
).toMatchObject({ ok: true });
expect(pending(world).status).toBe('cancelled');
expect(world.getGeneralById(4)?.officerLevel).toBe(4);
});
it('uses the monthly calendar boundary and survives a world snapshot reload', async () => {
const { world } = buildWorld({});
request(world);
const saved = JSON.parse(JSON.stringify(world.getGeneralById(3)?.meta));
const { world: reloaded } = buildWorld({ generals: [buildGeneral(3, { meta: saved })] });
await createCityOfficeRequestCalendarHandler(() => reloaded).onMonthChanged?.({
previousYear: 185,
previousMonth: 1,
currentYear: 185,
currentMonth: 2,
turnTime: reloaded.gameTickToDate(pending(reloaded).dueTick),
});
expect(pending(reloaded).status).toBe('approved');
});
});
it('preserves a full review month for requests accepted just before a month boundary', () => {
const { world } = buildWorld({
clock: {
clockBaseTime: acceptedAt,
clockWallAnchor: acceptedAt,
clockMode: 'manual',
clockTick: GAME_TICKS_PER_TURN - 1,
lastTurnTick: 0,
},
});
expect(request(world)).toMatchObject({ ok: true });
expect(pending(world).dueTick).toBe(2 * GAME_TICKS_PER_TURN);
settleCityOfficeRequests(world, GAME_TICKS_PER_TURN);
expect(pending(world).status).toBe('pending');
});
it('releases the quarterly applicant limit next quarter while preserving same-quarter refusal', () => {
const { world } = buildWorld({});
request(world);
decide(world, 'reject');
expect(request(world).ok).toBe(false);
const previous = pending(world);
world.updateGeneral(3, {
meta: { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...previous, quarter: previous.quarter - 1 } },
});
expect(request(world).ok).toBe(true);
});
it('cancels pending requests before quarterly locks reset', async () => {
const { world } = buildWorld({});
request(world);
world.updateCity(1, { meta: { officer_set: 16 } });
await createCityOfficeRequestCalendarHandler(() => world).beforeMonthChanged?.({
previousYear: 185,
previousMonth: 3,
currentYear: 185,
currentMonth: 4,
turnTime: acceptedAt,
});
expect(pending(world).status).toBe('cancelled');
});
it('does not relax a stored secret rejection when tenure increases', () => {
const { world } = buildWorld({ generals: [buildGeneral(3, { meta: { killturn: 12, belong: 0 } })] });
request(world);
world.updateGeneral(3, { meta: { ...world.getGeneralById(3)!.meta, belong: 10 } });
settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe('rejected');
});
it('moves pending deadlines on backlog rebase and preserves occurrence ticks', () => {
const anchor = new Date('2026-01-01T00:00:00Z');
const { world } = buildWorld({
clock: { clockBaseTime: anchor, clockWallAnchor: anchor, clockMode: 'realtime', clockTick: 0, lastTurnTick: 0 },
});
expect(
handleCityOfficeRequest(
world,
{ type: 'cityOfficeRequest', userId: 'user-3', generalId: 3, action: 'request', officerLevel: 4 },
anchor
)
).toMatchObject({ ok: true });
const before = pending(world);
const shifted = world.rebaseRealtimeBacklog(new Date(anchor.getTime() + 13 * 600_000));
expect(shifted?.shiftedTicks).toBe(12 * GAME_TICKS_PER_TURN);
expect(pending(world)).toMatchObject({
createdTick: before.createdTick,
dueTick: before.dueTick + shifted!.shiftedTicks,
});
const completed = { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...before, status: 'approved' } };
expect(shiftOfficeRequestDeadline(completed, 10)).toEqual(completed);
expect(shiftOfficeRequestDeadline(world.getGeneralById(3)!.meta, 10, pending(world).dueTick + 1)).toEqual(
world.getGeneralById(3)!.meta
);
});
it('uses the existing secret-limit fallback for malformed metadata', () => {
const { world } = buildWorld({
nationMeta: { secretlimit: 'invalid' },
generals: [buildGeneral(3, { meta: { killturn: 12, belong: 'invalid' } })],
});
expect(request(world)).toMatchObject({ ok: true });
expect(pending(world).defaultDecision).toBe('reject');
});
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery, readOfficeRequest } from '@sammo-ts/common';
import {
createGamePostgresConnector,
readTurnRuntimeReady,
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
meta:
index === 0
? {
cityOfficeRequest: {
id: '1:760',
generalId: 1,
userId: 'clock-user',
nationId: 1,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: initialTick - 100,
dueTick: reselectionTick,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
},
next_change_tick: reselectionTick,
next_change: clock.tickToDate(reselectionTick).toISOString(),
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
@@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => {
);
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
expect(readOfficeRequest(shiftedReselectionMeta)).toMatchObject({
createdTick: initialTick - 100,
dueTick: reselectionTick + reconciled.shiftTicks,
status: 'pending',
});
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
);
@@ -0,0 +1,140 @@
import { type GeneralActionModule, type TriggerValue, type TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
export const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
userId: `user-${id}`,
name: `장수${id}`,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
turnTime: new Date('0185-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 },
penalty: {},
officerLevel: 1,
experience: 1_000,
dedication: 2_000,
injury: 0,
gold: 1_500,
rice: 1_600,
crew: 100,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
...overrides,
});
export const buildWorld = (options: {
generals?: TurnGeneral[];
nationMeta?: Record<string, TriggerValue>;
nationChiefGeneralId?: number | null;
cityMeta?: Record<string, TriggerValue>;
currentYear?: number;
scenarioConst?: Record<string, unknown>;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
clock?: Pick<TurnWorldState, 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'>;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: options.currentYear ?? 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
meta: { killturn: 24, scenarioMeta: { startYear: 180 } },
...options.clock,
};
const snapshot: TurnWorldSnapshot = {
generals: options.generals ?? [
buildGeneral(1, { officerLevel: 12 }),
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3),
],
cities: [
{
id: 1,
name: '허창',
nationId: 1,
level: 7,
state: 0,
population: 1_000,
populationMax: 2_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: options.cityMeta ?? {},
},
],
nations: [
{
id: 1,
name: '위',
color: '#777777',
capitalCityId: 1,
chiefGeneralId: options.nationChiefGeneralId === undefined ? 1 : options.nationChiefGeneralId,
gold: 10_000,
rice: 20_000,
power: 0,
level: 3,
typeCode: 'che_법가',
meta: options.nationMeta ?? {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: { defaultGold: 1000, defaultRice: 1000, ...options.scenarioConst },
environment: { mapName: 'test', unitSet: 'test' },
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
return {
snapshot,
state,
world,
handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }),
};
};
@@ -504,11 +504,11 @@ describe('summarizeRealtimeReadModelChanges', () => {
mapGeneralIds: [],
mapCityIds: [1],
mapNationIds: [],
frontStatusGeneralIds: [],
frontStatusGeneralIds: [7],
frontStatusNationIds: [1],
lobbyGeneralIds: [],
contactsChanged: true,
frontStatusChanged: false,
frontStatusChanged: true,
lobbyChanged: false,
});
});