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