fix nation personnel command parity
This commit is contained in:
@@ -1,13 +1,6 @@
|
|||||||
import type {
|
import type { City, General, GeneralMeta, GeneralTriggerState, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
|
||||||
City,
|
|
||||||
General,
|
|
||||||
GeneralMeta,
|
|
||||||
GeneralTriggerState,
|
|
||||||
TriggerValue,
|
|
||||||
} from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
denyWithReason,
|
|
||||||
beChief,
|
beChief,
|
||||||
existsDestGeneral,
|
existsDestGeneral,
|
||||||
friendlyDestGeneral,
|
friendlyDestGeneral,
|
||||||
@@ -32,11 +25,11 @@ import { resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionConte
|
|||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import type { NationTurnCommandSpec } from './index.js';
|
import type { NationTurnCommandSpec } from './index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destGeneralId: z.number(),
|
destGeneralId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()),
|
||||||
destCityId: z.number(),
|
destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()),
|
||||||
});
|
});
|
||||||
export type AssignmentArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type AssignmentArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -101,6 +94,18 @@ export class ActionResolver<
|
|||||||
void _args;
|
void _args;
|
||||||
const destGeneral = context.destGeneral;
|
const destGeneral = context.destGeneral;
|
||||||
const destCity = context.destCity;
|
const destCity = context.destCity;
|
||||||
|
if (destGeneral.id === context.general.id) {
|
||||||
|
return {
|
||||||
|
completed: false,
|
||||||
|
effects: [
|
||||||
|
createLogEffect(`본인입니다 <Y>${destGeneral.name}</> ${ACTION_NAME} 실패.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
const cityName = this.env.formatCityName ? this.env.formatCityName(destCity) : destCity.name;
|
const cityName = this.env.formatCityName ? this.env.formatCityName(destCity) : destCity.name;
|
||||||
const cityJosa = JosaUtil.pick(cityName, '로');
|
const cityJosa = JosaUtil.pick(cityName, '로');
|
||||||
const generalJosa = JosaUtil.pick(destGeneral.name, '을');
|
const generalJosa = JosaUtil.pick(destGeneral.name, '을');
|
||||||
@@ -158,11 +163,7 @@ export class ActionDefinition<
|
|||||||
return [beChief(), notBeNeutral(), occupiedCity(), suppliedCity()];
|
return [beChief(), notBeNeutral(), occupiedCity(), suppliedCity()];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] {
|
||||||
void _args;
|
|
||||||
if (ctx.destGeneralId === ctx.actorId) {
|
|
||||||
return [denyWithReason('본인입니다')];
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
beChief(),
|
beChief(),
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import { beChief, existsDestGeneral, friendlyDestGeneral, notBeNeutral } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
denyWithReason,
|
|
||||||
beChief,
|
|
||||||
existsDestGeneral,
|
|
||||||
friendlyDestGeneral,
|
|
||||||
notBeNeutral,
|
|
||||||
} 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 {
|
||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
@@ -23,10 +17,7 @@ import { z } from 'zod';
|
|||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
destGeneralId: z.preprocess(
|
destGeneralId: z.number().int().positive(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value) : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
export type TroopKickArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type TroopKickArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -52,10 +43,7 @@ export class ActionDefinition<
|
|||||||
return [notBeNeutral(), beChief()];
|
return [notBeNeutral(), beChief()];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] {
|
||||||
if (ctx.destGeneralId !== undefined && ctx.destGeneralId === ctx.actorId) {
|
|
||||||
return [denyWithReason('본인입니다')];
|
|
||||||
}
|
|
||||||
return [notBeNeutral(), beChief(), existsDestGeneral(), friendlyDestGeneral()];
|
return [notBeNeutral(), beChief(), existsDestGeneral(), friendlyDestGeneral()];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +54,19 @@ export class ActionDefinition<
|
|||||||
const josaUn = JosaUtil.pick(destGeneralName, '은');
|
const josaUn = JosaUtil.pick(destGeneralName, '은');
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||||
|
|
||||||
|
if (destGeneral.id === general.id) {
|
||||||
|
return {
|
||||||
|
completed: false,
|
||||||
|
effects: [
|
||||||
|
createLogEffect(`본인입니다 ${ACTION_NAME} 실패.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (destGeneral.troopId === 0) {
|
if (destGeneral.troopId === 0) {
|
||||||
context.addLog(`<Y>${destGeneralName}</>${josaUn} 부대원이 아닙니다.`);
|
context.addLog(`<Y>${destGeneralName}</>${josaUn} 부대원이 아닙니다.`);
|
||||||
return { effects };
|
return { effects };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/
|
|||||||
import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js';
|
import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js';
|
||||||
import { ActionDefinition as StopWarProposalAction } from '../../../src/actions/turn/nation/che_종전제의.js';
|
import { ActionDefinition as StopWarProposalAction } from '../../../src/actions/turn/nation/che_종전제의.js';
|
||||||
import { ActionDefinition as NonAggressionCancelProposalAction } from '../../../src/actions/turn/nation/che_불가침파기제의.js';
|
import { ActionDefinition as NonAggressionCancelProposalAction } from '../../../src/actions/turn/nation/che_불가침파기제의.js';
|
||||||
|
import { ActionDefinition as TroopKickAction } from '../../../src/actions/turn/nation/che_부대탈퇴지시.js';
|
||||||
|
import { ActionDefinition as AssignmentAction } from '../../../src/actions/turn/nation/che_발령.js';
|
||||||
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
|
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
|
||||||
import {
|
import {
|
||||||
ActionDefinition as ChangeNationNameAction,
|
ActionDefinition as ChangeNationNameAction,
|
||||||
@@ -266,6 +268,29 @@ describe('Nation Actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('personnel command argument boundaries', () => {
|
||||||
|
it('keeps troop-kick target IDs strict', () => {
|
||||||
|
const definition = new TroopKickAction();
|
||||||
|
|
||||||
|
expect(definition.parseArgs({ destGeneralId: 3 })).toEqual({ destGeneralId: 3 });
|
||||||
|
expect(definition.parseArgs({ destGeneralId: '3' })).toBeNull();
|
||||||
|
expect(definition.parseArgs({ destGeneralId: 3.9 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses PHP weak integer coercion for assignment IDs', () => {
|
||||||
|
const definition = new AssignmentAction({});
|
||||||
|
|
||||||
|
expect(definition.parseArgs({ destGeneralId: '3', destCityId: '70' })).toEqual({
|
||||||
|
destGeneralId: 3,
|
||||||
|
destCityId: 70,
|
||||||
|
});
|
||||||
|
expect(definition.parseArgs({ destGeneralId: 3.9, destCityId: 70.9 })).toEqual({
|
||||||
|
destGeneralId: 3,
|
||||||
|
destCityId: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('che_필사즉생 (Last Stand)', () => {
|
describe('che_필사즉생 (Last Stand)', () => {
|
||||||
it('applies the legacy three-turn gains and global delay', () => {
|
it('applies the legacy three-turn gains and global delay', () => {
|
||||||
const nation = buildNation(1);
|
const nation = buildNation(1);
|
||||||
|
|||||||
@@ -199,8 +199,11 @@ const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandPr
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildGeneral = (row: Record<string, unknown>, turnTime: Date): TurnGeneral => {
|
const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): TurnGeneral => {
|
||||||
const meta = asRecord(row.meta);
|
const meta = asRecord(row.meta);
|
||||||
|
const rawTurnTime = row.turnTime;
|
||||||
|
const parsedTurnTime = typeof rawTurnTime === 'string' ? new Date(rawTurnTime) : fallbackTurnTime;
|
||||||
|
const turnTime = Number.isNaN(parsedTurnTime.getTime()) ? fallbackTurnTime : parsedTurnTime;
|
||||||
const rawLastTurn = asRecord(row.lastTurn);
|
const rawLastTurn = asRecord(row.lastTurn);
|
||||||
const lastTurn =
|
const lastTurn =
|
||||||
typeof rawLastTurn.command === 'string'
|
typeof rawLastTurn.command === 'string'
|
||||||
|
|||||||
@@ -1178,6 +1178,196 @@ integration('nation diplomacy proposal boundary and message parity', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface PersonnelCommandBoundaryCase {
|
||||||
|
name: string;
|
||||||
|
action: 'che_부대탈퇴지시' | 'che_발령';
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
completed: boolean;
|
||||||
|
expectedTroopId?: number;
|
||||||
|
expectedCityId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personnelCommandBoundaryCases: PersonnelCommandBoundaryCase[] = [
|
||||||
|
{
|
||||||
|
name: 'troop-kick rejects a numeric-string target',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: '3' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick rejects a fractional target instead of truncating it',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 3.9 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick rejects self',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 1 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick rejects a missing target',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 9 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick rejects a foreign target',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 2 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick completes without mutation for a non-member',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 3 },
|
||||||
|
completed: true,
|
||||||
|
expectedTroopId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick completes without mutation for a troop leader',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 3 },
|
||||||
|
fixturePatches: { generals: { 3: { troopId: 3 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedTroopId: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'troop-kick removes a regular troop member',
|
||||||
|
action: 'che_부대탈퇴지시',
|
||||||
|
args: { destGeneralID: 3 },
|
||||||
|
fixturePatches: { generals: { 3: { troopId: 1 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedTroopId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment accepts numeric-string IDs through PHP weak int coercion',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: '3', destCityID: '70' },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedCityId: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment truncates fractional IDs through PHP weak int coercion',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 3.9, destCityID: 70.9 },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedCityId: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects self',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 1, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects a missing target general at execution time',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 9, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects a foreign target general',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 2, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects an actor city occupied by another nation',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 3, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 3: { nationId: 2 }, 70: { nationId: 1 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects an unsupplied actor city',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 3, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 3: { supplyState: 0 }, 70: { nationId: 1 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects a destination city occupied by another nation',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 3, destCityID: 70 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment rejects an unsupplied destination city',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 3, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1, supplyState: 0 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment moves a friendly target and records the assignment month',
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralID: 3, destCityID: 70 },
|
||||||
|
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||||
|
completed: true,
|
||||||
|
expectedCityId: 70,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation personnel command boundary parity', () => {
|
||||||
|
it.each(personnelCommandBoundaryCases)(
|
||||||
|
'$name',
|
||||||
|
async ({ action, args, fixturePatches, completed, expectedTroopId, expectedCityId }) => {
|
||||||
|
const request = buildRequest(action, args, fixturePatches);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
const originalCommandFailure = args.destGeneralID === 1;
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: completed || originalCommandFailure ? action : '휴식',
|
||||||
|
usedFallback: !completed && !originalCommandFailure,
|
||||||
|
});
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
if (!completed) {
|
||||||
|
if (originalCommandFailure) {
|
||||||
|
expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual(
|
||||||
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceTarget = reference.after.generals.find((entry) => entry.id === 3);
|
||||||
|
const coreTarget = core.after.generals.find((entry) => entry.id === 3);
|
||||||
|
if (expectedTroopId !== undefined) {
|
||||||
|
expect(referenceTarget?.troopId).toBe(expectedTroopId);
|
||||||
|
expect(coreTarget?.troopId).toBe(expectedTroopId);
|
||||||
|
}
|
||||||
|
if (expectedCityId !== undefined) {
|
||||||
|
expect(referenceTarget?.cityId).toBe(expectedCityId);
|
||||||
|
expect(coreTarget?.cityId).toBe(expectedCityId);
|
||||||
|
expect(readGeneralMeta(coreTarget).last발령).toBe(readGeneralMeta(referenceTarget).last발령);
|
||||||
|
}
|
||||||
|
expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual(
|
||||||
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed';
|
type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed';
|
||||||
|
|
||||||
const volunteerRecruitBoundaryCases: Array<{
|
const volunteerRecruitBoundaryCases: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user