merge: complete general turn compatibility
This commit is contained in:
@@ -145,6 +145,7 @@ const buildGeneralUpdate = (
|
||||
personalCode: toCode(general.role.personality),
|
||||
specialCode: toCode(general.role.specialDomestic),
|
||||
special2Code: toCode(general.role.specialWar),
|
||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
@@ -180,6 +181,7 @@ const buildGeneralCreate = (
|
||||
personalCode: toCode(general.role.personality),
|
||||
specialCode: toCode(general.role.specialDomestic),
|
||||
special2Code: toCode(general.role.specialWar),
|
||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
@@ -214,6 +216,7 @@ const buildCityUpdate = (
|
||||
defenceMax: city.defenceMax,
|
||||
wall: city.wall,
|
||||
wallMax: city.wallMax,
|
||||
...(city.conflict ? { conflict: asJson(city.conflict) } : {}),
|
||||
meta: asJson(meta),
|
||||
};
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
||||
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
||||
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], DEFAULT_ATMOS_DELTA),
|
||||
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
||||
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], DEFAULT_MAX_TRAIN_BY_COMMAND),
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_MAX_ATMOS_BY_COMMAND),
|
||||
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], DEFAULT_SABOTAGE_PROB),
|
||||
@@ -95,6 +96,9 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
|
||||
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
||||
maxResourceActionAmount: resolveNumber(
|
||||
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
import {
|
||||
DEFAULT_TURN_COMMAND_PROFILE,
|
||||
GeneralTurnCommandLoader,
|
||||
GeneralActionPipeline,
|
||||
NationTurnCommandLoader,
|
||||
createGeneralTriggerContext,
|
||||
defaultActionContextBuilder,
|
||||
evaluateConstraints,
|
||||
resolveGeneralAction,
|
||||
@@ -101,7 +103,7 @@ const serializeSeed = (...values: Array<string | number>): string =>
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
type NationLastTurn = {
|
||||
type LegacyLastTurn = {
|
||||
command: string;
|
||||
arg?: Record<string, unknown>;
|
||||
term?: number;
|
||||
@@ -110,7 +112,7 @@ type NationLastTurn = {
|
||||
|
||||
const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`;
|
||||
|
||||
const normalizeLastTurn = (value: unknown): NationLastTurn => {
|
||||
const normalizeLastTurn = (value: unknown): LegacyLastTurn => {
|
||||
const raw = asRecord(value);
|
||||
return {
|
||||
command: typeof raw.command === 'string' ? raw.command : '휴식',
|
||||
@@ -135,6 +137,18 @@ const readNextAvailableTurn = (nation: Nation, actionName: string): number | nul
|
||||
return null;
|
||||
};
|
||||
|
||||
const readGeneralNextAvailableTurn = (general: TurnGeneral, actionName: string): number | null => {
|
||||
const raw = asRecord(general.meta)[`next_execute_${actionName}`];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -631,7 +645,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
definitionMap: Map<string, GeneralActionDefinition>,
|
||||
fallbackDefinition: GeneralActionDefinition,
|
||||
command: ReservedTurnEntry,
|
||||
applyNextTurnAt: boolean
|
||||
applyNextTurnAt: boolean,
|
||||
alternativeDepth = 0
|
||||
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
||||
const rawArgs = extractArgsRecord(command.args);
|
||||
@@ -680,9 +695,12 @@ export const createReservedTurnHandler = async (options: {
|
||||
const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined;
|
||||
logs.push(createActionLog(reason, meta));
|
||||
}
|
||||
if (kind === 'nation' && !usedFallback && currentNation) {
|
||||
if (!usedFallback && (kind === 'general' || currentNation)) {
|
||||
const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth);
|
||||
const nextAvailableTurn = readNextAvailableTurn(currentNation, definition.name);
|
||||
const nextAvailableTurn =
|
||||
kind === 'general'
|
||||
? readGeneralNextAvailableTurn(currentGeneral, definition.name)
|
||||
: readNextAvailableTurn(currentNation!, definition.name);
|
||||
if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
|
||||
const remainTurn = nextAvailableTurn - currentYearMonth;
|
||||
definition = fallbackDefinition;
|
||||
@@ -698,10 +716,11 @@ export const createReservedTurnHandler = async (options: {
|
||||
const buildRng = (key: string) => {
|
||||
const rngSeed = serializeSeed(
|
||||
seedBase,
|
||||
key,
|
||||
kind === 'general' ? 'generalCommand' : 'nationCommand',
|
||||
context.world.currentYear,
|
||||
context.world.currentMonth,
|
||||
currentGeneral.id
|
||||
currentGeneral.id,
|
||||
key
|
||||
);
|
||||
return new RandUtil(new LiteHashDRBG(rngSeed));
|
||||
};
|
||||
@@ -759,19 +778,27 @@ export const createReservedTurnHandler = async (options: {
|
||||
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||
getStackSequence?: (context: ActionContextBase, args: unknown) => number | null;
|
||||
getProgressText?: (
|
||||
context: ActionContextBase,
|
||||
args: unknown,
|
||||
term: number,
|
||||
termMax: number
|
||||
) => string;
|
||||
getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number;
|
||||
};
|
||||
const preReqTurn =
|
||||
kind === 'nation' && !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
const postReqTurn =
|
||||
kind === 'nation' && !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
const preReqTurn = !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
const postReqTurn = !usedFallback
|
||||
? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||
: 0;
|
||||
|
||||
if (kind === 'nation' && !usedFallback && currentNation && preReqTurn > 0) {
|
||||
if (!usedFallback && preReqTurn > 0 && (kind === 'general' || currentNation)) {
|
||||
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||
const lastTurn = normalizeLastTurn(asRecord(currentNation.meta)[metaKey]);
|
||||
const lastTurn =
|
||||
kind === 'general'
|
||||
? normalizeLastTurn(currentGeneral.lastTurn)
|
||||
: normalizeLastTurn(asRecord(currentNation!.meta)[metaKey]);
|
||||
const stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null;
|
||||
const sequenceChanged =
|
||||
stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence);
|
||||
@@ -782,26 +809,39 @@ export const createReservedTurnHandler = async (options: {
|
||||
const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1;
|
||||
|
||||
if (!continuing || (lastTurn.term ?? 0) < preReqTurn) {
|
||||
const nextLastTurn: NationLastTurn = {
|
||||
const nextLastTurn: LegacyLastTurn = {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
term: nextTerm,
|
||||
...(stackSequence !== null ? { seq: stackSequence } : undefined),
|
||||
};
|
||||
const nextNation: Nation = {
|
||||
...currentNation,
|
||||
meta: {
|
||||
...currentNation.meta,
|
||||
[metaKey]: nextLastTurn,
|
||||
} as Nation['meta'],
|
||||
};
|
||||
currentNation = nextNation;
|
||||
worldOverlay?.syncNation(nextNation);
|
||||
logs.push(createActionLog(`${definition.name} 수행중... (${nextTerm}/${preReqTurn + 1})`));
|
||||
if (kind === 'general') {
|
||||
currentGeneral = {
|
||||
...currentGeneral,
|
||||
lastTurn: nextLastTurn,
|
||||
};
|
||||
worldOverlay?.syncGeneral(currentGeneral);
|
||||
} else {
|
||||
const nextNation: Nation = {
|
||||
...currentNation!,
|
||||
meta: {
|
||||
...currentNation!.meta,
|
||||
[metaKey]: nextLastTurn,
|
||||
} as Nation['meta'],
|
||||
};
|
||||
currentNation = nextNation;
|
||||
worldOverlay?.syncNation(nextNation);
|
||||
}
|
||||
const termMax = preReqTurn + 1;
|
||||
const progressText =
|
||||
executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ??
|
||||
`${definition.name} 수행중... (${nextTerm}/${termMax})`;
|
||||
logs.push(createActionLog(progressText));
|
||||
return { actionKey, usedFallback, blockedReason };
|
||||
}
|
||||
}
|
||||
|
||||
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
|
||||
const resolution = resolveGeneralAction(
|
||||
definition,
|
||||
actionContext,
|
||||
@@ -815,19 +855,59 @@ export const createReservedTurnHandler = async (options: {
|
||||
currentGeneral = resolution.general as TurnGeneral;
|
||||
currentCity = resolution.city ?? currentCity;
|
||||
currentNation = resolution.nation ?? currentNation;
|
||||
if (kind === 'nation' && !usedFallback && definition.countsAsInheritanceActiveAction) {
|
||||
if (
|
||||
!resolution.alternative &&
|
||||
kind === 'nation' &&
|
||||
!usedFallback &&
|
||||
definition.countsAsInheritanceActiveAction
|
||||
) {
|
||||
const meta = { ...currentGeneral.meta };
|
||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||
meta.inherit_active_action = active + 1;
|
||||
currentGeneral = { ...currentGeneral, meta };
|
||||
}
|
||||
if (
|
||||
!resolution.alternative &&
|
||||
kind === 'general' &&
|
||||
!usedFallback &&
|
||||
executionDefinition.getInheritanceActiveActionAmount
|
||||
) {
|
||||
const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs);
|
||||
if (Number.isFinite(amount) && amount !== 0) {
|
||||
const meta = { ...currentGeneral.meta };
|
||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||
meta.inherit_active_action = active + amount;
|
||||
currentGeneral = { ...currentGeneral, meta };
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentNation && resolution.created?.nations) {
|
||||
currentNation =
|
||||
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
|
||||
currentNation;
|
||||
}
|
||||
if (kind === 'nation' && !usedFallback && currentNation) {
|
||||
if (!resolution.alternative && kind === 'general' && !usedFallback) {
|
||||
const actionChangedLastTurn =
|
||||
JSON.stringify(currentGeneral.lastTurn ?? {}) !== lastTurnBeforeExecution;
|
||||
const nextMeta = { ...currentGeneral.meta };
|
||||
if (postReqTurn > 0) {
|
||||
nextMeta[`next_execute_${definition.name}`] =
|
||||
joinYearMonth(context.world.currentYear, context.world.currentMonth) +
|
||||
postReqTurn -
|
||||
preReqTurn;
|
||||
}
|
||||
currentGeneral = {
|
||||
...currentGeneral,
|
||||
meta: nextMeta,
|
||||
lastTurn: actionChangedLastTurn
|
||||
? currentGeneral.lastTurn
|
||||
: {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!resolution.alternative && kind === 'nation' && !usedFallback && currentNation) {
|
||||
const metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||
const nextMeta: Record<string, unknown> = {
|
||||
...currentNation.meta,
|
||||
@@ -835,7 +915,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
command: definition.name,
|
||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||
term: 0,
|
||||
} satisfies NationLastTurn,
|
||||
} satisfies LegacyLastTurn,
|
||||
};
|
||||
if (postReqTurn > 0) {
|
||||
nextMeta[`next_execute_${definition.name}`] =
|
||||
@@ -951,6 +1031,23 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (resolution.alternative) {
|
||||
if (alternativeDepth >= 5) {
|
||||
throw new Error('Command fallback loop limit exceeded');
|
||||
}
|
||||
return runAction(
|
||||
kind,
|
||||
definitionMap,
|
||||
fallbackDefinition,
|
||||
{
|
||||
action: resolution.alternative.commandKey,
|
||||
args: extractArgsRecord(resolution.alternative.args),
|
||||
},
|
||||
applyNextTurnAt,
|
||||
alternativeDepth + 1
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined,
|
||||
actionKey,
|
||||
@@ -959,7 +1056,98 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
};
|
||||
|
||||
if (currentNation && currentGeneral.officerLevel >= 5) {
|
||||
const preprocessRng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
serializeSeed(
|
||||
buildSeedBase(context.world),
|
||||
'preprocess',
|
||||
context.world.currentYear,
|
||||
context.world.currentMonth,
|
||||
currentGeneral.id
|
||||
)
|
||||
)
|
||||
);
|
||||
currentGeneral = {
|
||||
...currentGeneral,
|
||||
role: {
|
||||
...currentGeneral.role,
|
||||
items: { ...currentGeneral.role.items },
|
||||
},
|
||||
meta: { ...currentGeneral.meta },
|
||||
triggerState: {
|
||||
...currentGeneral.triggerState,
|
||||
flags: { ...currentGeneral.triggerState.flags },
|
||||
counters: { ...currentGeneral.triggerState.counters },
|
||||
modifiers: { ...currentGeneral.triggerState.modifiers },
|
||||
meta: { ...currentGeneral.triggerState.meta },
|
||||
},
|
||||
};
|
||||
if (currentGeneral.npcState < 2) {
|
||||
const lived =
|
||||
typeof currentGeneral.meta.inherit_lived_month === 'number'
|
||||
? currentGeneral.meta.inherit_lived_month
|
||||
: 0;
|
||||
currentGeneral.meta.inherit_lived_month = lived + 1;
|
||||
}
|
||||
currentCity = currentCity ? { ...currentCity, meta: { ...currentCity.meta } } : currentCity;
|
||||
const preTurnPipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
const preTurnContext = createGeneralTriggerContext({
|
||||
general: currentGeneral,
|
||||
nation: currentNation,
|
||||
worldView: worldView ?? undefined,
|
||||
rng: preprocessRng,
|
||||
log: {
|
||||
push: (message: string) => logs.push(createActionLog(message)),
|
||||
},
|
||||
});
|
||||
preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv);
|
||||
if (currentGeneral.injury > 0 && !preTurnContext.skill.has('pre.부상경감')) {
|
||||
currentGeneral.injury = Math.max(0, currentGeneral.injury - 10);
|
||||
preTurnContext.skill.activate('pre.부상경감');
|
||||
}
|
||||
if (currentGeneral.crew >= 100) {
|
||||
const consumeRice = Math.trunc(currentGeneral.crew / 100);
|
||||
if (consumeRice <= currentGeneral.rice) {
|
||||
currentGeneral.rice -= consumeRice;
|
||||
} else {
|
||||
const releasedCrew = preTurnPipeline.onCalcDomestic(
|
||||
preTurnContext,
|
||||
'징집인구',
|
||||
'score',
|
||||
currentGeneral.crew
|
||||
);
|
||||
if (currentCity) {
|
||||
currentCity.population += releasedCrew;
|
||||
}
|
||||
currentGeneral.crew = 0;
|
||||
currentGeneral.rice = 0;
|
||||
logs.push(createActionLog('군량이 모자라 병사들이 <R>소집해제</>되었습니다!'));
|
||||
preTurnContext.skill.activate('pre.소집해제');
|
||||
}
|
||||
preTurnContext.skill.activate('pre.병력군량소모');
|
||||
}
|
||||
worldOverlay?.syncGeneral(currentGeneral);
|
||||
if (currentCity) {
|
||||
worldOverlay?.syncCity(currentCity);
|
||||
}
|
||||
|
||||
const blockCode = typeof currentGeneral.meta.block === 'number' ? Math.trunc(currentGeneral.meta.block) : 0;
|
||||
const isBlocked = blockCode === 2 || blockCode === 3;
|
||||
if (isBlocked) {
|
||||
currentGeneral.meta.killturn = Math.max(
|
||||
0,
|
||||
typeof currentGeneral.meta.killturn === 'number' ? currentGeneral.meta.killturn - 1 : 0
|
||||
);
|
||||
logs.push(
|
||||
createActionLog(
|
||||
blockCode === 2
|
||||
? '현재 멀티, 또는 비매너로 인한<R>블럭</> 대상자입니다.'
|
||||
: '현재 악성유저로 분류되어 <R>블럭</> 대상자입니다.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
|
||||
let nationCommand = options.reservedTurns.getNationTurn(
|
||||
currentNation.id,
|
||||
currentGeneral.officerLevel,
|
||||
@@ -1003,9 +1191,13 @@ export const createReservedTurnHandler = async (options: {
|
||||
});
|
||||
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
|
||||
}
|
||||
if (isBlocked && currentNation && currentGeneral.officerLevel >= 5) {
|
||||
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
|
||||
}
|
||||
|
||||
let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
|
||||
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
|
||||
let generalAutorunMode = false;
|
||||
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||
const ai = new GeneralAI({
|
||||
general: currentGeneral,
|
||||
@@ -1026,11 +1218,20 @@ export const createReservedTurnHandler = async (options: {
|
||||
});
|
||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||
if (candidate) {
|
||||
generalAutorunMode =
|
||||
candidate.action !== generalCommand.action ||
|
||||
JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {});
|
||||
generalCommand = { action: candidate.action, args: candidate.args };
|
||||
}
|
||||
generalAiState = ai.getDebugState();
|
||||
}
|
||||
const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
const generalResult = isBlocked
|
||||
? {
|
||||
actionKey: DEFAULT_ACTION,
|
||||
usedFallback: true,
|
||||
blockedReason: '블럭 대상자입니다.',
|
||||
}
|
||||
: runAction('general', generalDefinitions, generalFallback, generalCommand, true);
|
||||
options.onActionResolved?.({
|
||||
kind: 'general',
|
||||
generalId: currentGeneral.id,
|
||||
@@ -1044,20 +1245,42 @@ export const createReservedTurnHandler = async (options: {
|
||||
const nextTurnAt = generalResult.nextTurnAt;
|
||||
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
|
||||
|
||||
const worldMeta = asRecord(context.world.meta);
|
||||
if (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) {
|
||||
if (!isBlocked) {
|
||||
const meta = { ...currentGeneral.meta };
|
||||
const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0;
|
||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||
meta.inherit_lived_month = lived + 1;
|
||||
if (generalResult.actionKey !== DEFAULT_ACTION) {
|
||||
meta.inherit_active_action = active + 1;
|
||||
const currentKillturn =
|
||||
typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0;
|
||||
const worldKillturn = readMetaNumber(asRecord(context.world.meta), 'killturn', currentKillturn);
|
||||
const requestedRest = generalCommand.action === DEFAULT_ACTION;
|
||||
if (
|
||||
currentGeneral.npcState >= 2 ||
|
||||
currentKillturn > worldKillturn ||
|
||||
generalAutorunMode ||
|
||||
requestedRest
|
||||
) {
|
||||
meta.killturn = Math.max(0, currentKillturn - 1);
|
||||
} else {
|
||||
meta.inherit_active_action = active;
|
||||
meta.killturn = worldKillturn;
|
||||
}
|
||||
currentGeneral = { ...currentGeneral, meta };
|
||||
worldOverlay?.syncGeneral(currentGeneral);
|
||||
}
|
||||
currentGeneral = {
|
||||
...currentGeneral,
|
||||
meta: {
|
||||
...currentGeneral.meta,
|
||||
myset: Math.min(
|
||||
9,
|
||||
(typeof currentGeneral.meta.myset === 'number' ? currentGeneral.meta.myset : 0) + 3
|
||||
),
|
||||
},
|
||||
};
|
||||
currentGeneral = {
|
||||
...currentGeneral,
|
||||
triggerState: {
|
||||
...currentGeneral.triggerState,
|
||||
flags: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result: GeneralTurnResult = {
|
||||
general: currentGeneral,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Troop,
|
||||
UnitSetDefinition,
|
||||
WorldSnapshot,
|
||||
GeneralLastTurn,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
export interface TurnWorldState {
|
||||
@@ -22,6 +23,7 @@ export interface TurnWorldState {
|
||||
export interface TurnGeneral extends General {
|
||||
turnTime: Date;
|
||||
recentWarTime?: Date | null;
|
||||
lastTurn?: GeneralLastTurn;
|
||||
}
|
||||
|
||||
export interface TurnDiplomacy {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type {
|
||||
City,
|
||||
GeneralItemSlots,
|
||||
GeneralLastTurn,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioMeta,
|
||||
@@ -40,6 +41,17 @@ type JsonRecord = Record<string, unknown>;
|
||||
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
|
||||
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
|
||||
|
||||
const normalizeGeneralLastTurn = (value: unknown): GeneralLastTurn => {
|
||||
const raw = asRecord(value);
|
||||
const arg = asRecord(raw.arg);
|
||||
return {
|
||||
command: typeof raw.command === 'string' ? raw.command : '휴식',
|
||||
...(Object.keys(arg).length > 0 ? { arg } : {}),
|
||||
...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : {}),
|
||||
...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||
if (!value || value === 'None') {
|
||||
return null;
|
||||
@@ -195,6 +207,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => {
|
||||
meta: {},
|
||||
},
|
||||
itemInventory,
|
||||
lastTurn: normalizeGeneralLastTurn(row.lastTurn),
|
||||
// meta는 상단에서 보장 처리됨.
|
||||
turnTime: row.turnTime,
|
||||
recentWarTime: row.recentWarTime ?? null,
|
||||
@@ -224,6 +237,7 @@ const mapCityRow = (row: TurnEngineCityRow): City => {
|
||||
defenceMax: row.defenceMax,
|
||||
wall: row.wall,
|
||||
wallMax: row.wallMax,
|
||||
conflict: asTriggerRecord(row.conflict),
|
||||
meta: {
|
||||
...meta,
|
||||
trust: row.trust,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||
|
||||
const start = new Date('0200-01-01T00:00:00.000Z');
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const map = {
|
||||
id: 'general-turn-test',
|
||||
name: '장수턴 테스트',
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
level: 1,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [],
|
||||
max: {
|
||||
population: 50_000,
|
||||
agriculture: 1_000,
|
||||
commerce: 1_000,
|
||||
security: 1_000,
|
||||
defence: 1_000,
|
||||
wall: 1_000,
|
||||
},
|
||||
initial: {
|
||||
population: 10_000,
|
||||
agriculture: 500,
|
||||
commerce: 500,
|
||||
security: 500,
|
||||
defence: 500,
|
||||
wall: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const makeGeneral = (patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
id: 1,
|
||||
name: '테스트장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1,
|
||||
train: 40,
|
||||
atmos: 40,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime: start,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const makeSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
trainDelta: 30,
|
||||
atmosDelta: 30,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
initialNationGenLimit: 10,
|
||||
},
|
||||
environment: { mapName: map.id, unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '장수턴 테스트',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map,
|
||||
unitSet: { id: 'test', name: 'test', crewTypes: [] },
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#000000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: null,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_중립',
|
||||
meta: { gennum: 1, tech: 0 },
|
||||
},
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 50_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 500,
|
||||
defenceMax: 1_000,
|
||||
wall: 500,
|
||||
wallMax: 1_000,
|
||||
meta: { trust: 50, trade: 100, region: 1 },
|
||||
},
|
||||
],
|
||||
generals: [general],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
});
|
||||
|
||||
const makeState = (): TurnWorldState => ({
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: start,
|
||||
meta: { killturn: 24 },
|
||||
});
|
||||
|
||||
describe('legacy general-turn execution contract', () => {
|
||||
it('runs injury recovery and troop rice consumption before the reserved command', async () => {
|
||||
const general = makeGeneral({ injury: 25, crew: 200, rice: 1 });
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
collectLogs: true,
|
||||
});
|
||||
await harness.runOneTick();
|
||||
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.injury).toBe(15);
|
||||
expect(updated.crew).toBe(0);
|
||||
expect(updated.rice).toBe(0);
|
||||
expect(harness.world.getCityById(1)!.population).toBe(10_200);
|
||||
expect(harness.getCollectedLogs().some((log) => log.text.includes('소집해제'))).toBe(true);
|
||||
});
|
||||
|
||||
it('persists pre-turn stacking and applies the inherited 60-turn cooldown', async () => {
|
||||
const general = makeGeneral({
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: 'che_격노',
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
});
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
collectLogs: true,
|
||||
});
|
||||
const turns = harness.reservedTurnStore.getGeneralTurns(1);
|
||||
turns[0] = { action: 'che_전투특기초기화', args: {} };
|
||||
turns[1] = { action: 'che_전투특기초기화', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
expect(harness.world.getGeneralById(1)!.lastTurn).toEqual({
|
||||
command: '전투 특기 초기화',
|
||||
term: 1,
|
||||
});
|
||||
expect(harness.world.getGeneralById(1)!.role.specialWar).toBe('che_격노');
|
||||
|
||||
await harness.runOneTick();
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.role.specialWar).toBeNull();
|
||||
expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460);
|
||||
expect(updated.meta.prev_types_special2).toEqual(['che_격노']);
|
||||
});
|
||||
|
||||
it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => {
|
||||
const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 });
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
collectLogs: true,
|
||||
});
|
||||
const turns = harness.reservedTurnStore.getGeneralTurns(1);
|
||||
for (let idx = 0; idx < 4; idx += 1) {
|
||||
turns[idx] = { action: 'che_전투태세', args: {} };
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < 4; idx += 1) {
|
||||
await harness.runOneTick();
|
||||
}
|
||||
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.lastTurn).toEqual({ command: '전투태세', term: 1 });
|
||||
expect(updated.experience).toBe(0);
|
||||
expect(updated.train).toBe(40);
|
||||
expect(updated.atmos).toBe(40);
|
||||
});
|
||||
|
||||
it('skips commands for blocked generals while advancing the queue once', async () => {
|
||||
const general = makeGeneral({ injury: 20, meta: { killturn: 5, block: 3 } });
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(general),
|
||||
state: makeState(),
|
||||
schedule,
|
||||
map,
|
||||
collectLogs: true,
|
||||
});
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} };
|
||||
await harness.runOneTick();
|
||||
|
||||
const updated = harness.world.getGeneralById(1)!;
|
||||
expect(updated.injury).toBe(10);
|
||||
expect(updated.experience).toBe(0);
|
||||
expect(updated.meta.killturn).toBe(4);
|
||||
expect(updated.meta.inherit_lived_month).toBe(1);
|
||||
expect(updated.meta.myset).toBe(3);
|
||||
expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식');
|
||||
expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -287,7 +287,8 @@ describe('NPC 일반 내정 턴', () => {
|
||||
const afterCity = world.getCityById(1)!;
|
||||
expect(afterCity).toMatchObject({
|
||||
...beforeStats,
|
||||
security: 1050,
|
||||
// 레거시 내정 수식과 고정 seed의 치명/실패 배율을 적용한 값.
|
||||
security: 1063,
|
||||
});
|
||||
expect(world.getGeneralById(1)!.turnTime.getTime()).toBe(addMinutes(mockDate, 10).getTime());
|
||||
});
|
||||
|
||||
@@ -306,11 +306,11 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
// Gen 2 stayed in City 1?
|
||||
expect(finalGen2.cityId).toBe(1);
|
||||
|
||||
// City 1 Agric increased (100 -> 300)
|
||||
expect(finalCity1.agriculture).toBeGreaterThanOrEqual(300);
|
||||
// 레거시 내정식은 고정 증가량이 아니라 능력치·경험·난수·치명 배율을 사용한다.
|
||||
expect(finalCity1.agriculture).toBe(206);
|
||||
|
||||
// City 1 Commerce increased (100 -> ~178)
|
||||
expect(finalCity1.commerce).toBeGreaterThanOrEqual(170);
|
||||
// 레거시 상업 투자식(지력·민심·경험·난수·치명 배율)을 고정 seed로 계산한 결과.
|
||||
expect(finalCity1.commerce).toBe(124);
|
||||
|
||||
// Gen 1 reserved turns should be shifted and empty/default
|
||||
const gen1Turns = reservedTurnStore.getGeneralTurns(1);
|
||||
@@ -422,9 +422,7 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const invalidRows = [
|
||||
{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } },
|
||||
];
|
||||
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }];
|
||||
|
||||
const mockPrisma = createMockPrisma(invalidRows);
|
||||
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
||||
@@ -585,9 +583,7 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const invalidRows = [
|
||||
{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } },
|
||||
];
|
||||
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } }];
|
||||
|
||||
const mockPrisma = createMockPrisma(invalidRows);
|
||||
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
||||
|
||||
Reference in New Issue
Block a user