fix general movement command parity

This commit is contained in:
2026-07-26 15:21:37 +00:00
parent e6fe5cda63
commit c003f9e112
7 changed files with 309 additions and 10 deletions
@@ -17,7 +17,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
export interface ForcedMoveResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -30,7 +30,7 @@ export interface ForcedMoveResolveContext<
const ACTION_NAME = '강행';
const ACTION_KEY = 'che_강행';
const ARGS_SCHEMA = z.object({
destCityId: z.number(),
destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int()),
});
export type ForcedMoveArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -57,7 +57,10 @@ export class ActionResolver<
// Logic: specific officer levels return to their assigned city
// 2 (Chief?), 3 (Staff?), 4 (Governor/Taishu)
if (officerLevel >= 2 && officerLevel <= 4) {
const officerCity = readMetaNumberFromUnknown(general.meta, 'officer_city');
const officerCity =
readMetaNumberFromUnknown(general.meta, 'officer_city') ??
readMetaNumberFromUnknown(general.meta, 'officerCity') ??
readMetaNumberFromUnknown(general.meta, 'officerCityId');
if (officerCity) {
destCityId = officerCity;
}
@@ -180,9 +183,7 @@ export class ActionDefinition<
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
return {
...base,
// We effectively need all cities to find the destination name if it's not the capital.
// Or at least nation cities.
nationCities: options.worldRef?.listCities().filter((c) => c.nationId === base.nation?.id) ?? [],
nationCities: options.worldRef?.listCities() ?? [],
};
};
@@ -80,13 +80,13 @@ export class ActionResolver<
const josaUl = JosaUtil.pick(nation.name, '을');
context.addLog(`<Y>${general.name}</>${josaYi} 방랑의 길을 떠납니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
format: LogFormat.RAWTEXT,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
context.addLog(`<R><b>【방랑】</b></><D><b>${general.name}</b></>${josaUn} <R>방랑</>의 길을 떠납니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.RAWTEXT,
format: LogFormat.YEAR_MONTH,
});
context.addLog(`<D><b>${nation.name}</b></>${josaUl} 버리고 방랑`, {
scope: LogScope.GENERAL,
+4
View File
@@ -662,6 +662,10 @@ export const notCapital = (checkCurrentCity = false): Constraint => ({
}
if (targetCityId === nation.capitalCityId) {
const general = readGeneral(ctx, view);
if (checkCurrentCity && general && general.officerLevel >= 2 && general.officerLevel <= 4) {
return allow();
}
return { kind: 'deny', reason: '수도입니다.' };
}
return allow();
@@ -119,6 +119,11 @@ export const projectCoreDatabaseSnapshot = (rows: {
experience: row.experience,
dedication: row.dedication,
officerLevel: row.officerLevel,
officerCityId: readNumber(
row,
'officerCityId',
readNumber(meta, 'officer_city', readNumber(meta, 'officerCity', readNumber(meta, 'officerCityId')))
),
betray: row.betray,
personality: row.personality ?? null,
specialDomestic: row.specialDomestic ?? null,
@@ -262,7 +262,21 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')),
explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')),
betray: readNumber(row, 'betray', readNumber(meta, 'betray')),
officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')),
officerCityId: readNumber(
row,
'officerCityId',
readNumber(meta, 'officerCityId', readNumber(meta, 'officerCity', readNumber(meta, 'officer_city')))
),
officerCity: readNumber(
row,
'officerCityId',
readNumber(meta, 'officerCity', readNumber(meta, 'officer_city'))
),
officer_city: readNumber(
row,
'officerCityId',
readNumber(meta, 'officer_city', readNumber(meta, 'officerCity'))
),
block: readNumber(row, 'blockState', readNumber(meta, 'block')),
},
...(lastTurn ? { lastTurn } : {}),
@@ -520,6 +534,11 @@ const projectWorld = (
dedication: toDatabaseInt(general.dedication),
expLevel: readNumber(general.meta, 'explevel'),
officerLevel: general.officerLevel,
officerCityId: readNumber(
general.meta,
'officer_city',
readNumber(general.meta, 'officerCity', readNumber(general.meta, 'officerCityId'))
),
betray: readNumber(general.meta, 'betray'),
personality: general.role.personality,
specialDomestic: general.role.specialDomestic,
@@ -637,6 +637,276 @@ integration('general sightseeing event, RNG, value, and log parity', () => {
);
});
interface MovementBoundaryCase {
name: string;
action: 'che_강행' | 'che_귀환' | 'che_접경귀환' | 'che_집합' | 'che_방랑';
args?: Record<string, unknown>;
actorPatch?: Record<string, unknown>;
fixturePatches?: FixturePatches;
completed: boolean;
fallback?: boolean;
}
const movementBoundaryCases: MovementBoundaryCase[] = [
{
name: 'forced move accepts a numeric-string city ID',
action: 'che_강행',
args: { destCityID: '70' },
completed: true,
},
{
name: 'forced move truncates a fractional city ID',
action: 'che_강행',
args: { destCityID: 70.9 },
completed: true,
},
{
name: 'forced move rejects the current city',
action: 'che_강행',
args: { destCityID: 3 },
completed: false,
},
{
name: 'forced move rejects an unknown city',
action: 'che_강행',
args: { destCityID: 999 },
completed: false,
},
{
name: 'forced move rejects insufficient gold',
action: 'che_강행',
args: { destCityID: 70 },
actorPatch: { gold: 0 },
completed: false,
},
{
name: 'forced move preserves the training and morale floors',
action: 'che_강행',
args: { destCityID: 70 },
actorPatch: { train: 20, atmos: 20 },
completed: true,
},
{
name: 'forced move carries every wandering-nation general',
action: 'che_강행',
args: { destCityID: 70 },
fixturePatches: { nations: { 1: { level: 0 } } },
completed: true,
},
{
name: 'return rejects a neutral actor',
action: 'che_귀환',
actorPatch: { nationId: 0 },
completed: false,
},
{
name: 'return rejects a wandering nation',
action: 'che_귀환',
fixturePatches: { nations: { 1: { level: 0 } } },
completed: false,
},
{
name: 'return rejects a lord already in the capital',
action: 'che_귀환',
completed: false,
},
{
name: 'return lets a local officer leave the capital for the assigned city',
action: 'che_귀환',
actorPatch: { officerLevel: 2, officerCityId: 70 },
fixturePatches: { cities: { 70: { nationId: 1 } } },
completed: true,
},
{
name: 'return rewards an officer already in the assigned non-capital city',
action: 'che_귀환',
actorPatch: { officerLevel: 4, officerCityId: 70, cityId: 70 },
fixturePatches: { cities: { 70: { nationId: 1 } } },
completed: true,
},
{
name: 'return sends a normal officer to the capital',
action: 'che_귀환',
actorPatch: { officerLevel: 1, cityId: 70 },
completed: true,
},
{
name: 'border return rejects a neutral actor',
action: 'che_접경귀환',
actorPatch: { nationId: 0, cityId: 70 },
completed: false,
},
{
name: 'border return rejects a wandering nation',
action: 'che_접경귀환',
actorPatch: { cityId: 70 },
fixturePatches: { nations: { 1: { level: 0 } } },
completed: false,
},
{
name: 'border return rejects an actor already in an owned city',
action: 'che_접경귀환',
completed: false,
},
{
name: 'border return fails without a supplied owned city in range',
action: 'che_접경귀환',
actorPatch: { cityId: 70 },
fixturePatches: { cities: { 3: { supplyState: 0 } } },
completed: false,
fallback: false,
},
{
name: 'border return consumes the single-candidate choice and moves',
action: 'che_접경귀환',
actorPatch: { cityId: 70 },
completed: true,
},
{
name: 'assembly rejects a neutral actor',
action: 'che_집합',
actorPatch: { nationId: 0, troopId: 1 },
fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] },
completed: false,
},
{
name: 'assembly rejects a foreign-occupied city',
action: 'che_집합',
actorPatch: { troopId: 1 },
fixturePatches: {
cities: { 3: { nationId: 2 } },
troops: [{ id: 1, nationId: 1, name: '조조군' }],
},
completed: false,
},
{
name: 'assembly rejects an unsupplied city',
action: 'che_집합',
actorPatch: { troopId: 1 },
fixturePatches: {
cities: { 3: { supplyState: 0 } },
troops: [{ id: 1, nationId: 1, name: '조조군' }],
},
completed: false,
},
{
name: 'assembly rejects a non-leader',
action: 'che_집합',
actorPatch: { troopId: 3 },
fixturePatches: {
generals: { 3: { troopId: 3 } },
troops: [{ id: 3, nationId: 1, name: '조조군' }],
},
completed: false,
},
{
name: 'assembly rejects a troop without another member',
action: 'che_집합',
actorPatch: { troopId: 1 },
fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] },
completed: false,
},
{
name: 'assembly completes when every member is already present',
action: 'che_집합',
actorPatch: { troopId: 1 },
fixturePatches: {
generals: { 3: { troopId: 1 } },
troops: [{ id: 1, nationId: 1, name: '조조군' }],
},
completed: true,
},
{
name: 'assembly moves a remote member and records its plain log',
action: 'che_집합',
actorPatch: { troopId: 1 },
fixturePatches: {
generals: { 3: { cityId: 70, troopId: 1 } },
troops: [{ id: 1, nationId: 1, name: '조조군' }],
},
completed: true,
},
{
name: 'wander rejects a non-lord',
action: 'che_방랑',
actorPatch: { officerLevel: 11 },
fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } },
completed: false,
},
{
name: 'wander rejects an already wandering nation',
action: 'che_방랑',
fixturePatches: {
nations: { 1: { level: 0 } },
diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } },
},
completed: false,
},
{
name: 'wander rejects the opening phase',
action: 'che_방랑',
fixturePatches: {
world: { year: 180 },
diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } },
},
completed: false,
},
{
name: 'wander rejects a disallowed diplomacy state',
action: 'che_방랑',
completed: false,
},
{
name: 'wander accepts neutral diplomacy',
action: 'che_방랑',
fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } },
completed: true,
},
{
name: 'wander accepts non-aggression diplomacy',
action: 'che_방랑',
fixturePatches: { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 7 } } },
completed: true,
},
];
integration('general movement command boundary and log parity', () => {
it.each(movementBoundaryCases)(
'$name',
async ({ name, action, args, actorPatch, fixturePatches, completed, fallback }) => {
const request = buildRequest(action, args, actorPatch, fixturePatches);
request.setup!.world!.hiddenSeed = `general-movement-${name}`;
request.observe!.includeGlobalHistoryLogs = true;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const usedFallback = fallback ?? !completed;
expect(reference.execution.outcome).toMatchObject({ completed });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: usedFallback ? '휴식' : action,
usedFallback,
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
if (completed) {
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
}
},
120_000
);
});
integration('명장일람 rank_data command parity', () => {
it('화계 increments firenum from the same seeded value as legacy', async () => {
const request = buildRequest(