fix reserved turn permission constraints

This commit is contained in:
2026-07-26 18:57:27 +00:00
parent 7a18bb48f2
commit d1e9d5fc75
13 changed files with 482 additions and 18 deletions
+65 -3
View File
@@ -5,7 +5,7 @@ import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic';
import { authedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
import { buildTurnCommandTable } from '../../turns/commandTable.js';
import { buildTurnCommandTable, evaluateReservedTurnPermission } from '../../turns/commandTable.js';
import {
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
@@ -28,6 +28,7 @@ import {
shiftNationTurns,
} from '../../turns/reservedTurns.js';
import { getOwnedGeneral } from '../shared/general.js';
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
const zPushAmount = z
.number()
@@ -80,6 +81,43 @@ const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> =>
}
};
const getReservationWorldState = async (ctx: GameApiContext): Promise<WorldStateRow> => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return worldState;
};
const assertReservedTurnPermission = async (
worldState: WorldStateRow,
general: GeneralRow,
scope: 'general' | 'nation',
action: string,
args: Record<string, unknown>
): Promise<void> => {
const result = await evaluateReservedTurnPermission({
worldState,
general,
scope,
action,
args,
});
if (result.kind === 'allow') {
return;
}
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message:
result.kind === 'deny'
? `예약 불가능한 커맨드 :${result.reason}`
: '예약 권한을 확인할 정보가 부족합니다.',
});
};
export const turnsRouter = router({
getCommandTable: authedProcedure
.input(
@@ -239,8 +277,10 @@ export const turnsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const general = await getOwnedGeneral(ctx, input.generalId);
const args = await parseCommandArgs('general', input.action, input.args);
const worldState = await getReservationWorldState(ctx);
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
const snapshot = await mutateReservedTurns(() =>
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
@@ -293,7 +333,7 @@ export const turnsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const general = await getOwnedGeneral(ctx, input.generalId);
const updates = await Promise.all(
input.entries.map(async (entry) => ({
turnIndices: expandGeneralTurnIndices(entry.turnList),
@@ -301,6 +341,16 @@ export const turnsRouter = router({
args: await parseCommandArgs('general', entry.action, entry.args),
}))
);
const worldState = await getReservationWorldState(ctx);
for (const update of updates) {
await assertReservedTurnPermission(
worldState,
general,
'general',
update.action,
update.args
);
}
const snapshot = await mutateReservedTurns(() =>
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
);
@@ -335,6 +385,8 @@ export const turnsRouter = router({
});
}
const args = await parseCommandArgs('nation', input.action, input.args);
const worldState = await getReservationWorldState(ctx);
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
const snapshot = await mutateReservedTurns(() =>
setNationTurn(
@@ -451,6 +503,16 @@ export const turnsRouter = router({
args: await parseCommandArgs('nation', entry.action, entry.args),
}))
);
const worldState = await getReservationWorldState(ctx);
for (const update of updates) {
await assertReservedTurnPermission(
worldState,
general,
'nation',
update.action,
update.args
);
}
const snapshot = await mutateReservedTurns(() =>
setNationTurns(
ctx.db,
+52 -5
View File
@@ -2,6 +2,7 @@ import type {
City,
Constraint,
ConstraintContext,
ConstraintResult,
General,
GeneralItemSlots,
GeneralActionDefinition,
@@ -225,6 +226,8 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined;
const joinModeRaw = config.join_mode ?? config.joinMode;
const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full';
return {
currentYear: worldState.currentYear,
@@ -233,18 +236,23 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
month: worldState.currentMonth,
startYear,
relYear,
join_mode: joinMode,
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
};
};
const mapGeneralRow = (row: GeneralRow): General => {
const mapGeneralRow = (row: GeneralRow, missingKillturnFallback?: number): General => {
const legacySlots: GeneralItemSlots = {
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
book: normalizeCode(row.bookCode),
item: normalizeCode(row.itemCode),
};
const meta = ensureGeneralMeta(asTriggerRecord(row.meta), row.id);
const rawMeta = asTriggerRecord(row.meta);
const meta =
readMetaNumber(rawMeta, 'killturn') === null && missingKillturnFallback !== undefined
? ({ ...rawMeta, killturn: missingKillturnFallback } as General['meta'])
: ensureGeneralMeta(rawMeta, row.id);
const itemInventory = readItemInventoryFromMeta(meta, legacySlots);
return {
id: row.id,
@@ -339,10 +347,16 @@ const buildStateView = (
general: General,
city: City | null,
nation: Nation | null,
generalList: General[] | null
generalList: General[] | null,
env: Record<string, unknown>
): StateView => {
const view = new MemoryStateView();
view.set({ kind: 'general', id: general.id }, general);
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
view.set({ kind: 'env', key }, value);
}
}
if (city) {
view.set({ kind: 'city', id: city.id }, city);
}
@@ -485,14 +499,15 @@ export const buildTurnCommandTable = async (options: {
const city = options.city ? mapCityRow(options.city) : null;
const nation = options.nation ? mapNationRow(options.nation) : null;
const generalList = options.nationGenerals ? options.nationGenerals.map(mapGeneralRow) : null;
const view = buildStateView(general, city, nation, generalList);
const constraintEnv = buildConstraintEnv(options.worldState);
const view = buildStateView(general, city, nation, generalList, constraintEnv);
const ctx: ConstraintContext = {
actorId: general.id,
cityId: city?.id,
nationId: nation?.id,
args: {},
env: buildConstraintEnv(options.worldState),
env: constraintEnv,
mode: 'precheck',
};
@@ -516,3 +531,35 @@ export const buildTurnCommandTable = async (options: {
},
};
};
export const evaluateReservedTurnPermission = async (options: {
worldState: WorldStateRow;
general: GeneralRow;
scope: 'general' | 'nation';
action: string;
args: Record<string, unknown>;
}): Promise<ConstraintResult> => {
const specs = await loadTurnCommandSpecs();
const spec = specs[options.scope].find((entry) => entry.key === options.action);
if (!spec) {
throw new Error(`Unknown ${options.scope} turn command: ${options.action}`);
}
const definition = spec.createDefinition(buildCommandEnv(options.worldState));
if (!definition.buildPermissionConstraints) {
return { kind: 'allow' };
}
const general = mapGeneralRow(options.general, 0);
const env = buildConstraintEnv(options.worldState);
const ctx: ConstraintContext = {
actorId: general.id,
cityId: general.cityId > 0 ? general.cityId : undefined,
nationId: general.nationId > 0 ? general.nationId : undefined,
args: options.args,
env,
mode: 'full',
};
const view = buildStateView(general, null, null, null, env);
return evaluateConstraints(definition.buildPermissionConstraints(ctx, options.args), ctx, view);
};
+22 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
import { buildTurnCommandTable } from '../src/turns/commandTable.js';
const buildWorldState = (): WorldStateRow =>
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
({
id: 1,
scenarioCode: 'default',
@@ -11,6 +11,7 @@ const buildWorldState = (): WorldStateRow =>
currentMonth: 1,
tickSeconds: 600,
config: {
joinMode,
const: {
baseGold: 1000,
baseRice: 1000,
@@ -115,4 +116,24 @@ describe('buildTurnCommandTable', () => {
expect(nationCommand?.possible).toBe(true);
expect(nationCommand?.status).not.toBe('blocked');
});
it('provides join_mode to reservation availability constraints', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState('onlyRandom'),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const appointment = table.general
.flatMap((group) => group.values)
.find((command) => command.key === 'che_임관');
expect(appointment).toMatchObject({
possible: false,
status: 'blocked',
reason: '랜덤 임관만 가능합니다',
});
});
});
+147 -4
View File
@@ -23,6 +23,25 @@ const profile: GameProfile = {
name: 'che:default',
};
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
({
id: 1,
scenarioCode: 'default',
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
config: {
joinMode,
const: {},
},
meta: {
scenarioMeta: {
startYear: 180,
},
},
updatedAt: new Date('2026-01-01T00:00:00Z'),
}) as unknown as WorldStateRow;
const buildAuth = (sanctions: GameSessionTokenPayload['sanctions'] = {}): GameSessionTokenPayload => ({
version: 1,
profile: profile.name,
@@ -444,7 +463,9 @@ describe('appRouter', () => {
it('validates and persists general command arguments from the authenticated owner', async () => {
const general = buildGeneralRow({ id: 13 });
const writes: unknown[] = [];
const caller = appRouter.createCaller(buildContext({ general, generalTurnWrites: writes }));
const caller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, generalTurnWrites: writes })
);
const response = await caller.turns.reserved.setGeneral({
generalId: 13,
@@ -479,7 +500,9 @@ describe('appRouter', () => {
it('applies legacy general sentinel bulk entries and repeats the leading pattern', async () => {
const general = buildGeneralRow({ id: 16 });
const bulkWrites: unknown[] = [];
const bulkCaller = appRouter.createCaller(buildContext({ general, generalTurnWrites: bulkWrites }));
const bulkCaller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, generalTurnWrites: bulkWrites })
);
const bulk = await bulkCaller.turns.reserved.setGeneralBulk({
generalId: general.id,
@@ -538,7 +561,9 @@ describe('appRouter', () => {
it('accepts the legacy nation amount-12 no-op without incrementing revision', async () => {
const general = buildGeneralRow({ id: 17, nationId: 3, officerLevel: 12 });
const nationWrites: unknown[] = [];
const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites }));
const caller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites })
);
const shifted = await caller.turns.reserved.shiftNation({
generalId: general.id,
@@ -559,7 +584,9 @@ describe('appRouter', () => {
it('applies nation bulk entries sequentially so later entries overwrite overlaps', async () => {
const general = buildGeneralRow({ id: 18, nationId: 3, officerLevel: 12 });
const nationWrites: unknown[] = [];
const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites }));
const caller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites })
);
const response = await caller.turns.reserved.setNationBulk({
generalId: general.id,
@@ -583,6 +610,122 @@ describe('appRouter', () => {
expect(nationWrites).toHaveLength(1);
});
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
const general = buildGeneralRow({ id: 19 });
const allowedWrites: unknown[] = [];
const allowedCaller = appRouter.createCaller(
buildContext({
state: buildWorldState('full'),
general,
generalTurnWrites: allowedWrites,
})
);
await expect(
allowedCaller.turns.reserved.setGeneral({
generalId: general.id,
turnIndex: 0,
action: 'che_임관',
args: { destNationId: 2 },
expectedRevision: 0,
})
).resolves.toMatchObject({ ok: true });
expect(allowedWrites).toHaveLength(1);
const randomOnlyWrites: unknown[] = [];
const randomOnlyCaller = appRouter.createCaller(
buildContext({
state: buildWorldState('onlyRandom'),
general,
generalTurnWrites: randomOnlyWrites,
})
);
await expect(
randomOnlyCaller.turns.reserved.setGeneral({
generalId: general.id,
turnIndex: 0,
action: 'che_임관',
args: { destNationId: 2 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('랜덤 임관만 가능합니다'),
});
expect(randomOnlyWrites).toHaveLength(0);
});
it('checks nation treaty-term reservation permission but not full diplomacy state', async () => {
const general = buildGeneralRow({ id: 20, nationId: 3, officerLevel: 12 });
const shortTermWrites: unknown[] = [];
const shortTermCaller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
general,
nationTurnWrites: shortTermWrites,
})
);
await expect(
shortTermCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_불가침제의',
args: { destNationId: 2, year: 190, month: 6 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('기한은 6개월 이상'),
});
expect(shortTermWrites).toHaveLength(0);
const validTermWrites: unknown[] = [];
const validTermCaller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
general,
nationTurnWrites: validTermWrites,
})
);
await expect(
validTermCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_불가침제의',
args: { destNationId: 2, year: 190, month: 7 },
expectedRevision: 0,
})
).resolves.toMatchObject({ ok: true });
expect(validTermWrites).toHaveLength(1);
});
it('validates every bulk reservation permission before writing any turn', async () => {
const general = buildGeneralRow({ id: 21 });
const writes: unknown[] = [];
const caller = appRouter.createCaller(
buildContext({
state: buildWorldState('onlyRandom'),
general,
generalTurnWrites: writes,
})
);
await expect(
caller.turns.reserved.setGeneralBulk({
generalId: general.id,
entries: [
{ turnList: [0], action: 'che_훈련' },
{
turnList: [1],
action: 'che_임관',
args: { destNationId: 2 },
},
],
expectedRevision: 0,
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(writes).toHaveLength(0);
});
it('rejects malformed and cross-scope arguments without writing turns', async () => {
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
const generalWrites: unknown[] = [];