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