fix reserved turn permission constraints
This commit is contained in:
@@ -5,7 +5,7 @@ import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic';
|
|||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
||||||
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
|
||||||
import { buildTurnCommandTable } from '../../turns/commandTable.js';
|
import { buildTurnCommandTable, evaluateReservedTurnPermission } from '../../turns/commandTable.js';
|
||||||
import {
|
import {
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
shiftNationTurns,
|
shiftNationTurns,
|
||||||
} from '../../turns/reservedTurns.js';
|
} from '../../turns/reservedTurns.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
|
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
|
||||||
|
|
||||||
const zPushAmount = z
|
const zPushAmount = z
|
||||||
.number()
|
.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({
|
export const turnsRouter = router({
|
||||||
getCommandTable: authedProcedure
|
getCommandTable: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -239,8 +277,10 @@ export const turnsRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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 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(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
||||||
@@ -293,7 +333,7 @@ export const turnsRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
const updates = await Promise.all(
|
const updates = await Promise.all(
|
||||||
input.entries.map(async (entry) => ({
|
input.entries.map(async (entry) => ({
|
||||||
turnIndices: expandGeneralTurnIndices(entry.turnList),
|
turnIndices: expandGeneralTurnIndices(entry.turnList),
|
||||||
@@ -301,6 +341,16 @@ export const turnsRouter = router({
|
|||||||
args: await parseCommandArgs('general', entry.action, entry.args),
|
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(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
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 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(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setNationTurn(
|
setNationTurn(
|
||||||
@@ -451,6 +503,16 @@ export const turnsRouter = router({
|
|||||||
args: await parseCommandArgs('nation', entry.action, entry.args),
|
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(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setNationTurns(
|
setNationTurns(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
City,
|
City,
|
||||||
Constraint,
|
Constraint,
|
||||||
ConstraintContext,
|
ConstraintContext,
|
||||||
|
ConstraintResult,
|
||||||
General,
|
General,
|
||||||
GeneralItemSlots,
|
GeneralItemSlots,
|
||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
@@ -225,6 +226,8 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
|||||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||||
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
||||||
const relYear = typeof startYear === 'number' ? worldState.currentYear - 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 {
|
return {
|
||||||
currentYear: worldState.currentYear,
|
currentYear: worldState.currentYear,
|
||||||
@@ -233,18 +236,23 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
|||||||
month: worldState.currentMonth,
|
month: worldState.currentMonth,
|
||||||
startYear,
|
startYear,
|
||||||
relYear,
|
relYear,
|
||||||
|
join_mode: joinMode,
|
||||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapGeneralRow = (row: GeneralRow): General => {
|
const mapGeneralRow = (row: GeneralRow, missingKillturnFallback?: number): General => {
|
||||||
const legacySlots: GeneralItemSlots = {
|
const legacySlots: GeneralItemSlots = {
|
||||||
horse: normalizeCode(row.horseCode),
|
horse: normalizeCode(row.horseCode),
|
||||||
weapon: normalizeCode(row.weaponCode),
|
weapon: normalizeCode(row.weaponCode),
|
||||||
book: normalizeCode(row.bookCode),
|
book: normalizeCode(row.bookCode),
|
||||||
item: normalizeCode(row.itemCode),
|
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);
|
const itemInventory = readItemInventoryFromMeta(meta, legacySlots);
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -339,10 +347,16 @@ const buildStateView = (
|
|||||||
general: General,
|
general: General,
|
||||||
city: City | null,
|
city: City | null,
|
||||||
nation: Nation | null,
|
nation: Nation | null,
|
||||||
generalList: General[] | null
|
generalList: General[] | null,
|
||||||
|
env: Record<string, unknown>
|
||||||
): StateView => {
|
): StateView => {
|
||||||
const view = new MemoryStateView();
|
const view = new MemoryStateView();
|
||||||
view.set({ kind: 'general', id: general.id }, general);
|
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) {
|
if (city) {
|
||||||
view.set({ kind: 'city', id: city.id }, 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 city = options.city ? mapCityRow(options.city) : null;
|
||||||
const nation = options.nation ? mapNationRow(options.nation) : null;
|
const nation = options.nation ? mapNationRow(options.nation) : null;
|
||||||
const generalList = options.nationGenerals ? options.nationGenerals.map(mapGeneralRow) : 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 = {
|
const ctx: ConstraintContext = {
|
||||||
actorId: general.id,
|
actorId: general.id,
|
||||||
cityId: city?.id,
|
cityId: city?.id,
|
||||||
nationId: nation?.id,
|
nationId: nation?.id,
|
||||||
args: {},
|
args: {},
|
||||||
env: buildConstraintEnv(options.worldState),
|
env: constraintEnv,
|
||||||
mode: 'precheck',
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
|
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js';
|
||||||
import { buildTurnCommandTable } from '../src/turns/commandTable.js';
|
import { buildTurnCommandTable } from '../src/turns/commandTable.js';
|
||||||
|
|
||||||
const buildWorldState = (): WorldStateRow =>
|
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
||||||
({
|
({
|
||||||
id: 1,
|
id: 1,
|
||||||
scenarioCode: 'default',
|
scenarioCode: 'default',
|
||||||
@@ -11,6 +11,7 @@ const buildWorldState = (): WorldStateRow =>
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
config: {
|
config: {
|
||||||
|
joinMode,
|
||||||
const: {
|
const: {
|
||||||
baseGold: 1000,
|
baseGold: 1000,
|
||||||
baseRice: 1000,
|
baseRice: 1000,
|
||||||
@@ -115,4 +116,24 @@ describe('buildTurnCommandTable', () => {
|
|||||||
expect(nationCommand?.possible).toBe(true);
|
expect(nationCommand?.possible).toBe(true);
|
||||||
expect(nationCommand?.status).not.toBe('blocked');
|
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: '랜덤 임관만 가능합니다',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ const profile: GameProfile = {
|
|||||||
name: 'che:default',
|
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 => ({
|
const buildAuth = (sanctions: GameSessionTokenPayload['sanctions'] = {}): GameSessionTokenPayload => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: profile.name,
|
profile: profile.name,
|
||||||
@@ -444,7 +463,9 @@ describe('appRouter', () => {
|
|||||||
it('validates and persists general command arguments from the authenticated owner', async () => {
|
it('validates and persists general command arguments from the authenticated owner', async () => {
|
||||||
const general = buildGeneralRow({ id: 13 });
|
const general = buildGeneralRow({ id: 13 });
|
||||||
const writes: unknown[] = [];
|
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({
|
const response = await caller.turns.reserved.setGeneral({
|
||||||
generalId: 13,
|
generalId: 13,
|
||||||
@@ -479,7 +500,9 @@ describe('appRouter', () => {
|
|||||||
it('applies legacy general sentinel bulk entries and repeats the leading pattern', async () => {
|
it('applies legacy general sentinel bulk entries and repeats the leading pattern', async () => {
|
||||||
const general = buildGeneralRow({ id: 16 });
|
const general = buildGeneralRow({ id: 16 });
|
||||||
const bulkWrites: unknown[] = [];
|
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({
|
const bulk = await bulkCaller.turns.reserved.setGeneralBulk({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
@@ -538,7 +561,9 @@ describe('appRouter', () => {
|
|||||||
it('accepts the legacy nation amount-12 no-op without incrementing revision', async () => {
|
it('accepts the legacy nation amount-12 no-op without incrementing revision', async () => {
|
||||||
const general = buildGeneralRow({ id: 17, nationId: 3, officerLevel: 12 });
|
const general = buildGeneralRow({ id: 17, nationId: 3, officerLevel: 12 });
|
||||||
const nationWrites: unknown[] = [];
|
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({
|
const shifted = await caller.turns.reserved.shiftNation({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
@@ -559,7 +584,9 @@ describe('appRouter', () => {
|
|||||||
it('applies nation bulk entries sequentially so later entries overwrite overlaps', async () => {
|
it('applies nation bulk entries sequentially so later entries overwrite overlaps', async () => {
|
||||||
const general = buildGeneralRow({ id: 18, nationId: 3, officerLevel: 12 });
|
const general = buildGeneralRow({ id: 18, nationId: 3, officerLevel: 12 });
|
||||||
const nationWrites: unknown[] = [];
|
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({
|
const response = await caller.turns.reserved.setNationBulk({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
@@ -583,6 +610,122 @@ describe('appRouter', () => {
|
|||||||
expect(nationWrites).toHaveLength(1);
|
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 () => {
|
it('rejects malformed and cross-scope arguments without writing turns', async () => {
|
||||||
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
|
const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 });
|
||||||
const generalWrites: unknown[] = [];
|
const generalWrites: unknown[] = [];
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export interface GeneralActionDefinition<
|
|||||||
Args = unknown,
|
Args = unknown,
|
||||||
Context extends GeneralActionResolveContext<TriggerState> = GeneralActionResolveContext<TriggerState>,
|
Context extends GeneralActionResolveContext<TriggerState> = GeneralActionResolveContext<TriggerState>,
|
||||||
> {
|
> {
|
||||||
// TODO: legacy permissionConstraints(예약 권한) 모델링 필요.
|
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
parseArgs(raw: unknown): Args | null;
|
parseArgs(raw: unknown): Args | null;
|
||||||
|
// 레거시 testPermissionToReserve()와 같은 예약 입력 전용 제약이다.
|
||||||
|
// 정의하지 않은 명령은 레거시처럼 인자 검증 외 상태 제약 없이 예약한다.
|
||||||
|
buildPermissionConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
|
||||||
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
|
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
|
||||||
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
|
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
|
||||||
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
|
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { mustBeNPC } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
|
|
||||||
export type NPCSelfResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
export type NPCSelfResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
GeneralActionResolveContext<TriggerState> & {
|
GeneralActionResolveContext<TriggerState> & {
|
||||||
@@ -99,6 +100,10 @@ export class ActionDefinition<
|
|||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: NPCSelfArgs): Constraint[] {
|
||||||
|
return [mustBeNPC()];
|
||||||
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
||||||
void _ctx;
|
void _ctx;
|
||||||
void args;
|
void args;
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ export class ActionDefinition<
|
|||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] {
|
||||||
|
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')];
|
||||||
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] {
|
||||||
return [
|
return [
|
||||||
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
reqGeneralValue,
|
reqGeneralValue,
|
||||||
reqEnvValue,
|
reqEnvValue,
|
||||||
readMetaNumberFromUnknown,
|
readMetaNumberFromUnknown,
|
||||||
|
alwaysFail,
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -313,6 +314,10 @@ export class ActionDefinition<
|
|||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||||
|
return [alwaysFail('예약 불가능 커맨드')];
|
||||||
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||||
const env = _ctx.env;
|
const env = _ctx.env;
|
||||||
const year = typeof env.year === 'number' ? env.year : 0;
|
const year = typeof env.year === 'number' ? env.year : 0;
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export class ActionDefinition<
|
|||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
||||||
|
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')];
|
||||||
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
||||||
return [
|
return [
|
||||||
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ export class ActionDefinition<
|
|||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
||||||
|
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')];
|
||||||
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
||||||
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), beNeutral(), allowJoinAction()];
|
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), beNeutral(), allowJoinAction()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export class ActionDefinition<
|
|||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPermissionConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] {
|
||||||
|
return [reqMinimumTreatyTerm(MIN_TERM_MONTHS)];
|
||||||
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] {
|
||||||
return [beChief(), notBeNeutral()];
|
return [beChief(), notBeNeutral()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -596,13 +596,10 @@ export const mustBeNPC = (): Constraint => ({
|
|||||||
if (!general) {
|
if (!general) {
|
||||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||||
}
|
}
|
||||||
// Assuming npcState >= 2 means NPC. Need to verify exact logic if possible,
|
|
||||||
// but typically 0=human, 1=?, 2=NPC.
|
|
||||||
// Legacy: $general->getNPC() where 0:User, 1:Virtual User(unused?), 2:NPC ...
|
|
||||||
if (general.npcState >= 2) {
|
if (general.npcState >= 2) {
|
||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
return { kind: 'deny', reason: 'NPC가 아닙니다.' };
|
return { kind: 'deny', reason: 'NPC여야 합니다.' };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
|
||||||
|
import type { Constraint, ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||||
|
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||||
|
import { ActionDefinition as EmployAction } from '../../../src/actions/turn/general/che_등용.js';
|
||||||
|
import { ActionDefinition as AcceptScoutAction } from '../../../src/actions/turn/general/che_등용수락.js';
|
||||||
|
import { ActionDefinition as AppointmentAction } from '../../../src/actions/turn/general/che_임관.js';
|
||||||
|
import { ActionDefinition as FollowAppointmentAction } from '../../../src/actions/turn/general/che_장수대상임관.js';
|
||||||
|
import { ActionDefinition as NpcSelfAction } from '../../../src/actions/turn/general/che_NPC능동.js';
|
||||||
|
import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js';
|
||||||
|
|
||||||
|
const commandEnv: TurnCommandEnv = {
|
||||||
|
develCost: 100,
|
||||||
|
trainDelta: 35,
|
||||||
|
atmosDelta: 35,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
sabotageDefaultProb: 0.5,
|
||||||
|
sabotageProbCoefByStat: 0.1,
|
||||||
|
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||||
|
sabotageDamageMin: 10,
|
||||||
|
sabotageDamageMax: 30,
|
||||||
|
openingPartYear: 0,
|
||||||
|
maxGeneral: 10,
|
||||||
|
defaultNpcGold: 1000,
|
||||||
|
defaultNpcRice: 1000,
|
||||||
|
defaultCrewTypeId: 1100,
|
||||||
|
defaultSpecialDomestic: null,
|
||||||
|
defaultSpecialWar: null,
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
maxTechLevel: 10,
|
||||||
|
baseGold: 1000,
|
||||||
|
baseRice: 1000,
|
||||||
|
maxResourceActionAmount: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
class PermissionStateView implements StateView {
|
||||||
|
constructor(
|
||||||
|
private readonly actor: { npcState: number },
|
||||||
|
private readonly env: Record<string, unknown>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
has(req: RequirementKey): boolean {
|
||||||
|
if (req.kind === 'general') {
|
||||||
|
return req.id === 1;
|
||||||
|
}
|
||||||
|
if (req.kind === 'env') {
|
||||||
|
return Object.hasOwn(this.env, req.key);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(req: RequirementKey): unknown | null {
|
||||||
|
if (req.kind === 'general' && req.id === 1) {
|
||||||
|
return this.actor;
|
||||||
|
}
|
||||||
|
if (req.kind === 'env') {
|
||||||
|
return this.env[req.key] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluatePermission = (
|
||||||
|
constraints: Constraint[],
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
options?: { joinMode?: string; npcState?: number }
|
||||||
|
) => {
|
||||||
|
const env = {
|
||||||
|
join_mode: options?.joinMode ?? 'full',
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
startYear: 180,
|
||||||
|
};
|
||||||
|
const ctx: ConstraintContext = {
|
||||||
|
actorId: 1,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
mode: 'full',
|
||||||
|
};
|
||||||
|
return evaluateConstraints(constraints, ctx, new PermissionStateView({ npcState: options?.npcState ?? 0 }, env));
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionContext: ConstraintContext = {
|
||||||
|
actorId: 1,
|
||||||
|
args: {},
|
||||||
|
env: {},
|
||||||
|
mode: 'full',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('legacy reservation permission constraints', () => {
|
||||||
|
it('applies only join_mode to the three reservable join commands', () => {
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
constraints: new EmployAction(commandEnv).buildPermissionConstraints(permissionContext, {
|
||||||
|
destGeneralId: 2,
|
||||||
|
}),
|
||||||
|
args: { destGeneralId: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
constraints: new AppointmentAction(commandEnv).buildPermissionConstraints(permissionContext, {
|
||||||
|
destNationId: 2,
|
||||||
|
}),
|
||||||
|
args: { destNationId: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
constraints: new FollowAppointmentAction().buildPermissionConstraints(permissionContext, {
|
||||||
|
destGeneralID: 2,
|
||||||
|
}),
|
||||||
|
args: { destGeneralID: 2 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { constraints, args } of cases) {
|
||||||
|
const randomOnly = evaluatePermission(constraints, args, { joinMode: 'onlyRandom' });
|
||||||
|
expect(randomOnly).toMatchObject({ kind: 'deny', reason: '랜덤 임관만 가능합니다' });
|
||||||
|
|
||||||
|
const full = evaluatePermission(constraints, args);
|
||||||
|
expect(full).toEqual({ kind: 'allow' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks scout acceptance reservations and restricts NPC self-action to NPC actors', () => {
|
||||||
|
const accept = new AcceptScoutAction(commandEnv);
|
||||||
|
const acceptArgs = { destNationId: 2, destGeneralId: 7 };
|
||||||
|
expect(
|
||||||
|
evaluatePermission(accept.buildPermissionConstraints(permissionContext, acceptArgs), acceptArgs)
|
||||||
|
).toMatchObject({
|
||||||
|
kind: 'deny',
|
||||||
|
reason: '예약 불가능 커맨드',
|
||||||
|
});
|
||||||
|
|
||||||
|
const npcAction = new NpcSelfAction();
|
||||||
|
const npcArgs = { optionText: '순간이동' as const, destCityId: 2 };
|
||||||
|
expect(
|
||||||
|
evaluatePermission(npcAction.buildPermissionConstraints(permissionContext, npcArgs), npcArgs)
|
||||||
|
).toMatchObject({
|
||||||
|
kind: 'deny',
|
||||||
|
reason: 'NPC여야 합니다.',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
evaluatePermission(npcAction.buildPermissionConstraints(permissionContext, npcArgs), npcArgs, {
|
||||||
|
npcState: 2,
|
||||||
|
})
|
||||||
|
).toEqual({ kind: 'allow' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks only the six-month minimum when reserving a non-aggression proposal', () => {
|
||||||
|
const definition = new NonAggressionProposalAction();
|
||||||
|
const shortArgs = { destNationId: 2, year: 190, month: 6 };
|
||||||
|
expect(
|
||||||
|
evaluatePermission(definition.buildPermissionConstraints(permissionContext, shortArgs), shortArgs)
|
||||||
|
).toMatchObject({
|
||||||
|
kind: 'deny',
|
||||||
|
reason: '기한은 6개월 이상이어야 합니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const validArgs = { destNationId: 2, year: 190, month: 7 };
|
||||||
|
expect(
|
||||||
|
evaluatePermission(definition.buildPermissionConstraints(permissionContext, validArgs), validArgs)
|
||||||
|
).toEqual({
|
||||||
|
kind: 'allow',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user