283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
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');
|
|
});
|