merge: complete general turn compatibility
This commit is contained in:
@@ -181,6 +181,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|||||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||||
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
||||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
||||||
|
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
||||||
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
|
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
|
||||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0),
|
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0),
|
||||||
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0),
|
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0),
|
||||||
@@ -197,6 +198,9 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|||||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
||||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
|
||||||
|
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
|
||||||
|
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
||||||
|
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const buildGeneralUpdate = (
|
|||||||
personalCode: toCode(general.role.personality),
|
personalCode: toCode(general.role.personality),
|
||||||
specialCode: toCode(general.role.specialDomestic),
|
specialCode: toCode(general.role.specialDomestic),
|
||||||
special2Code: toCode(general.role.specialWar),
|
special2Code: toCode(general.role.specialWar),
|
||||||
|
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||||
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
|
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
|
||||||
turnTime: general.turnTime,
|
turnTime: general.turnTime,
|
||||||
recentWarTime: general.recentWarTime ?? null,
|
recentWarTime: general.recentWarTime ?? null,
|
||||||
@@ -180,6 +181,7 @@ const buildGeneralCreate = (
|
|||||||
personalCode: toCode(general.role.personality),
|
personalCode: toCode(general.role.personality),
|
||||||
specialCode: toCode(general.role.specialDomestic),
|
specialCode: toCode(general.role.specialDomestic),
|
||||||
special2Code: toCode(general.role.specialWar),
|
special2Code: toCode(general.role.specialWar),
|
||||||
|
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||||
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
|
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
|
||||||
turnTime: general.turnTime,
|
turnTime: general.turnTime,
|
||||||
recentWarTime: general.recentWarTime ?? null,
|
recentWarTime: general.recentWarTime ?? null,
|
||||||
@@ -214,6 +216,7 @@ const buildCityUpdate = (
|
|||||||
defenceMax: city.defenceMax,
|
defenceMax: city.defenceMax,
|
||||||
wall: city.wall,
|
wall: city.wall,
|
||||||
wallMax: city.wallMax,
|
wallMax: city.wallMax,
|
||||||
|
...(city.conflict ? { conflict: asJson(city.conflict) } : {}),
|
||||||
meta: asJson(meta),
|
meta: asJson(meta),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
||||||
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
||||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], DEFAULT_ATMOS_DELTA),
|
atmosDelta: resolveNumber(constValues, ['atmosDelta'], DEFAULT_ATMOS_DELTA),
|
||||||
|
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
||||||
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], DEFAULT_MAX_TRAIN_BY_COMMAND),
|
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], DEFAULT_MAX_TRAIN_BY_COMMAND),
|
||||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_MAX_ATMOS_BY_COMMAND),
|
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_MAX_ATMOS_BY_COMMAND),
|
||||||
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], DEFAULT_SABOTAGE_PROB),
|
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], DEFAULT_SABOTAGE_PROB),
|
||||||
@@ -95,6 +96,9 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
|
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
|
||||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
|
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),
|
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
||||||
maxResourceActionAmount: resolveNumber(
|
maxResourceActionAmount: resolveNumber(
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import type {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TURN_COMMAND_PROFILE,
|
DEFAULT_TURN_COMMAND_PROFILE,
|
||||||
GeneralTurnCommandLoader,
|
GeneralTurnCommandLoader,
|
||||||
|
GeneralActionPipeline,
|
||||||
NationTurnCommandLoader,
|
NationTurnCommandLoader,
|
||||||
|
createGeneralTriggerContext,
|
||||||
defaultActionContextBuilder,
|
defaultActionContextBuilder,
|
||||||
evaluateConstraints,
|
evaluateConstraints,
|
||||||
resolveGeneralAction,
|
resolveGeneralAction,
|
||||||
@@ -101,7 +103,7 @@ const serializeSeed = (...values: Array<string | number>): string =>
|
|||||||
|
|
||||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||||
|
|
||||||
type NationLastTurn = {
|
type LegacyLastTurn = {
|
||||||
command: string;
|
command: string;
|
||||||
arg?: Record<string, unknown>;
|
arg?: Record<string, unknown>;
|
||||||
term?: number;
|
term?: number;
|
||||||
@@ -110,7 +112,7 @@ type NationLastTurn = {
|
|||||||
|
|
||||||
const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`;
|
const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`;
|
||||||
|
|
||||||
const normalizeLastTurn = (value: unknown): NationLastTurn => {
|
const normalizeLastTurn = (value: unknown): LegacyLastTurn => {
|
||||||
const raw = asRecord(value);
|
const raw = asRecord(value);
|
||||||
return {
|
return {
|
||||||
command: typeof raw.command === 'string' ? raw.command : '휴식',
|
command: typeof raw.command === 'string' ? raw.command : '휴식',
|
||||||
@@ -135,6 +137,18 @@ const readNextAvailableTurn = (nation: Nation, actionName: string): number | nul
|
|||||||
return null;
|
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 readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||||
const value = meta[key];
|
const value = meta[key];
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -631,7 +645,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
definitionMap: Map<string, GeneralActionDefinition>,
|
definitionMap: Map<string, GeneralActionDefinition>,
|
||||||
fallbackDefinition: GeneralActionDefinition,
|
fallbackDefinition: GeneralActionDefinition,
|
||||||
command: ReservedTurnEntry,
|
command: ReservedTurnEntry,
|
||||||
applyNextTurnAt: boolean
|
applyNextTurnAt: boolean,
|
||||||
|
alternativeDepth = 0
|
||||||
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
||||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
||||||
const rawArgs = extractArgsRecord(command.args);
|
const rawArgs = extractArgsRecord(command.args);
|
||||||
@@ -680,9 +695,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined;
|
const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined;
|
||||||
logs.push(createActionLog(reason, meta));
|
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 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) {
|
if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) {
|
||||||
const remainTurn = nextAvailableTurn - currentYearMonth;
|
const remainTurn = nextAvailableTurn - currentYearMonth;
|
||||||
definition = fallbackDefinition;
|
definition = fallbackDefinition;
|
||||||
@@ -698,10 +716,11 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const buildRng = (key: string) => {
|
const buildRng = (key: string) => {
|
||||||
const rngSeed = serializeSeed(
|
const rngSeed = serializeSeed(
|
||||||
seedBase,
|
seedBase,
|
||||||
key,
|
kind === 'general' ? 'generalCommand' : 'nationCommand',
|
||||||
context.world.currentYear,
|
context.world.currentYear,
|
||||||
context.world.currentMonth,
|
context.world.currentMonth,
|
||||||
currentGeneral.id
|
currentGeneral.id,
|
||||||
|
key
|
||||||
);
|
);
|
||||||
return new RandUtil(new LiteHashDRBG(rngSeed));
|
return new RandUtil(new LiteHashDRBG(rngSeed));
|
||||||
};
|
};
|
||||||
@@ -759,19 +778,27 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
getPreReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||||
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
getPostReqTurn?: (context: ActionContextBase, args: unknown) => number;
|
||||||
getStackSequence?: (context: ActionContextBase, args: unknown) => number | null;
|
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 =
|
const preReqTurn = !usedFallback
|
||||||
kind === 'nation' && !usedFallback
|
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||||
? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0))
|
: 0;
|
||||||
: 0;
|
const postReqTurn = !usedFallback
|
||||||
const postReqTurn =
|
? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0))
|
||||||
kind === 'nation' && !usedFallback
|
: 0;
|
||||||
? 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 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 stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null;
|
||||||
const sequenceChanged =
|
const sequenceChanged =
|
||||||
stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence);
|
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;
|
const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1;
|
||||||
|
|
||||||
if (!continuing || (lastTurn.term ?? 0) < preReqTurn) {
|
if (!continuing || (lastTurn.term ?? 0) < preReqTurn) {
|
||||||
const nextLastTurn: NationLastTurn = {
|
const nextLastTurn: LegacyLastTurn = {
|
||||||
command: definition.name,
|
command: definition.name,
|
||||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||||
term: nextTerm,
|
term: nextTerm,
|
||||||
...(stackSequence !== null ? { seq: stackSequence } : undefined),
|
...(stackSequence !== null ? { seq: stackSequence } : undefined),
|
||||||
};
|
};
|
||||||
const nextNation: Nation = {
|
if (kind === 'general') {
|
||||||
...currentNation,
|
currentGeneral = {
|
||||||
meta: {
|
...currentGeneral,
|
||||||
...currentNation.meta,
|
lastTurn: nextLastTurn,
|
||||||
[metaKey]: nextLastTurn,
|
};
|
||||||
} as Nation['meta'],
|
worldOverlay?.syncGeneral(currentGeneral);
|
||||||
};
|
} else {
|
||||||
currentNation = nextNation;
|
const nextNation: Nation = {
|
||||||
worldOverlay?.syncNation(nextNation);
|
...currentNation!,
|
||||||
logs.push(createActionLog(`${definition.name} 수행중... (${nextTerm}/${preReqTurn + 1})`));
|
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 };
|
return { actionKey, usedFallback, blockedReason };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
|
||||||
const resolution = resolveGeneralAction(
|
const resolution = resolveGeneralAction(
|
||||||
definition,
|
definition,
|
||||||
actionContext,
|
actionContext,
|
||||||
@@ -815,19 +855,59 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
currentGeneral = resolution.general as TurnGeneral;
|
currentGeneral = resolution.general as TurnGeneral;
|
||||||
currentCity = resolution.city ?? currentCity;
|
currentCity = resolution.city ?? currentCity;
|
||||||
currentNation = resolution.nation ?? currentNation;
|
currentNation = resolution.nation ?? currentNation;
|
||||||
if (kind === 'nation' && !usedFallback && definition.countsAsInheritanceActiveAction) {
|
if (
|
||||||
|
!resolution.alternative &&
|
||||||
|
kind === 'nation' &&
|
||||||
|
!usedFallback &&
|
||||||
|
definition.countsAsInheritanceActiveAction
|
||||||
|
) {
|
||||||
const meta = { ...currentGeneral.meta };
|
const meta = { ...currentGeneral.meta };
|
||||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
||||||
meta.inherit_active_action = active + 1;
|
meta.inherit_active_action = active + 1;
|
||||||
currentGeneral = { ...currentGeneral, meta };
|
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) {
|
if (!currentNation && resolution.created?.nations) {
|
||||||
currentNation =
|
currentNation =
|
||||||
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
|
(resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ??
|
||||||
currentNation;
|
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 metaKey = nationLastTurnKey(currentGeneral.officerLevel);
|
||||||
const nextMeta: Record<string, unknown> = {
|
const nextMeta: Record<string, unknown> = {
|
||||||
...currentNation.meta,
|
...currentNation.meta,
|
||||||
@@ -835,7 +915,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
command: definition.name,
|
command: definition.name,
|
||||||
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined),
|
||||||
term: 0,
|
term: 0,
|
||||||
} satisfies NationLastTurn,
|
} satisfies LegacyLastTurn,
|
||||||
};
|
};
|
||||||
if (postReqTurn > 0) {
|
if (postReqTurn > 0) {
|
||||||
nextMeta[`next_execute_${definition.name}`] =
|
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 {
|
return {
|
||||||
nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined,
|
nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined,
|
||||||
actionKey,
|
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(
|
let nationCommand = options.reservedTurns.getNationTurn(
|
||||||
currentNation.id,
|
currentNation.id,
|
||||||
currentGeneral.officerLevel,
|
currentGeneral.officerLevel,
|
||||||
@@ -1003,9 +1191,13 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
});
|
});
|
||||||
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
|
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 generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
|
||||||
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
|
let generalAiState: ReturnType<GeneralAI['getDebugState']> | undefined;
|
||||||
|
let generalAutorunMode = false;
|
||||||
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
if (worldView && shouldUseAi(currentGeneral, context.world)) {
|
||||||
const ai = new GeneralAI({
|
const ai = new GeneralAI({
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
@@ -1026,11 +1218,20 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
});
|
});
|
||||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||||
if (candidate) {
|
if (candidate) {
|
||||||
|
generalAutorunMode =
|
||||||
|
candidate.action !== generalCommand.action ||
|
||||||
|
JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {});
|
||||||
generalCommand = { action: candidate.action, args: candidate.args };
|
generalCommand = { action: candidate.action, args: candidate.args };
|
||||||
}
|
}
|
||||||
generalAiState = ai.getDebugState();
|
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?.({
|
options.onActionResolved?.({
|
||||||
kind: 'general',
|
kind: 'general',
|
||||||
generalId: currentGeneral.id,
|
generalId: currentGeneral.id,
|
||||||
@@ -1044,20 +1245,42 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const nextTurnAt = generalResult.nextTurnAt;
|
const nextTurnAt = generalResult.nextTurnAt;
|
||||||
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
|
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
|
||||||
|
|
||||||
const worldMeta = asRecord(context.world.meta);
|
if (!isBlocked) {
|
||||||
if (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) {
|
|
||||||
const meta = { ...currentGeneral.meta };
|
const meta = { ...currentGeneral.meta };
|
||||||
const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0;
|
const currentKillturn =
|
||||||
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
|
typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0;
|
||||||
meta.inherit_lived_month = lived + 1;
|
const worldKillturn = readMetaNumber(asRecord(context.world.meta), 'killturn', currentKillturn);
|
||||||
if (generalResult.actionKey !== DEFAULT_ACTION) {
|
const requestedRest = generalCommand.action === DEFAULT_ACTION;
|
||||||
meta.inherit_active_action = active + 1;
|
if (
|
||||||
|
currentGeneral.npcState >= 2 ||
|
||||||
|
currentKillturn > worldKillturn ||
|
||||||
|
generalAutorunMode ||
|
||||||
|
requestedRest
|
||||||
|
) {
|
||||||
|
meta.killturn = Math.max(0, currentKillturn - 1);
|
||||||
} else {
|
} else {
|
||||||
meta.inherit_active_action = active;
|
meta.killturn = worldKillturn;
|
||||||
}
|
}
|
||||||
currentGeneral = { ...currentGeneral, meta };
|
currentGeneral = { ...currentGeneral, meta };
|
||||||
worldOverlay?.syncGeneral(currentGeneral);
|
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 = {
|
const result: GeneralTurnResult = {
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
Troop,
|
Troop,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
|
GeneralLastTurn,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
export interface TurnWorldState {
|
export interface TurnWorldState {
|
||||||
@@ -22,6 +23,7 @@ export interface TurnWorldState {
|
|||||||
export interface TurnGeneral extends General {
|
export interface TurnGeneral extends General {
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
recentWarTime?: Date | null;
|
recentWarTime?: Date | null;
|
||||||
|
lastTurn?: GeneralLastTurn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDiplomacy {
|
export interface TurnDiplomacy {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
City,
|
City,
|
||||||
GeneralItemSlots,
|
GeneralItemSlots,
|
||||||
|
GeneralLastTurn,
|
||||||
Nation,
|
Nation,
|
||||||
ScenarioConfig,
|
ScenarioConfig,
|
||||||
ScenarioMeta,
|
ScenarioMeta,
|
||||||
@@ -40,6 +41,17 @@ type JsonRecord = Record<string, unknown>;
|
|||||||
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
|
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
|
||||||
isRecord(value) ? (value as 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 => {
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||||
if (!value || value === 'None') {
|
if (!value || value === 'None') {
|
||||||
return null;
|
return null;
|
||||||
@@ -195,6 +207,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
itemInventory,
|
itemInventory,
|
||||||
|
lastTurn: normalizeGeneralLastTurn(row.lastTurn),
|
||||||
// meta는 상단에서 보장 처리됨.
|
// meta는 상단에서 보장 처리됨.
|
||||||
turnTime: row.turnTime,
|
turnTime: row.turnTime,
|
||||||
recentWarTime: row.recentWarTime ?? null,
|
recentWarTime: row.recentWarTime ?? null,
|
||||||
@@ -224,6 +237,7 @@ const mapCityRow = (row: TurnEngineCityRow): City => {
|
|||||||
defenceMax: row.defenceMax,
|
defenceMax: row.defenceMax,
|
||||||
wall: row.wall,
|
wall: row.wall,
|
||||||
wallMax: row.wallMax,
|
wallMax: row.wallMax,
|
||||||
|
conflict: asTriggerRecord(row.conflict),
|
||||||
meta: {
|
meta: {
|
||||||
...meta,
|
...meta,
|
||||||
trust: row.trust,
|
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)!;
|
const afterCity = world.getCityById(1)!;
|
||||||
expect(afterCity).toMatchObject({
|
expect(afterCity).toMatchObject({
|
||||||
...beforeStats,
|
...beforeStats,
|
||||||
security: 1050,
|
// 레거시 내정 수식과 고정 seed의 치명/실패 배율을 적용한 값.
|
||||||
|
security: 1063,
|
||||||
});
|
});
|
||||||
expect(world.getGeneralById(1)!.turnTime.getTime()).toBe(addMinutes(mockDate, 10).getTime());
|
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?
|
// Gen 2 stayed in City 1?
|
||||||
expect(finalGen2.cityId).toBe(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)
|
// 레거시 상업 투자식(지력·민심·경험·난수·치명 배율)을 고정 seed로 계산한 결과.
|
||||||
expect(finalCity1.commerce).toBeGreaterThanOrEqual(170);
|
expect(finalCity1.commerce).toBe(124);
|
||||||
|
|
||||||
// Gen 1 reserved turns should be shifted and empty/default
|
// Gen 1 reserved turns should be shifted and empty/default
|
||||||
const gen1Turns = reservedTurnStore.getGeneralTurns(1);
|
const gen1Turns = reservedTurnStore.getGeneralTurns(1);
|
||||||
@@ -422,9 +422,7 @@ describe('Reserved Turn Execution Integration', () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const invalidRows = [
|
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }];
|
||||||
{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockPrisma = createMockPrisma(invalidRows);
|
const mockPrisma = createMockPrisma(invalidRows);
|
||||||
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
||||||
@@ -585,9 +583,7 @@ describe('Reserved Turn Execution Integration', () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const invalidRows = [
|
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } }];
|
||||||
{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockPrisma = createMockPrisma(invalidRows);
|
const mockPrisma = createMockPrisma(invalidRows);
|
||||||
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"build:server": "pnpm --filter ./tools/build-scripts build:server --",
|
"build:server": "pnpm --filter ./tools/build-scripts build:server --",
|
||||||
"generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas",
|
"generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas",
|
||||||
"validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources",
|
"validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources",
|
||||||
"check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check"
|
"check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check",
|
||||||
|
"check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface TurnEngineGeneralRow {
|
|||||||
atmos: number;
|
atmos: number;
|
||||||
age: number;
|
age: number;
|
||||||
npcState: number;
|
npcState: number;
|
||||||
|
lastTurn: JsonValue;
|
||||||
meta: JsonValue;
|
meta: JsonValue;
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
recentWarTime: Date | null;
|
recentWarTime: Date | null;
|
||||||
@@ -66,6 +67,7 @@ export interface TurnEngineCityRow {
|
|||||||
defenceMax: number;
|
defenceMax: number;
|
||||||
wall: number;
|
wall: number;
|
||||||
wallMax: number;
|
wallMax: number;
|
||||||
|
conflict: JsonValue;
|
||||||
meta: JsonValue;
|
meta: JsonValue;
|
||||||
trust: number;
|
trust: number;
|
||||||
trade: number;
|
trade: number;
|
||||||
@@ -166,6 +168,7 @@ export interface TurnEngineGeneralUpdateInput {
|
|||||||
personalCode: string;
|
personalCode: string;
|
||||||
specialCode: string;
|
specialCode: string;
|
||||||
special2Code: string;
|
special2Code: string;
|
||||||
|
lastTurn: InputJsonValue;
|
||||||
meta: InputJsonValue;
|
meta: InputJsonValue;
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
recentWarTime: Date | null;
|
recentWarTime: Date | null;
|
||||||
@@ -228,6 +231,7 @@ export interface TurnEngineCityUpdateInput {
|
|||||||
defenceMax: number;
|
defenceMax: number;
|
||||||
wall: number;
|
wall: number;
|
||||||
wallMax: number;
|
wallMax: number;
|
||||||
|
conflict?: InputJsonValue;
|
||||||
meta: InputJsonValue;
|
meta: InputJsonValue;
|
||||||
trust?: number;
|
trust?: number;
|
||||||
trade?: number;
|
trade?: number;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface GeneralActionDefinition<
|
|||||||
getPreReqTurn?(context: Context, args: Args): number;
|
getPreReqTurn?(context: Context, args: Args): number;
|
||||||
getPostReqTurn?(context: Context, args: Args): number;
|
getPostReqTurn?(context: Context, args: Args): number;
|
||||||
getStackSequence?(context: Context, args: Args): number | null;
|
getStackSequence?(context: Context, args: Args): number | null;
|
||||||
|
getProgressText?(context: Context, args: Args, term: number, termMax: number): string;
|
||||||
readonly countsAsInheritanceActiveAction?: boolean;
|
readonly countsAsInheritanceActiveAction?: boolean;
|
||||||
|
getInheritanceActiveActionAmount?(context: Context, args: Args): number;
|
||||||
resolve(context: Context, args: Args): GeneralActionOutcome<TriggerState>;
|
resolve(context: Context, args: Args): GeneralActionOutcome<TriggerState>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ActionContextWorldState {
|
|||||||
currentYear: number;
|
currentYear: number;
|
||||||
currentMonth: number;
|
currentMonth: number;
|
||||||
tickSeconds: number;
|
tickSeconds: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionContextWorldRef {
|
export interface ActionContextWorldRef {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface TurnCommandEnv {
|
|||||||
minAvailableRecruitPop?: number;
|
minAvailableRecruitPop?: number;
|
||||||
trainDelta: number;
|
trainDelta: number;
|
||||||
atmosDelta: number;
|
atmosDelta: number;
|
||||||
|
trainSideEffectByAtmosTurn?: number;
|
||||||
maxTrainByCommand: number;
|
maxTrainByCommand: number;
|
||||||
maxAtmosByCommand: number;
|
maxAtmosByCommand: number;
|
||||||
sabotageDefaultProb: number;
|
sabotageDefaultProb: number;
|
||||||
@@ -35,6 +36,9 @@ export interface TurnCommandEnv {
|
|||||||
defaultSpecialWar: string | null;
|
defaultSpecialWar: string | null;
|
||||||
initialNationGenLimit: number;
|
initialNationGenLimit: number;
|
||||||
maxTechLevel: number;
|
maxTechLevel: number;
|
||||||
|
maxStatLevel?: number;
|
||||||
|
techLevelIncYear?: number;
|
||||||
|
initialAllowedTechLevel?: number;
|
||||||
baseGold: number;
|
baseGold: number;
|
||||||
baseRice: number;
|
baseRice: number;
|
||||||
maxResourceActionAmount: number;
|
maxResourceActionAmount: number;
|
||||||
|
|||||||
@@ -1,11 +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 { notSameDestCity, nearCity, reqGeneralGold, reqGeneralRice } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
notSameDestCity,
|
|
||||||
nearCity,
|
|
||||||
reqGeneralGold,
|
|
||||||
reqGeneralRice,
|
|
||||||
} 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 {
|
||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
@@ -13,8 +8,8 @@ import type {
|
|||||||
GeneralActionResolver,
|
GeneralActionResolver,
|
||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
@@ -125,16 +120,6 @@ export class ActionResolver<
|
|||||||
if (isRoamingLeader && moveTargets.length > 1) {
|
if (isRoamingLeader && moveTargets.length > 1) {
|
||||||
for (const target of moveTargets) {
|
for (const target of moveTargets) {
|
||||||
if (target.id === general.id) continue;
|
if (target.id === general.id) continue;
|
||||||
|
|
||||||
// Legacy: "방랑군 세력이 <G><b>{$destCityName}</b></>{$josaRo} 강행했습니다." (LOG_PLAIN)
|
|
||||||
// NOTE: We need a way to push log to OTHER general's logger.
|
|
||||||
// In current engine, we return effects. Log effects?
|
|
||||||
// Currently addLog attaches provided logs to turnLog (which is for the actor).
|
|
||||||
// To log for OTHERS, we might need specific effect or handle it differently.
|
|
||||||
// For now, I will omit logs for others or use a special effect if available.
|
|
||||||
// The legacy TS porting pattern for "others" logs isn't fully standardized yet in shared snippets.
|
|
||||||
// Assuming createGeneralPatchEffect handles state. Logs for others might be missing in this iteration unless I find `createLogEffect`.
|
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
@@ -142,7 +127,13 @@ export class ActionResolver<
|
|||||||
cityId: destCityId,
|
cityId: destCityId,
|
||||||
},
|
},
|
||||||
target.id
|
target.id
|
||||||
)
|
),
|
||||||
|
createLogEffect(`방랑군 세력이 <G><b>${destCityName}</b></>${josaRo} 강행했습니다.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
generalId: target.id,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,11 +179,12 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
|
const rawCost = options.scenarioConfig.const.develCost ?? options.scenarioConfig.const.develcost;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
moveGenerals: options.worldRef?.listGenerals() ?? [],
|
moveGenerals: options.worldRef?.listGenerals() ?? [],
|
||||||
map: options.map,
|
map: options.map,
|
||||||
startDevelCost: options.scenarioConfig.const.develCost as number | undefined,
|
startDevelCost: typeof rawCost === 'number' ? rawCost : 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState, Nation } 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 { allowJoinAction, beNeutral, beOpeningPart, noPenalty } from '@sammo-ts/logic/constraints/presets.js';
|
import { allowJoinAction, beNeutral, beOpeningPart, noPenalty } 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 { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type {
|
||||||
|
GeneralActionEffect,
|
||||||
|
GeneralActionOutcome,
|
||||||
|
GeneralActionResolveContext,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import {
|
import {
|
||||||
createGeneralPatchEffect,
|
createGeneralPatchEffect,
|
||||||
|
createDiplomacyPatchEffect,
|
||||||
createNationAddEffect,
|
createNationAddEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
@@ -13,11 +19,13 @@ import type { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/ac
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export interface UprisingArgs { }
|
export interface UprisingArgs {}
|
||||||
|
|
||||||
export interface UprisingContext extends ActionContextBase {
|
export interface UprisingContext extends ActionContextBase {
|
||||||
createNationId: () => number;
|
createNationId: () => number;
|
||||||
listNations?: () => Nation[];
|
listNations?: () => Nation[];
|
||||||
|
scenarioId: number;
|
||||||
|
baseRice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '거병';
|
const ACTION_NAME = '거병';
|
||||||
@@ -27,6 +35,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, UprisingArgs> {
|
> implements GeneralActionDefinition<TriggerState, UprisingArgs> {
|
||||||
public readonly key = 'che_거병';
|
public readonly key = 'che_거병';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): UprisingArgs | null {
|
parseArgs(_raw: unknown): UprisingArgs | null {
|
||||||
return {};
|
return {};
|
||||||
@@ -48,7 +59,7 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newNationId = uprisingCtx.createNationId();
|
const newNationId = uprisingCtx.createNationId();
|
||||||
const josaYi = '이'; // Mock JodaUtil.pick
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
|
|
||||||
let nationName = general.name;
|
let nationName = general.name;
|
||||||
const nations = uprisingCtx.listNations ? uprisingCtx.listNations() : [];
|
const nations = uprisingCtx.listNations ? uprisingCtx.listNations() : [];
|
||||||
@@ -80,14 +91,14 @@ export class ActionDefinition<
|
|||||||
capitalCityId: null,
|
capitalCityId: null,
|
||||||
chiefGeneralId: general.id,
|
chiefGeneralId: general.id,
|
||||||
gold: 0,
|
gold: 0,
|
||||||
rice: 2000,
|
rice: uprisingCtx.baseRice,
|
||||||
power: 0,
|
power: 0,
|
||||||
meta: {
|
meta: {
|
||||||
rate: 20,
|
rate: 20,
|
||||||
bill: 100,
|
bill: 100,
|
||||||
strategic_cmd_limit: 12,
|
strategic_cmd_limit: 12,
|
||||||
surlimit: 72,
|
surlimit: 72,
|
||||||
secretlimit: 3,
|
secretlimit: uprisingCtx.scenarioId >= 1000 ? 1 : 3,
|
||||||
gennum: 1,
|
gennum: 1,
|
||||||
...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}),
|
...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}),
|
||||||
},
|
},
|
||||||
@@ -118,25 +129,45 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
const effects = [
|
const effects: GeneralActionEffect<TriggerState>[] = [
|
||||||
createNationAddEffect(newNation),
|
createNationAddEffect(newNation),
|
||||||
createGeneralPatchEffect<TriggerState>({
|
createGeneralPatchEffect<TriggerState>({
|
||||||
nationId: newNationId,
|
nationId: newNationId,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
experience: (general.experience || 0) + 100,
|
experience: (general.experience || 0) + 100,
|
||||||
dedication: (general.dedication || 0) + 100,
|
dedication: (general.dedication || 0) + 100,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
belong: 1,
|
||||||
|
officer_city: 0,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
for (const nation of nations) {
|
||||||
|
if (nation.id === newNationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
effects.push(
|
||||||
|
createDiplomacyPatchEffect(nation.id, newNationId, { state: 2, term: 0 }),
|
||||||
|
createDiplomacyPatchEffect(newNationId, nation.id, { state: 2, term: 0 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
|
const worldMeta = asRecord(options.world.meta);
|
||||||
|
const constValues = asRecord(options.scenarioConfig.const);
|
||||||
|
const scenarioRaw = worldMeta.scenarioId ?? worldMeta.scenario;
|
||||||
|
const baseRiceRaw = constValues.baseRice ?? constValues.baserice;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
createNationId: options.createNationId,
|
createNationId: options.createNationId,
|
||||||
listNations: () => options.worldRef?.listNations() ?? [],
|
listNations: () => options.worldRef?.listNations() ?? [],
|
||||||
|
scenarioId: typeof scenarioRaw === 'number' && Number.isFinite(scenarioRaw) ? scenarioRaw : 0,
|
||||||
|
baseRice: typeof baseRiceRaw === 'number' && Number.isFinite(baseRiceRaw) ? baseRiceRaw : 2_000,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,13 +76,20 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
||||||
public readonly key = 'che_건국';
|
public readonly key = 'che_건국';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): FoundingArgs | null {
|
parseArgs(raw: unknown): FoundingArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
|
||||||
return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.'), noPenalty('noFoundNation')];
|
return [
|
||||||
|
beOpeningPart(),
|
||||||
|
reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.'),
|
||||||
|
noPenalty('noFoundNation'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { parseArgsWithSchema } from '../parseArgs.js';
|
|||||||
|
|
||||||
export interface TradeEnvironment {
|
export interface TradeEnvironment {
|
||||||
exchangeFee?: number;
|
exchangeFee?: number;
|
||||||
|
maxResourceActionAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '군량매매';
|
const ACTION_NAME = '군량매매';
|
||||||
@@ -41,7 +42,13 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): TradeArgs | null {
|
parseArgs(raw: unknown): TradeArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
|
if (!parsed || !Number.isFinite(parsed.amount)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const maxAmount = this.env.maxResourceActionAmount ?? 10_000;
|
||||||
|
const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount));
|
||||||
|
return { buyRice: parsed.buyRice, amount };
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: TradeArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: TradeArgs): Constraint[] {
|
||||||
@@ -69,13 +76,13 @@ export class ActionDefinition<
|
|||||||
const rate = tradeRate / 100;
|
const rate = tradeRate / 100;
|
||||||
const fee = this.env.exchangeFee ?? DEFAULT_EXCHANGE_FEE;
|
const fee = this.env.exchangeFee ?? DEFAULT_EXCHANGE_FEE;
|
||||||
|
|
||||||
let buyAmount = 0;
|
let tax: number;
|
||||||
let sellAmount = 0;
|
|
||||||
let tax = 0;
|
|
||||||
|
|
||||||
if (args.buyRice) {
|
if (args.buyRice) {
|
||||||
const requestedSell = Math.min(args.amount * rate, general.gold);
|
const requestedSell = Math.min(args.amount * rate, general.gold);
|
||||||
tax = requestedSell * fee;
|
tax = requestedSell * fee;
|
||||||
|
let sellAmount: number;
|
||||||
|
let buyAmount: number;
|
||||||
if (requestedSell + tax > general.gold) {
|
if (requestedSell + tax > general.gold) {
|
||||||
sellAmount = general.gold;
|
sellAmount = general.gold;
|
||||||
tax = sellAmount * (fee / (1 + fee));
|
tax = sellAmount * (fee / (1 + fee));
|
||||||
@@ -85,8 +92,8 @@ export class ActionDefinition<
|
|||||||
sellAmount = requestedSell + tax;
|
sellAmount = requestedSell + tax;
|
||||||
buyAmount = args.amount;
|
buyAmount = args.amount;
|
||||||
}
|
}
|
||||||
general.gold = Math.max(0, general.gold - sellAmount);
|
general.gold = Math.max(0, Math.round(general.gold - sellAmount));
|
||||||
general.rice += buyAmount;
|
general.rice = Math.round(general.rice + buyAmount);
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`군량 <C>${Math.round(buyAmount).toLocaleString()}</>을 사서 자금 <C>${Math.round(
|
`군량 <C>${Math.round(buyAmount).toLocaleString()}</>을 사서 자금 <C>${Math.round(
|
||||||
sellAmount
|
sellAmount
|
||||||
@@ -94,12 +101,12 @@ export class ActionDefinition<
|
|||||||
{ format: LogFormat.PLAIN }
|
{ format: LogFormat.PLAIN }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
sellAmount = Math.min(args.amount, general.rice);
|
const sellAmount = Math.min(args.amount, general.rice);
|
||||||
const grossBuy = sellAmount * rate;
|
const grossBuy = sellAmount * rate;
|
||||||
tax = grossBuy * fee;
|
tax = grossBuy * fee;
|
||||||
buyAmount = grossBuy - tax;
|
const buyAmount = grossBuy - tax;
|
||||||
general.rice = Math.max(0, general.rice - sellAmount);
|
general.rice = Math.max(0, Math.round(general.rice - sellAmount));
|
||||||
general.gold += buyAmount;
|
general.gold = Math.round(general.gold + buyAmount);
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`군량 <C>${Math.round(sellAmount).toLocaleString()}</>을 팔아 자금 <C>${Math.round(
|
`군량 <C>${Math.round(sellAmount).toLocaleString()}</>을 팔아 자금 <C>${Math.round(
|
||||||
buyAmount
|
buyAmount
|
||||||
@@ -112,12 +119,30 @@ export class ActionDefinition<
|
|||||||
if (context.nation) {
|
if (context.nation) {
|
||||||
const nation = context.nation;
|
const nation = context.nation;
|
||||||
const currentGold = (nation.gold as number) ?? 0;
|
const currentGold = (nation.gold as number) ?? 0;
|
||||||
nation.gold = currentGold + tax;
|
nation.gold = currentGold + Math.trunc(tax);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 경험치 및 명성 증가
|
// 경험치 및 명성 증가
|
||||||
general.experience += 30;
|
general.experience += 30;
|
||||||
general.dedication += 50;
|
general.dedication += 50;
|
||||||
|
const weightedStats = [
|
||||||
|
['leadership_exp', general.stats.leadership],
|
||||||
|
['strength_exp', general.stats.strength],
|
||||||
|
['intel_exp', general.stats.intelligence],
|
||||||
|
] as const;
|
||||||
|
const totalWeight = weightedStats.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
|
||||||
|
let pick = context.rng.nextFloat1() * totalWeight;
|
||||||
|
let statKey: (typeof weightedStats)[number][0] = weightedStats[weightedStats.length - 1]![0];
|
||||||
|
for (const [key, weight] of weightedStats) {
|
||||||
|
if (pick <= Math.max(0, weight)) {
|
||||||
|
statKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pick -= Math.max(0, weight);
|
||||||
|
}
|
||||||
|
const statRaw = general.meta[statKey];
|
||||||
|
const statExp = typeof statRaw === 'number' ? statRaw : 0;
|
||||||
|
general.meta[statKey] = statExp + 1;
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
@@ -136,5 +161,8 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
amount: 'number',
|
amount: 'number',
|
||||||
},
|
},
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: ARGS_SCHEMA,
|
||||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
createDefinition: (env: TurnCommandEnv) =>
|
||||||
|
new ActionDefinition({
|
||||||
|
maxResourceActionAmount: env.maxResourceActionAmount,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
notWanderingNation,
|
notWanderingNation,
|
||||||
@@ -9,106 +9,153 @@ import {
|
|||||||
suppliedCity,
|
suppliedCity,
|
||||||
} 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 { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome } from '@sammo-ts/logic/actions/engine.js';
|
||||||
|
import type {
|
||||||
|
ActionContextBase,
|
||||||
|
ActionContextBuilder,
|
||||||
|
ActionContextOptions,
|
||||||
|
} from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import {
|
||||||
|
buildDomesticContextFromView,
|
||||||
|
CommandResolver,
|
||||||
|
type DomesticActionContext,
|
||||||
|
type InvestmentConfig,
|
||||||
|
} from './che_상업투자.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
import { clamp } from 'es-toolkit';
|
||||||
|
|
||||||
export interface TechResearchArgs {}
|
export interface TechResearchArgs {}
|
||||||
|
|
||||||
export interface TechResearchEnvironment {
|
interface TechResearchContext<
|
||||||
costGold?: number;
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
techDelta?: number;
|
> extends DomesticActionContext<TriggerState> {
|
||||||
maxTechLevel?: number;
|
nation: Nation;
|
||||||
|
nationGeneralCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '기술 연구';
|
const ACTION_NAME = '기술 연구';
|
||||||
const DEFAULT_TECH_DELTA = 1;
|
const CONFIG: InvestmentConfig = {
|
||||||
|
key: 'che_기술연구',
|
||||||
|
name: ACTION_NAME,
|
||||||
|
actionKey: '기술',
|
||||||
|
statKey: 'intelligence',
|
||||||
|
statExpKey: 'intel_exp',
|
||||||
|
cityKey: 'commerce',
|
||||||
|
cityMaxKey: 'commerceMax',
|
||||||
|
frontDebuff: 1,
|
||||||
|
};
|
||||||
|
|
||||||
const readTech = (nation: Nation | null | undefined): number => {
|
const readTech = (nation: Nation): number => {
|
||||||
if (!nation) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const tech = nation.meta.tech;
|
const tech = nation.meta.tech;
|
||||||
return typeof tech === 'number' ? tech : 0;
|
return typeof tech === 'number' && Number.isFinite(tech) ? tech : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, TechResearchArgs> {
|
> implements GeneralActionDefinition<TriggerState, TechResearchArgs, TechResearchContext<TriggerState>> {
|
||||||
public readonly key = 'che_기술연구';
|
public readonly key = CONFIG.key;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly env: TechResearchEnvironment;
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(env: TechResearchEnvironment = {}) {
|
constructor(private readonly env: TurnCommandEnv) {
|
||||||
this.env = env;
|
this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): TechResearchArgs | null {
|
parseArgs(_raw: unknown): TechResearchArgs | null {
|
||||||
void _raw;
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: TechResearchArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: TechResearchArgs): Constraint[] {
|
||||||
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.costGold ?? 0;
|
const requirements: RequirementKey[] = [];
|
||||||
|
if (ctx.cityId !== undefined) requirements.push({ kind: 'city', id: ctx.cityId });
|
||||||
|
if (ctx.nationId !== undefined) requirements.push({ kind: 'nation', id: ctx.nationId });
|
||||||
|
const getCost = (context: ConstraintContext, view: StateView): number => {
|
||||||
|
const domesticContext = buildDomesticContextFromView<TriggerState>(context, view);
|
||||||
|
return domesticContext ? this.command.getCost(domesticContext).gold : 0;
|
||||||
|
};
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
notWanderingNation(),
|
notWanderingNation(),
|
||||||
occupiedCity(),
|
occupiedCity(),
|
||||||
suppliedCity(),
|
suppliedCity(),
|
||||||
reqGeneralGold(getRequiredGold),
|
reqGeneralGold(getCost, requirements),
|
||||||
reqGeneralRice(() => 0),
|
reqGeneralRice(() => 0, requirements),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(context: TechResearchContext<TriggerState>, _args: TechResearchArgs): GeneralActionOutcome<TriggerState> {
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
const result = this.command.resolve(context, context.rng);
|
||||||
_args: TechResearchArgs
|
let techScore = result.score;
|
||||||
): GeneralActionOutcome<TriggerState> {
|
const currentTech = readTech(context.nation);
|
||||||
const general = context.general;
|
const relYear = context.relYear ?? 0;
|
||||||
const nation = context.nation;
|
const techLevelIncYear = this.env.techLevelIncYear ?? 5;
|
||||||
if (!nation) {
|
const initialAllowedTechLevel = this.env.initialAllowedTechLevel ?? 1;
|
||||||
context.addLog('국가 정보를 찾지 못했습니다.');
|
const relativeMaxTech = clamp(
|
||||||
return { effects: [] };
|
Math.floor(relYear / techLevelIncYear) + initialAllowedTechLevel,
|
||||||
|
1,
|
||||||
|
this.env.maxTechLevel
|
||||||
|
);
|
||||||
|
const currentTechLevel = clamp(Math.floor(currentTech / 1000), 0, this.env.maxTechLevel);
|
||||||
|
if (currentTechLevel >= relativeMaxTech) {
|
||||||
|
techScore /= 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = this.env.techDelta ?? DEFAULT_TECH_DELTA;
|
context.nation.meta = {
|
||||||
const currentTech = readTech(nation);
|
...context.nation.meta,
|
||||||
const maxTech =
|
tech: currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit),
|
||||||
typeof this.env.maxTechLevel === 'number' && this.env.maxTechLevel > 0
|
};
|
||||||
? this.env.maxTechLevel
|
context.general.gold = Math.max(0, context.general.gold - result.costGold);
|
||||||
: currentTech + delta;
|
context.general.experience += result.exp;
|
||||||
const nextTech = Math.min(currentTech + delta, maxTech);
|
context.general.dedication += result.dedication;
|
||||||
const applied = nextTech - currentTech;
|
const intelExp = typeof context.general.meta.intel_exp === 'number' ? context.general.meta.intel_exp : 0;
|
||||||
const costGold = this.env.costGold ?? 0;
|
context.general.meta = {
|
||||||
|
...context.general.meta,
|
||||||
|
intel_exp: intelExp + 1,
|
||||||
|
max_domestic_critical: result.pick === 'success' ? result.score : 0,
|
||||||
|
};
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
const scoreText = Math.round(result.score).toLocaleString();
|
||||||
nation.meta = { ...nation.meta, tech: nextTech };
|
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
general.gold = Math.max(0, general.gold - costGold);
|
if (result.pick === 'fail') {
|
||||||
|
context.addLog(
|
||||||
const logName = ACTION_NAME.replace(' ', '');
|
`${ACTION_NAME}${josaUl} <span class='ev_failed'>실패</span>하여 <C>${scoreText}</> 상승했습니다.`
|
||||||
const josaUl = JosaUtil.pick(logName, '을');
|
);
|
||||||
const scoreText = applied.toLocaleString();
|
} else if (result.pick === 'success') {
|
||||||
context.addLog(`${logName}${josaUl} 하여 <C>${scoreText}</> 상승했습니다.`);
|
context.addLog(`${ACTION_NAME}${josaUl} <S>성공</>하여 <C>${scoreText}</> 상승했습니다.`);
|
||||||
|
} else {
|
||||||
|
context.addLog(`${ACTION_NAME}${josaUl} 하여 <C>${scoreText}</> 상승했습니다.`);
|
||||||
|
}
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export const actionContextBuilder: ActionContextBuilder = (
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
base: ActionContextBase,
|
||||||
|
options: ActionContextOptions
|
||||||
|
): (ActionContextBase & Record<string, unknown>) | null => {
|
||||||
|
if (!base.city || !base.nation || !options.worldRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
city: base.city,
|
||||||
|
nation: base.nation,
|
||||||
|
nationGeneralCount: options.worldRef.listGenerals().filter((general) => general.nationId === base.nation!.id)
|
||||||
|
.length,
|
||||||
|
relYear:
|
||||||
|
typeof options.scenarioMeta?.startYear === 'number'
|
||||||
|
? options.world.currentYear - options.scenarioMeta.startYear
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_기술연구',
|
key: 'che_기술연구',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
createDefinition: (env: TurnCommandEnv) =>
|
|
||||||
new ActionDefinition({
|
|
||||||
costGold: env.develCost,
|
|
||||||
maxTechLevel: env.maxTechLevel,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { setMetaNumber } from '@sammo-ts/logic/war/utils.js';
|
import { setMetaNumber } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
import { DOMESTIC_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/domestic/index.js';
|
||||||
|
|
||||||
export interface ResetSpecialDomesticArgs {}
|
export interface ResetSpecialDomesticArgs {}
|
||||||
|
|
||||||
@@ -37,6 +38,23 @@ export class ActionDefinition<
|
|||||||
public readonly key = 'che_내정특기초기화';
|
public readonly key = 'che_내정특기초기화';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
|
||||||
|
getPreReqTurn(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPostReqTurn(): number {
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProgressText(
|
||||||
|
_context: GeneralActionResolveContext<TriggerState>,
|
||||||
|
_args: ResetSpecialDomesticArgs,
|
||||||
|
term: number,
|
||||||
|
termMax: number
|
||||||
|
): string {
|
||||||
|
return `새로운 적성을 찾는 중... (${term}/${termMax})`;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): ResetSpecialDomesticArgs | null {
|
parseArgs(_raw: unknown): ResetSpecialDomesticArgs | null {
|
||||||
void _raw;
|
void _raw;
|
||||||
return {};
|
return {};
|
||||||
@@ -55,6 +73,15 @@ export class ActionDefinition<
|
|||||||
_args: ResetSpecialDomesticArgs
|
_args: ResetSpecialDomesticArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
|
const previous = general.meta.prev_types_special;
|
||||||
|
const previousTypes = Array.isArray(previous)
|
||||||
|
? previous.filter((value): value is string => typeof value === 'string')
|
||||||
|
: [];
|
||||||
|
const nextPreviousTypes = [...previousTypes, general.role.specialDomestic!];
|
||||||
|
general.meta.prev_types_special =
|
||||||
|
nextPreviousTypes.length === DOMESTIC_TRAIT_KEYS.length
|
||||||
|
? [general.role.specialDomestic!]
|
||||||
|
: nextPreviousTypes;
|
||||||
general.role.specialDomestic = null;
|
general.role.specialDomestic = null;
|
||||||
setMetaNumber(general.meta, 'specAge', general.age + 1);
|
setMetaNumber(general.meta, 'specAge', general.age + 1);
|
||||||
context.addLog('새로운 내정 특기를 가질 준비가 되었습니다.');
|
context.addLog('새로운 내정 특기를 가질 준비가 되었습니다.');
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
|
import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends CityDevelopmentActionDefinition<TriggerState> {
|
> extends DomesticActionDefinition<TriggerState> {
|
||||||
constructor(env: { develCost?: number; amount?: number } = {}) {
|
constructor(env: TurnCommandEnv) {
|
||||||
super(
|
super(env.generalActionModules ?? [], env, {
|
||||||
{
|
key: 'che_농지개간',
|
||||||
key: 'che_농지개간',
|
name: '농지 개간',
|
||||||
name: '농지 개간',
|
actionKey: '농업',
|
||||||
statKey: 'agriculture',
|
statKey: 'intelligence',
|
||||||
maxKey: 'agricultureMax',
|
statExpKey: 'intel_exp',
|
||||||
label: '농업',
|
cityKey: 'agriculture',
|
||||||
baseAmount: 100,
|
cityMaxKey: 'agricultureMax',
|
||||||
},
|
frontDebuff: 0.5,
|
||||||
env
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export { actionContextBuilder };
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_농지개간',
|
key: 'che_농지개간',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ export class ActionResolver<
|
|||||||
|
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const develCost = env?.develCost ?? 100;
|
const develCost = env?.develCost ?? 100;
|
||||||
const extraCost = Math.floor((destGeneral.experience + destGeneral.dedication) / 1000) * 10;
|
const reqGold = Math.round(develCost + (destGeneral.experience + destGeneral.dedication) / 1000) * 10;
|
||||||
const reqGold = develCost + extraCost;
|
|
||||||
|
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ export class ActionDefinition<
|
|||||||
(_c, view) => {
|
(_c, view) => {
|
||||||
const dest = view.get({ kind: 'destGeneral', id: args.destGeneralId }) as General | null;
|
const dest = view.get({ kind: 'destGeneral', id: args.destGeneralId }) as General | null;
|
||||||
if (!dest) return develCost;
|
if (!dest) return develCost;
|
||||||
return develCost + Math.floor((dest.experience + dest.dedication) / 1000) * 10;
|
return Math.round(develCost + (dest.experience + dest.dedication) / 1000) * 10;
|
||||||
},
|
},
|
||||||
[{ kind: 'destGeneral', id: args.destGeneralId }]
|
[{ kind: 'destGeneral', id: args.destGeneralId }]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ export class ActionResolver<
|
|||||||
|
|
||||||
// 3. Betrayal Logic
|
// 3. Betrayal Logic
|
||||||
// Legacy: GameConst::$defaultGold (usually 1000/2000).
|
// Legacy: GameConst::$defaultGold (usually 1000/2000).
|
||||||
const safeGold = this.env.baseGold || 1000;
|
const safeGold = this.env.defaultNpcGold;
|
||||||
const safeRice = this.env.baseRice || 1000;
|
const safeRice = this.env.defaultNpcRice;
|
||||||
|
|
||||||
let newGold = general.gold;
|
let newGold = general.gold;
|
||||||
let newRice = general.rice;
|
let newRice = general.rice;
|
||||||
@@ -137,7 +137,7 @@ export class ActionResolver<
|
|||||||
// Apply penalty
|
// Apply penalty
|
||||||
newExp = Math.floor(newExp * Math.max(0, penaltyFactor));
|
newExp = Math.floor(newExp * Math.max(0, penaltyFactor));
|
||||||
newDed = Math.floor(newDed * Math.max(0, penaltyFactor));
|
newDed = Math.floor(newDed * Math.max(0, penaltyFactor));
|
||||||
newBetray += 1;
|
newBetray = Math.min(9, newBetray + 1);
|
||||||
} else {
|
} else {
|
||||||
// Neutral -> Join: Grant Bonus
|
// Neutral -> Join: Grant Bonus
|
||||||
newExp += 100;
|
newExp += 100;
|
||||||
@@ -161,13 +161,65 @@ export class ActionResolver<
|
|||||||
troopId: 0, // Quit troop
|
troopId: 0, // Quit troop
|
||||||
meta: {
|
meta: {
|
||||||
...general.meta,
|
...general.meta,
|
||||||
|
belong: 1,
|
||||||
|
permission: 'normal',
|
||||||
officer_city: 0,
|
officer_city: 0,
|
||||||
betray: newBetray,
|
betray: newBetray,
|
||||||
|
...(general.npcState < 2
|
||||||
|
? {
|
||||||
|
killturn:
|
||||||
|
typeof context.general.meta.killturn === 'number'
|
||||||
|
? context.general.meta.killturn
|
||||||
|
: 0,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
general.id
|
general.id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (currentNation && currentNation.id !== 0) {
|
||||||
|
const currentCount = typeof currentNation.meta.gennum === 'number' ? currentNation.meta.gennum : 0;
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
...currentNation.meta,
|
||||||
|
gennum: Math.max(0, currentCount - 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
currentNation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const destCount = typeof destNation.meta.gennum === 'number' ? destNation.meta.gennum : 0;
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
...destNation.meta,
|
||||||
|
gennum: destCount + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
destNation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
context.addLog(`<D><b>${destNationName}</b></>${josaRo} 망명`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
});
|
||||||
|
context.addLog(`<Y>${generalName}</> 등용에 성공했습니다.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
generalId: destGeneral.id,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog(`<Y>${generalName}</> 등용에 성공`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
generalId: destGeneral.id,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
@@ -178,6 +230,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, AcceptScoutArgs, AcceptScoutResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, AcceptScoutArgs, AcceptScoutResolveContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
@@ -23,6 +23,7 @@ interface CandidateNation<TriggerState extends GeneralTriggerState = GeneralTrig
|
|||||||
generals: General<TriggerState>[];
|
generals: General<TriggerState>[];
|
||||||
generalCount: number;
|
generalCount: number;
|
||||||
monarchCityId: number | null;
|
monarchCityId: number | null;
|
||||||
|
monarchAffinity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RandomAppointmentResolveContext<
|
export interface RandomAppointmentResolveContext<
|
||||||
@@ -31,6 +32,7 @@ export interface RandomAppointmentResolveContext<
|
|||||||
candidateNations: Array<CandidateNation<TriggerState>>;
|
candidateNations: Array<CandidateNation<TriggerState>>;
|
||||||
relYear: number;
|
relYear: number;
|
||||||
initialNationGenLimit: number;
|
initialNationGenLimit: number;
|
||||||
|
historicalNpcAffinityMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '무작위 국가로 임관';
|
const ACTION_NAME = '무작위 국가로 임관';
|
||||||
@@ -115,7 +117,11 @@ const calcNationPower = <TriggerState extends GeneralTriggerState>(generals: Gen
|
|||||||
const leadership = general.stats.leadership;
|
const leadership = general.stats.leadership;
|
||||||
if (leadership >= 40) {
|
if (leadership >= 40) {
|
||||||
const coef = general.npcState < 2 ? 1.15 : 1;
|
const coef = general.npcState < 2 ? 1.15 : 1;
|
||||||
warPower += coef * leadership;
|
const killCrew =
|
||||||
|
resolveNumber(asRecord(general.meta), ['rank_killcrew_person', 'killcrew_person'], 0) + 50_000;
|
||||||
|
const deathCrew =
|
||||||
|
resolveNumber(asRecord(general.meta), ['rank_deathcrew_person', 'deathcrew_person'], 0) + 50_000;
|
||||||
|
warPower += (killCrew / deathCrew) * coef * leadership;
|
||||||
}
|
}
|
||||||
const base = Math.sqrt(general.stats.intelligence * general.stats.strength) * 2 + leadership / 2;
|
const base = Math.sqrt(general.stats.intelligence * general.stats.strength) * 2 + leadership / 2;
|
||||||
develPower += base / 5;
|
develPower += base / 5;
|
||||||
@@ -125,9 +131,16 @@ const calcNationPower = <TriggerState extends GeneralTriggerState>(generals: Gen
|
|||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, RandomAppointmentArgs, RandomAppointmentResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<
|
||||||
|
TriggerState,
|
||||||
|
RandomAppointmentArgs,
|
||||||
|
RandomAppointmentResolveContext<TriggerState>
|
||||||
|
> {
|
||||||
public readonly key = 'che_랜덤임관';
|
public readonly key = 'che_랜덤임관';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): RandomAppointmentArgs | null {
|
parseArgs(_raw: unknown): RandomAppointmentArgs | null {
|
||||||
return {};
|
return {};
|
||||||
@@ -159,17 +172,37 @@ export class ActionDefinition<
|
|||||||
return { effects, alternative: { commandKey: 'che_인재탐색', args: {} } };
|
return { effects, alternative: { commandKey: 'che_인재탐색', args: {} } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const weightedCandidates = candidateNations.map((candidate) => {
|
let picked: CandidateNation<TriggerState> | null;
|
||||||
let power = calcNationPower(candidate.generals);
|
if (context.historicalNpcAffinityMode) {
|
||||||
if (general.npcState < 2 && candidate.nation.name.startsWith('ⓤ')) {
|
const totalGenerals = candidateNations.reduce((sum, candidate) => sum + candidate.generalCount, 0);
|
||||||
power *= 100;
|
let actorAffinity = resolveNumber(asRecord(general.meta), ['affinity'], 0);
|
||||||
|
let bestScore = 1 << 30;
|
||||||
|
picked = null;
|
||||||
|
for (const candidate of candidateNations) {
|
||||||
|
// 레거시 구현은 actorAffinity를 매 후보마다 원래 값으로 되돌리지 않는다.
|
||||||
|
actorAffinity = Math.abs(actorAffinity - candidate.monarchAffinity);
|
||||||
|
actorAffinity = Math.min(actorAffinity, Math.abs(actorAffinity - 150));
|
||||||
|
const score =
|
||||||
|
Math.log2(actorAffinity + 1) +
|
||||||
|
rng.nextFloat1() +
|
||||||
|
Math.sqrt(candidate.generalCount / Math.max(totalGenerals, 1));
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
picked = candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const normalized = power > 0 ? power : 1;
|
} else {
|
||||||
const weight = Math.pow(1 / normalized, 3);
|
const weightedCandidates = candidateNations.map((candidate) => {
|
||||||
return [candidate, weight] as [CandidateNation<TriggerState>, number];
|
let power = calcNationPower(candidate.generals);
|
||||||
});
|
if (general.npcState < 2 && candidate.nation.name.startsWith('ⓤ')) {
|
||||||
|
power *= 100;
|
||||||
const picked = pickUsingWeightPair(rng, weightedCandidates);
|
}
|
||||||
|
const normalized = power > 0 ? power : 1;
|
||||||
|
const weight = Math.pow(1 / normalized, 3);
|
||||||
|
return [candidate, weight] as [CandidateNation<TriggerState>, number];
|
||||||
|
});
|
||||||
|
picked = pickUsingWeightPair(rng, weightedCandidates);
|
||||||
|
}
|
||||||
if (!picked) {
|
if (!picked) {
|
||||||
effects.push(
|
effects.push(
|
||||||
createLogEffect('임관 가능한 국가가 없습니다.', {
|
createLogEffect('임관 가능한 국가가 없습니다.', {
|
||||||
@@ -207,6 +240,15 @@ export class ActionDefinition<
|
|||||||
experience: general.experience + expGain,
|
experience: general.experience + expGain,
|
||||||
meta,
|
meta,
|
||||||
}),
|
}),
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
...destNation.meta,
|
||||||
|
gennum: picked.generalCount + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
destNation.id
|
||||||
|
),
|
||||||
createLogEffect(`<D>${destNationName}</>에 랜덤 임관했습니다.`, {
|
createLogEffect(`<D>${destNationName}</>에 랜덤 임관했습니다.`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
@@ -238,10 +280,17 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
|||||||
const startYear = resolveStartYear(options.world, options.scenarioMeta);
|
const startYear = resolveStartYear(options.world, options.scenarioMeta);
|
||||||
const relYear = Math.max(options.world.currentYear - startYear, 0);
|
const relYear = Math.max(options.world.currentYear - startYear, 0);
|
||||||
if (!worldRef) {
|
if (!worldRef) {
|
||||||
return { ...base, candidateNations: [], relYear, initialNationGenLimit: 0 };
|
return {
|
||||||
|
...base,
|
||||||
|
candidateNations: [],
|
||||||
|
relYear,
|
||||||
|
initialNationGenLimit: 0,
|
||||||
|
historicalNpcAffinityMode: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const constValues = asRecord(options.scenarioConfig.const);
|
const constValues = asRecord(options.scenarioConfig.const);
|
||||||
|
const worldMeta = asRecord(options.world.meta);
|
||||||
const initialNationGenLimit = resolveNumber(constValues, ['initialNationGenLimit'], 0);
|
const initialNationGenLimit = resolveNumber(constValues, ['initialNationGenLimit'], 0);
|
||||||
const defaultMaxGeneral = resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0);
|
const defaultMaxGeneral = resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0);
|
||||||
const genLimit = relYear < 3 && initialNationGenLimit > 0 ? initialNationGenLimit : defaultMaxGeneral;
|
const genLimit = relYear < 3 && initialNationGenLimit > 0 ? initialNationGenLimit : defaultMaxGeneral;
|
||||||
@@ -251,7 +300,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
|||||||
const monarchCityByNation = new Map<number, number>();
|
const monarchCityByNation = new Map<number, number>();
|
||||||
|
|
||||||
for (const general of generals) {
|
for (const general of generals) {
|
||||||
if (general.nationId <= 0) {
|
if (general.nationId <= 0 || ![0, 1, 2, 3, 6].includes(general.npcState)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const list = generalsByNation.get(general.nationId) ?? [];
|
const list = generalsByNation.get(general.nationId) ?? [];
|
||||||
@@ -292,15 +341,28 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
|||||||
if (!monarchCityId) {
|
if (!monarchCityId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const chief =
|
||||||
|
nationGenerals.find((general) => general.officerLevel === 12) ??
|
||||||
|
(nation.chiefGeneralId ? worldRef.getGeneralById(nation.chiefGeneralId) : null);
|
||||||
candidateNations.push({
|
candidateNations.push({
|
||||||
nation,
|
nation,
|
||||||
generals: nationGenerals,
|
generals: nationGenerals,
|
||||||
generalCount: nationGenerals.length,
|
generalCount: resolveNumber(meta, ['gennum'], nationGenerals.length),
|
||||||
monarchCityId,
|
monarchCityId,
|
||||||
|
monarchAffinity: chief ? resolveNumber(asRecord(chief.meta), ['affinity'], 0) : 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...base, candidateNations, relYear, initialNationGenLimit };
|
const scenarioId = resolveNumber(worldMeta, ['scenarioId', 'scenario'], 0);
|
||||||
|
const fiction = resolveNumber(asRecord(options.scenarioMeta), ['fiction'], 0);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
candidateNations,
|
||||||
|
relYear,
|
||||||
|
initialNationGenLimit,
|
||||||
|
historicalNpcAffinityMode:
|
||||||
|
base.general.npcState >= 2 && fiction === 0 && scenarioId >= 1000 && scenarioId < 2000,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, RebellionArgs, RebellionResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, RebellionArgs, RebellionResolveContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): RebellionArgs | null {
|
parseArgs(_raw: unknown): RebellionArgs | null {
|
||||||
return {};
|
return {};
|
||||||
@@ -54,8 +57,9 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lord =
|
const lord =
|
||||||
context.nationGenerals?.find((candidate) => candidate.nationId === nation.id && candidate.officerLevel === 12) ??
|
context.nationGenerals?.find(
|
||||||
null;
|
(candidate) => candidate.nationId === nation.id && candidate.officerLevel === 12
|
||||||
|
) ?? null;
|
||||||
if (!lord || lord.id === general.id) {
|
if (!lord || lord.id === general.id) {
|
||||||
throw new Error('모반할 대상 군주가 없습니다.');
|
throw new Error('모반할 대상 군주가 없습니다.');
|
||||||
}
|
}
|
||||||
@@ -63,10 +67,13 @@ export class ActionDefinition<
|
|||||||
const josaYi = JosaUtil.pick(general.name, '이');
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||||
|
|
||||||
context.addLog(`<Y><b>【모반】</b></><Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>의 군주 자리를 찬탈했습니다.`, {
|
context.addLog(
|
||||||
scope: LogScope.SYSTEM,
|
`<Y><b>【모반】</b></><Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>의 군주 자리를 찬탈했습니다.`,
|
||||||
category: LogCategory.HISTORY,
|
{
|
||||||
});
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
}
|
||||||
|
);
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${lord.name}</>에게서 군주자리를 찬탈`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${lord.name}</>에게서 군주자리를 찬탈`, {
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
@@ -88,12 +95,15 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
}),
|
}),
|
||||||
createLogEffect(`<D><b>${general.name}</b></>의 모반으로 인해 <D><b>${nation.name}</b></>의 군주자리를 박탈당함`, {
|
createLogEffect(
|
||||||
scope: LogScope.GENERAL,
|
`<D><b>${general.name}</b></>의 모반으로 인해 <D><b>${nation.name}</b></>의 군주자리를 박탈당함`,
|
||||||
generalId: lord.id,
|
{
|
||||||
category: LogCategory.HISTORY,
|
scope: LogScope.GENERAL,
|
||||||
format: LogFormat.PLAIN,
|
generalId: lord.id,
|
||||||
}),
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}
|
||||||
|
),
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, FoundingArgs, RandomFoundingResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, FoundingArgs, RandomFoundingResolveContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): FoundingArgs | null {
|
parseArgs(raw: unknown): FoundingArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
@@ -131,7 +134,9 @@ export class ActionDefinition<
|
|||||||
throw new Error('Invalid color type');
|
throw new Error('Invalid color type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = (context.allCities ?? []).filter((city) => city.nationId === 0 && [5, 6].includes(city.level));
|
const candidates = (context.allCities ?? []).filter(
|
||||||
|
(city) => city.nationId === 0 && [5, 6].includes(city.level)
|
||||||
|
);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
context.addLog('건국할 수 있는 도시가 없습니다.', {
|
context.addLog('건국할 수 있는 도시가 없습니다.', {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
@@ -157,10 +162,13 @@ export class ActionDefinition<
|
|||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`, {
|
context.addLog(
|
||||||
scope: LogScope.SYSTEM,
|
`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
|
||||||
category: LogCategory.HISTORY,
|
{
|
||||||
});
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
}
|
||||||
|
);
|
||||||
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
|
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import type {
|
|||||||
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export interface ProcureArgs {}
|
export interface ProcureArgs {}
|
||||||
|
interface ProcureContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
relYear?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '물자조달';
|
const ACTION_NAME = '물자조달';
|
||||||
const ACTION_KEY = 'che_물자조달';
|
const ACTION_KEY = 'che_물자조달';
|
||||||
@@ -30,10 +35,7 @@ export class ActionResolver<
|
|||||||
this.pipeline = new GeneralActionPipeline(modules);
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(context: ProcureContext<TriggerState>, _args: ProcureArgs): GeneralActionOutcome<TriggerState> {
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
|
||||||
_args: ProcureArgs
|
|
||||||
): GeneralActionOutcome<TriggerState> {
|
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const nation = context.nation;
|
const nation = context.nation;
|
||||||
|
|
||||||
@@ -42,18 +44,14 @@ export class ActionResolver<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Choose Gold or Rice
|
// 1. Choose Gold or Rice
|
||||||
const picked = context.rng.nextBool(0.5) ? 'gold' : 'rice';
|
const picked = context.rng.nextInt(0, 2) === 0 ? 'gold' : 'rice';
|
||||||
const resName = picked === 'gold' ? '금' : '쌀';
|
const resName = picked === 'gold' ? '금' : '쌀';
|
||||||
const resKey = picked === 'gold' ? 'gold' : 'rice';
|
const resKey = picked === 'gold' ? 'gold' : 'rice';
|
||||||
|
|
||||||
// 2. Base Score
|
// 2. Base Score
|
||||||
let score = general.stats.leadership + general.stats.strength + general.stats.intelligence;
|
let score = general.stats.leadership + general.stats.strength + general.stats.intelligence;
|
||||||
|
const expLevel = typeof general.meta.explevel === 'number' ? general.meta.explevel : 0;
|
||||||
// Domestic Bonus (Hardcoded simplified logic or need helper)
|
score *= 1 + expLevel / 500;
|
||||||
// In legacy: getDomesticExpLevelBonus($general->getVar('explevel'))
|
|
||||||
// For now, assume simplified or no bonus unless strictly required to port helper.
|
|
||||||
// Assuming 1.0 for now if helper not available, or implement simple bonus.
|
|
||||||
// Legacy: $score *= $rng->nextRange(0.8, 1.2);
|
|
||||||
score *= context.rng.nextFloat1() * 0.4 + 0.8;
|
score *= context.rng.nextFloat1() * 0.4 + 0.8;
|
||||||
|
|
||||||
// 3. Success/Fail Ratio
|
// 3. Success/Fail Ratio
|
||||||
@@ -64,12 +62,14 @@ export class ActionResolver<
|
|||||||
failRatio = this.pipeline.onCalcDomestic(context, '조달', 'fail', failRatio);
|
failRatio = this.pipeline.onCalcDomestic(context, '조달', 'fail', failRatio);
|
||||||
|
|
||||||
// 4. Determine Outcome
|
// 4. Determine Outcome
|
||||||
const roll = context.rng.nextFloat1();
|
const normalRatio = 1 - failRatio - successRatio;
|
||||||
|
let roll = context.rng.nextFloat1() * (failRatio + successRatio + normalRatio);
|
||||||
let outcome: 'fail' | 'success' | 'normal' = 'normal';
|
let outcome: 'fail' | 'success' | 'normal' = 'normal';
|
||||||
|
|
||||||
if (roll < failRatio) {
|
roll -= failRatio;
|
||||||
|
if (roll <= 0) {
|
||||||
outcome = 'fail';
|
outcome = 'fail';
|
||||||
} else if (roll < failRatio + successRatio) {
|
} else if (roll - successRatio <= 0) {
|
||||||
outcome = 'success';
|
outcome = 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,41 +77,48 @@ export class ActionResolver<
|
|||||||
// Legacy: CriticalScoreEx($rng, $pick);
|
// Legacy: CriticalScoreEx($rng, $pick);
|
||||||
// fail -> 0.5, success -> 1.5, normal -> 1.0 roughly
|
// fail -> 0.5, success -> 1.5, normal -> 1.0 roughly
|
||||||
if (outcome === 'fail') {
|
if (outcome === 'fail') {
|
||||||
score *= 0.5;
|
score *= 0.2 + context.rng.nextFloat1() * 0.2;
|
||||||
} else if (outcome === 'success') {
|
} else if (outcome === 'success') {
|
||||||
score *= 1.5;
|
score *= 2.2 + context.rng.nextFloat1() * 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
score = this.pipeline.onCalcDomestic(context, '조달', 'score', score);
|
score = this.pipeline.onCalcDomestic(context, '조달', 'score', score);
|
||||||
score = Math.floor(score);
|
score = Math.round(score);
|
||||||
|
|
||||||
// 6. Calculate Exp/Dedication
|
// 6. Calculate Exp/Dedication
|
||||||
const exp = (score * 0.7) / 3;
|
const exp = (score * 0.7) / 3;
|
||||||
const ded = (score * 1.0) / 3;
|
const ded = (score * 1.0) / 3;
|
||||||
|
|
||||||
// 7. Update General
|
// 7. Update General
|
||||||
const nextExp = general.experience + Math.floor(exp);
|
const nextExp = general.experience + Math.trunc(exp);
|
||||||
const nextDed = general.dedication + Math.floor(ded);
|
const nextDed = general.dedication + Math.trunc(ded);
|
||||||
|
|
||||||
|
let appliedScore = score;
|
||||||
|
if (context.city && [1, 3].includes(context.city.frontState)) {
|
||||||
|
let frontDebuff = 0.5;
|
||||||
|
if (nation.capitalCityId === context.city.id && (context.relYear ?? 0) < 25) {
|
||||||
|
const debuffScale = Math.max(0, Math.min(20, (context.relYear ?? 0) - 5)) * 0.05;
|
||||||
|
frontDebuff = debuffScale * frontDebuff + (1 - debuffScale);
|
||||||
|
}
|
||||||
|
appliedScore *= frontDebuff;
|
||||||
|
}
|
||||||
|
|
||||||
// Stat Exp
|
// Stat Exp
|
||||||
// Legacy: choose weighted among L/S/I
|
// Legacy: choose weighted among L/S/I
|
||||||
const statChoice =
|
const statChoice =
|
||||||
context.rng.nextFloat1() * (general.stats.leadership + general.stats.strength + general.stats.intelligence);
|
context.rng.nextFloat1() * (general.stats.leadership + general.stats.strength + general.stats.intelligence);
|
||||||
let statKey: 'leadership_exp' | 'strength_exp' | 'intel_exp' = 'leadership_exp';
|
const statKey: 'leadership_exp' | 'strength_exp' | 'intel_exp' =
|
||||||
|
statChoice < general.stats.leadership
|
||||||
if (statChoice < general.stats.leadership) {
|
? 'leadership_exp'
|
||||||
statKey = 'leadership_exp';
|
: statChoice < general.stats.leadership + general.stats.strength
|
||||||
} else if (statChoice < general.stats.leadership + general.stats.strength) {
|
? 'strength_exp'
|
||||||
statKey = 'strength_exp';
|
: 'intel_exp';
|
||||||
} else {
|
|
||||||
statKey = 'intel_exp';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Update Nation
|
// 8. Update Nation
|
||||||
const nextNationRes = (nation[resKey] ?? 0) + score;
|
const nextNationRes = (nation[resKey] ?? 0) + Math.trunc(appliedScore);
|
||||||
|
|
||||||
// 9. Logging
|
// 9. Logging
|
||||||
const scoreText = score.toLocaleString();
|
const scoreText = Math.round(appliedScore).toLocaleString();
|
||||||
if (outcome === 'fail') {
|
if (outcome === 'fail') {
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`조달을 <span class='ev_failed'>실패</span>하여 ${resName}을 <C>${scoreText}</> 조달했습니다.`,
|
`조달을 <span class='ev_failed'>실패</span>하여 ${resName}을 <C>${scoreText}</> 조달했습니다.`,
|
||||||
@@ -185,7 +192,12 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||||
|
...base,
|
||||||
|
...(typeof options.scenarioMeta?.startYear === 'number'
|
||||||
|
? { relYear: options.world.currentYear - options.scenarioMeta.startYear }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_물자조달',
|
key: 'che_물자조달',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
allow,
|
allow,
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
createCityPatchEffect,
|
createCityPatchEffect,
|
||||||
createDiplomacyPatchEffect,
|
createDiplomacyPatchEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
@@ -74,7 +75,24 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
// TODO: Global logs
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
|
const josaUn = JosaUtil.pick(general.name, '은');
|
||||||
|
const josaUl = JosaUtil.pick(nation.name, '을');
|
||||||
|
context.addLog(`<Y>${general.name}</>${josaYi} 방랑의 길을 떠납니다.`, {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.RAWTEXT,
|
||||||
|
});
|
||||||
|
context.addLog(`<R><b>【방랑】</b></><D><b>${general.name}</b></>${josaUn} <R>방랑</>의 길을 떠납니다.`, {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.RAWTEXT,
|
||||||
|
});
|
||||||
|
context.addLog(`<D><b>${nation.name}</b></>${josaUl} 버리고 방랑`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Nation Update
|
// 2. Nation Update
|
||||||
effects.push(
|
effects.push(
|
||||||
@@ -99,9 +117,11 @@ export class ActionResolver<
|
|||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
...general,
|
...general,
|
||||||
// meta: { ...general.meta, makelimit: 12 }, // If used. Legacy uses makelimit column. New entity might not have it.
|
meta: {
|
||||||
// If `makelimit` is not in entity, ignore for now or check meta.
|
...general.meta,
|
||||||
// officerLevel is already 12.
|
makelimit: 12,
|
||||||
|
officer_city: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
general.id
|
general.id
|
||||||
)
|
)
|
||||||
@@ -122,7 +142,11 @@ export class ActionResolver<
|
|||||||
{
|
{
|
||||||
...other,
|
...other,
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
// officer_city? logic probably means unassigned.
|
meta: {
|
||||||
|
...other.meta,
|
||||||
|
makelimit: 12,
|
||||||
|
officer_city: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
other.id
|
other.id
|
||||||
)
|
)
|
||||||
@@ -142,9 +166,7 @@ export class ActionResolver<
|
|||||||
...city,
|
...city,
|
||||||
nationId: 0,
|
nationId: 0,
|
||||||
frontState: 0,
|
frontState: 0,
|
||||||
// conflict: {} // Legacy clears conflict.
|
conflict: {},
|
||||||
// Assuming conflict is stored in meta or handled separately.
|
|
||||||
// If conflict is part of state, reset it.
|
|
||||||
},
|
},
|
||||||
city.id
|
city.id
|
||||||
)
|
)
|
||||||
@@ -179,6 +201,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, WanderArgs, WanderResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, WanderArgs, WanderResolveContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
|
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
|
||||||
export interface BoostMoraleArgs {}
|
export interface BoostMoraleArgs {}
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ export interface BoostMoraleEnvironment {
|
|||||||
atmosDelta?: number;
|
atmosDelta?: number;
|
||||||
maxAtmosByCommand?: number;
|
maxAtmosByCommand?: number;
|
||||||
costGold?: number;
|
costGold?: number;
|
||||||
|
trainSideEffectByAtmosTurn?: number;
|
||||||
|
unitSet?: TurnCommandEnv['unitSet'];
|
||||||
|
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '사기 진작';
|
const ACTION_NAME = '사기 진작';
|
||||||
@@ -35,9 +39,11 @@ export class ActionDefinition<
|
|||||||
public readonly key = 'che_사기진작';
|
public readonly key = 'che_사기진작';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly env: BoostMoraleEnvironment;
|
private readonly env: BoostMoraleEnvironment;
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
constructor(env: BoostMoraleEnvironment = {}) {
|
constructor(env: BoostMoraleEnvironment = {}) {
|
||||||
this.env = env;
|
this.env = env;
|
||||||
|
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): BoostMoraleArgs | null {
|
parseArgs(_raw: unknown): BoostMoraleArgs | null {
|
||||||
@@ -50,7 +56,10 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: BoostMoraleArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: BoostMoraleArgs): Constraint[] {
|
||||||
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.costGold ?? 0;
|
const getRequiredGold = (context: ConstraintContext, view: StateView): number => {
|
||||||
|
const general = view.get({ kind: 'general', id: context.actorId }) as { crew?: number } | null;
|
||||||
|
return this.env.costGold ?? Math.round((general?.crew ?? 0) / 100);
|
||||||
|
};
|
||||||
const maxAtmos =
|
const maxAtmos =
|
||||||
this.env.maxAtmosByCommand && this.env.maxAtmosByCommand > 0
|
this.env.maxAtmosByCommand && this.env.maxAtmosByCommand > 0
|
||||||
? this.env.maxAtmosByCommand
|
? this.env.maxAtmosByCommand
|
||||||
@@ -76,13 +85,26 @@ export class ActionDefinition<
|
|||||||
? this.env.maxAtmosByCommand
|
? this.env.maxAtmosByCommand
|
||||||
: DEFAULT_MAX_ATMOS;
|
: DEFAULT_MAX_ATMOS;
|
||||||
const delta = this.env.atmosDelta && this.env.atmosDelta > 0 ? this.env.atmosDelta : DEFAULT_ATMOS_DELTA;
|
const delta = this.env.atmosDelta && this.env.atmosDelta > 0 ? this.env.atmosDelta : DEFAULT_ATMOS_DELTA;
|
||||||
const nextAtmos = clamp(general.atmos + delta, 0, maxAtmos);
|
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
|
||||||
|
const score = Math.round((leadership * 100 * delta) / general.crew);
|
||||||
|
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
|
||||||
const applied = nextAtmos - general.atmos;
|
const applied = nextAtmos - general.atmos;
|
||||||
const costGold = this.env.costGold ?? 0;
|
const costGold = this.env.costGold ?? Math.round(general.crew / 100);
|
||||||
|
const trainSideEffect = Math.max(0, Math.trunc(general.train * (this.env.trainSideEffectByAtmosTurn ?? 1)));
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
|
||||||
general.atmos = nextAtmos;
|
general.atmos = nextAtmos;
|
||||||
|
general.train = trainSideEffect;
|
||||||
general.gold = Math.max(0, general.gold - costGold);
|
general.gold = Math.max(0, general.gold - costGold);
|
||||||
|
general.experience += 100;
|
||||||
|
general.dedication += 70;
|
||||||
|
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
||||||
|
general.meta.leadership_exp = leadershipExp + 1;
|
||||||
|
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
|
||||||
|
if (crewType) {
|
||||||
|
const dexKey = `dex${crewType.armType}`;
|
||||||
|
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
|
||||||
|
general.meta[dexKey] = dex + applied;
|
||||||
|
}
|
||||||
|
|
||||||
context.addLog(`사기치가 <C>${applied}</> 상승했습니다.`);
|
context.addLog(`사기치가 <C>${applied}</> 상승했습니다.`);
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
@@ -103,5 +125,10 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
new ActionDefinition({
|
new ActionDefinition({
|
||||||
atmosDelta: env.atmosDelta,
|
atmosDelta: env.atmosDelta,
|
||||||
maxAtmosByCommand: env.maxAtmosByCommand,
|
maxAtmosByCommand: env.maxAtmosByCommand,
|
||||||
|
...(env.trainSideEffectByAtmosTurn !== undefined
|
||||||
|
? { trainSideEffectByAtmosTurn: env.trainSideEffectByAtmosTurn }
|
||||||
|
: {}),
|
||||||
|
...(env.unitSet ? { unitSet: env.unitSet } : {}),
|
||||||
|
...(env.generalActionModules ? { generalActionModules: env.generalActionModules } : {}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
reqGeneralRice,
|
reqGeneralRice,
|
||||||
suppliedCity,
|
suppliedCity,
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
|
||||||
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
import type { TriggerDomesticActionType } from '@sammo-ts/logic/triggers/types.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 {
|
||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
@@ -20,19 +20,44 @@ import type {
|
|||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type {
|
||||||
|
ActionContextBase,
|
||||||
|
ActionContextBuilder,
|
||||||
|
ActionContextOptions,
|
||||||
|
ActionResolveContext,
|
||||||
|
} from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
|
|
||||||
export type DomesticCriticalPick = 'fail' | 'normal' | 'success';
|
export type DomesticCriticalPick = 'fail' | 'normal' | 'success';
|
||||||
|
|
||||||
export interface DomesticActionContext<
|
export interface DomesticBaseContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
|
||||||
> extends GeneralActionContext<TriggerState> {
|
|
||||||
general: General<TriggerState>;
|
general: General<TriggerState>;
|
||||||
city: City;
|
city: City;
|
||||||
nation?: Nation | null;
|
nation?: Nation | null;
|
||||||
|
relYear?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomesticActionContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||||
|
GeneralActionResolveContext<TriggerState> & DomesticBaseContext<TriggerState>;
|
||||||
|
|
||||||
|
export type DomesticStatKey = 'leadership' | 'strength' | 'intelligence';
|
||||||
|
export type DomesticCityKey = 'agriculture' | 'commerce' | 'security' | 'defence' | 'wall';
|
||||||
|
export type DomesticCityMaxKey = 'agricultureMax' | 'commerceMax' | 'securityMax' | 'defenceMax' | 'wallMax';
|
||||||
|
|
||||||
|
export interface InvestmentConfig {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
actionKey: TriggerDomesticActionType;
|
||||||
|
statKey: DomesticStatKey;
|
||||||
|
statExpKey: 'leadership_exp' | 'strength_exp' | 'intel_exp';
|
||||||
|
cityKey: DomesticCityKey;
|
||||||
|
cityMaxKey: DomesticCityMaxKey;
|
||||||
|
frontDebuff: number;
|
||||||
|
useCityTrust?: boolean;
|
||||||
|
scaleSuccessByTrust?: boolean;
|
||||||
|
roundCriticalScore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvestmentEnvironment {
|
export interface InvestmentEnvironment {
|
||||||
@@ -44,11 +69,13 @@ export interface InvestmentEnvironment {
|
|||||||
getCriticalRatio?: (context: DomesticActionContext, statKey: string) => { success: number; fail: number };
|
getCriticalRatio?: (context: DomesticActionContext, statKey: string) => { success: number; fail: number };
|
||||||
getCriticalScoreMultiplier?: (rng: RandomGenerator, pick: DomesticCriticalPick) => number;
|
getCriticalScoreMultiplier?: (rng: RandomGenerator, pick: DomesticCriticalPick) => number;
|
||||||
adjustFrontDebuff?: (context: DomesticActionContext, debuff: number) => number;
|
adjustFrontDebuff?: (context: DomesticActionContext, debuff: number) => number;
|
||||||
|
maxStatLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommerceInvestmentResult {
|
export interface CommerceInvestmentResult {
|
||||||
pick: DomesticCriticalPick;
|
pick: DomesticCriticalPick;
|
||||||
score: number;
|
score: number;
|
||||||
|
appliedScore: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
dedication: number;
|
dedication: number;
|
||||||
costGold: number;
|
costGold: number;
|
||||||
@@ -59,11 +86,17 @@ export interface CommerceInvestmentResult {
|
|||||||
export interface CommerceInvestmentArgs {}
|
export interface CommerceInvestmentArgs {}
|
||||||
|
|
||||||
const DEFAULT_TRUST = 50;
|
const DEFAULT_TRUST = 50;
|
||||||
const DEFAULT_FRONT_DEBUFF = 0.5;
|
|
||||||
const DEFAULT_FRONT_STATES = [1, 3];
|
const DEFAULT_FRONT_STATES = [1, 3];
|
||||||
const ACTION_NAME = '상업 투자';
|
const DEFAULT_CONFIG: InvestmentConfig = {
|
||||||
const CITY_KEY = 'commerce';
|
key: 'che_상업투자',
|
||||||
const STAT_EXP_KEY = 'intel_exp';
|
name: '상업 투자',
|
||||||
|
actionKey: '상업',
|
||||||
|
statKey: 'intelligence',
|
||||||
|
statExpKey: 'intel_exp',
|
||||||
|
cityKey: 'commerce',
|
||||||
|
cityMaxKey: 'commerceMax',
|
||||||
|
frontDebuff: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
const getMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
|
const getMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
|
||||||
const raw = meta[key];
|
const raw = meta[key];
|
||||||
@@ -78,7 +111,7 @@ const pickByWeight = (rng: RandomGenerator, weights: Record<DomesticCriticalPick
|
|||||||
return 'normal';
|
return 'normal';
|
||||||
}
|
}
|
||||||
let cursor = rng.nextFloat1() * total;
|
let cursor = rng.nextFloat1() * total;
|
||||||
for (const key of ['fail', 'normal', 'success'] as const) {
|
for (const key of ['fail', 'success', 'normal'] as const) {
|
||||||
cursor -= weights[key];
|
cursor -= weights[key];
|
||||||
if (cursor <= 0) {
|
if (cursor <= 0) {
|
||||||
return key;
|
return key;
|
||||||
@@ -92,10 +125,10 @@ const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMe
|
|||||||
return { ...meta, [key]: current + delta };
|
return { ...meta, [key]: current + delta };
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildDomesticContextFromView = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
export const buildDomesticContextFromView = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
ctx: ConstraintContext,
|
ctx: ConstraintContext,
|
||||||
view: StateView
|
view: StateView
|
||||||
): DomesticActionContext<TriggerState> | null => {
|
): DomesticBaseContext<TriggerState> | null => {
|
||||||
const general = view.get({
|
const general = view.get({
|
||||||
kind: 'general',
|
kind: 'general',
|
||||||
id: ctx.actorId,
|
id: ctx.actorId,
|
||||||
@@ -123,37 +156,56 @@ const buildDomesticContextFromView = <TriggerState extends GeneralTriggerState =
|
|||||||
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
private readonly env: InvestmentEnvironment;
|
private readonly env: InvestmentEnvironment;
|
||||||
private readonly actionKey = '상업';
|
private readonly config: InvestmentConfig;
|
||||||
private readonly statKey = 'intelligence';
|
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: InvestmentEnvironment) {
|
constructor(
|
||||||
|
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
|
env: InvestmentEnvironment,
|
||||||
|
config: InvestmentConfig = DEFAULT_CONFIG
|
||||||
|
) {
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
this.env = env;
|
this.env = env;
|
||||||
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCost(context: DomesticActionContext<TriggerState>): {
|
getCost(context: DomesticBaseContext<TriggerState>): {
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
} {
|
} {
|
||||||
const baseGold = this.env.develCost;
|
const baseGold = this.env.develCost;
|
||||||
const gold = Math.round(this.pipeline.onCalcDomestic(context, this.actionKey, 'cost', baseGold));
|
const gold = Math.round(this.pipeline.onCalcDomestic(context, this.config.actionKey, 'cost', baseGold));
|
||||||
return { gold, rice: 0 };
|
return { gold, rice: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
calcBaseScore(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): number {
|
calcBaseScore(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): number {
|
||||||
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
||||||
|
|
||||||
let score = this.pipeline.onCalcStat(context, this.statKey, context.general.stats.intelligence);
|
const injuryMultiplier = (100 - context.general.injury) / 100;
|
||||||
|
const rawStats = {
|
||||||
|
leadership: context.general.stats.leadership * injuryMultiplier,
|
||||||
|
strength: context.general.stats.strength * injuryMultiplier,
|
||||||
|
intelligence: context.general.stats.intelligence * injuryMultiplier,
|
||||||
|
};
|
||||||
|
if (this.config.statKey === 'strength') {
|
||||||
|
rawStats.strength += Math.round(rawStats.intelligence / 4);
|
||||||
|
} else if (this.config.statKey === 'intelligence') {
|
||||||
|
rawStats.intelligence += Math.round(rawStats.strength / 4);
|
||||||
|
}
|
||||||
|
const maxStatLevel = this.env.maxStatLevel ?? 255;
|
||||||
|
let score = clamp(rawStats[this.config.statKey], 0, maxStatLevel);
|
||||||
|
score = this.pipeline.onCalcStat(context, this.config.statKey, score);
|
||||||
|
|
||||||
const expLevel =
|
const expLevel =
|
||||||
getMetaNumber(context.general.meta, 'explevel') ?? getMetaNumber(context.general.meta, 'expLevel') ?? 0;
|
getMetaNumber(context.general.meta, 'explevel') ?? getMetaNumber(context.general.meta, 'expLevel') ?? 0;
|
||||||
const expBonus = this.env.getDomesticExpLevelBonus?.(expLevel) ?? 1;
|
const expBonus = this.env.getDomesticExpLevelBonus?.(expLevel) ?? 1 + expLevel / 500;
|
||||||
|
|
||||||
score *= trust / 100;
|
if (this.config.useCityTrust !== false) {
|
||||||
|
score *= trust / 100;
|
||||||
|
}
|
||||||
score *= expBonus;
|
score *= expBonus;
|
||||||
score *= randomRange(rng, 0.8, 1.2);
|
score *= randomRange(rng, 0.8, 1.2);
|
||||||
|
|
||||||
return this.pipeline.onCalcDomestic(context, this.actionKey, 'score', score);
|
return this.pipeline.onCalcDomestic(context, this.config.actionKey, 'score', score);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): CommerceInvestmentResult {
|
resolve(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): CommerceInvestmentResult {
|
||||||
@@ -161,17 +213,50 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
||||||
let score = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER);
|
let score = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
const ratio = this.env.getCriticalRatio?.(context, this.statKey) ?? {
|
const ratio =
|
||||||
success: 0,
|
this.env.getCriticalRatio?.(context, this.config.statKey) ??
|
||||||
fail: 0,
|
(() => {
|
||||||
};
|
const rawStats = {
|
||||||
|
leadership: context.general.stats.leadership,
|
||||||
|
strength: context.general.stats.strength + Math.round(context.general.stats.intelligence / 4),
|
||||||
|
intelligence: context.general.stats.intelligence + Math.round(context.general.stats.strength / 4),
|
||||||
|
};
|
||||||
|
const maxStatLevel = this.env.maxStatLevel ?? 255;
|
||||||
|
const leadership = this.pipeline.onCalcStat(
|
||||||
|
context,
|
||||||
|
'leadership',
|
||||||
|
clamp(rawStats.leadership, 0, maxStatLevel)
|
||||||
|
);
|
||||||
|
const strength = this.pipeline.onCalcStat(
|
||||||
|
context,
|
||||||
|
'strength',
|
||||||
|
clamp(rawStats.strength, 0, maxStatLevel)
|
||||||
|
);
|
||||||
|
const intelligence = this.pipeline.onCalcStat(
|
||||||
|
context,
|
||||||
|
'intelligence',
|
||||||
|
clamp(rawStats.intelligence, 0, maxStatLevel)
|
||||||
|
);
|
||||||
|
const average = (leadership + strength + intelligence) / 3;
|
||||||
|
const selected =
|
||||||
|
this.config.statKey === 'leadership'
|
||||||
|
? leadership
|
||||||
|
: this.config.statKey === 'strength'
|
||||||
|
? strength
|
||||||
|
: intelligence;
|
||||||
|
const value = Math.min(average / Math.max(selected, Number.EPSILON), 1.2);
|
||||||
|
return {
|
||||||
|
fail: clamp((value / 1.2) ** 1.4 - 0.3, 0, 0.5),
|
||||||
|
success: clamp((value / 1.2) ** 1.5 - 0.25, 0, 0.5),
|
||||||
|
};
|
||||||
|
})();
|
||||||
let successRatio = ratio.success;
|
let successRatio = ratio.success;
|
||||||
let failRatio = ratio.fail;
|
let failRatio = ratio.fail;
|
||||||
if (trust < 80) {
|
if (this.config.scaleSuccessByTrust !== false && trust < 80) {
|
||||||
successRatio *= trust / 80;
|
successRatio *= trust / 80;
|
||||||
}
|
}
|
||||||
successRatio = this.pipeline.onCalcDomestic(context, this.actionKey, 'success', successRatio);
|
successRatio = this.pipeline.onCalcDomestic(context, this.config.actionKey, 'success', successRatio);
|
||||||
failRatio = this.pipeline.onCalcDomestic(context, this.actionKey, 'fail', failRatio);
|
failRatio = this.pipeline.onCalcDomestic(context, this.config.actionKey, 'fail', failRatio);
|
||||||
|
|
||||||
successRatio = clamp(successRatio, 0, 1);
|
successRatio = clamp(successRatio, 0, 1);
|
||||||
failRatio = clamp(failRatio, 0, 1 - successRatio);
|
failRatio = clamp(failRatio, 0, 1 - successRatio);
|
||||||
@@ -183,24 +268,36 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
normal: normalRatio,
|
normal: normalRatio,
|
||||||
});
|
});
|
||||||
|
|
||||||
const criticalMultiplier = this.env.getCriticalScoreMultiplier?.(rng, pick) ?? 1;
|
const criticalMultiplier =
|
||||||
score = Math.round(score * criticalMultiplier);
|
this.env.getCriticalScoreMultiplier?.(rng, pick) ??
|
||||||
|
(pick === 'success' ? randomRange(rng, 2.2, 3) : pick === 'fail' ? randomRange(rng, 0.2, 0.4) : 1);
|
||||||
|
score *= criticalMultiplier;
|
||||||
|
if (this.config.roundCriticalScore !== false) {
|
||||||
|
score = Math.round(score);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewardScore = score;
|
||||||
const frontStates = this.env.frontStatesWithDebuff ?? DEFAULT_FRONT_STATES;
|
const frontStates = this.env.frontStatesWithDebuff ?? DEFAULT_FRONT_STATES;
|
||||||
let appliedFrontDebuff = false;
|
let appliedFrontDebuff = false;
|
||||||
if (frontStates.includes(context.city.frontState)) {
|
if (frontStates.includes(context.city.frontState)) {
|
||||||
const baseDebuff = this.env.frontDebuff ?? DEFAULT_FRONT_DEBUFF;
|
const baseDebuff = this.env.frontDebuff ?? this.config.frontDebuff;
|
||||||
const adjustedDebuff = this.env.adjustFrontDebuff?.(context, baseDebuff) ?? baseDebuff;
|
const adjustedDebuff = this.env.adjustFrontDebuff?.(context, baseDebuff) ?? baseDebuff;
|
||||||
score *= adjustedDebuff;
|
let debuff = adjustedDebuff;
|
||||||
|
if (context.nation?.capitalCityId === context.city.id && typeof context.relYear === 'number') {
|
||||||
|
const debuffScale = clamp(context.relYear - 5, 0, 20) * 0.05;
|
||||||
|
debuff = debuffScale * debuff + (1 - debuffScale);
|
||||||
|
}
|
||||||
|
score *= debuff;
|
||||||
appliedFrontDebuff = true;
|
appliedFrontDebuff = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exp = score * 0.7;
|
const exp = rewardScore * 0.7;
|
||||||
const dedication = score * 1.0;
|
const dedication = rewardScore;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pick,
|
pick,
|
||||||
score,
|
score: rewardScore,
|
||||||
|
appliedScore: score,
|
||||||
exp,
|
exp,
|
||||||
dedication,
|
dedication,
|
||||||
costGold,
|
costGold,
|
||||||
@@ -213,11 +310,18 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, CommerceInvestmentArgs> {
|
> implements GeneralActionResolver<TriggerState, CommerceInvestmentArgs> {
|
||||||
readonly key = 'che_상업투자';
|
readonly key: string;
|
||||||
private readonly command: CommandResolver<TriggerState>;
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
|
private readonly config: InvestmentConfig;
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: InvestmentEnvironment) {
|
constructor(
|
||||||
this.command = new CommandResolver(modules, env);
|
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
|
env: InvestmentEnvironment,
|
||||||
|
config: InvestmentConfig = DEFAULT_CONFIG
|
||||||
|
) {
|
||||||
|
this.key = config.key;
|
||||||
|
this.command = new CommandResolver(modules, env, config);
|
||||||
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
@@ -236,37 +340,43 @@ export class ActionResolver<
|
|||||||
...context,
|
...context,
|
||||||
city,
|
city,
|
||||||
nation: context.nation ?? null,
|
nation: context.nation ?? null,
|
||||||
|
relYear:
|
||||||
|
typeof (context as DomesticActionContext<TriggerState>).relYear === 'number'
|
||||||
|
? (context as DomesticActionContext<TriggerState>).relYear
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
context.rng
|
context.rng
|
||||||
);
|
);
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
// 직접 수정 (Immer Draft)
|
||||||
city.commerce = clamp(city.commerce + result.score, 0, city.commerceMax);
|
// 레거시 city 컬럼은 정수형이라 전선 debuff 뒤의 .5도 DB write 시 정수로 저장된다.
|
||||||
|
city[this.config.cityKey] = Math.round(
|
||||||
|
clamp(city[this.config.cityKey] + result.appliedScore, 0, city[this.config.cityMaxKey])
|
||||||
|
);
|
||||||
|
|
||||||
general.gold = Math.max(0, general.gold - result.costGold);
|
general.gold = Math.max(0, general.gold - result.costGold);
|
||||||
general.rice = Math.max(0, general.rice - result.costRice);
|
general.rice = Math.max(0, general.rice - result.costRice);
|
||||||
general.experience += result.exp;
|
general.experience += result.exp;
|
||||||
general.dedication += result.dedication;
|
general.dedication += result.dedication;
|
||||||
|
|
||||||
const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1);
|
const metaWithStatExp = addMetaNumber(general.meta, this.config.statExpKey, 1);
|
||||||
general.meta =
|
general.meta =
|
||||||
result.pick === 'success'
|
result.pick === 'success'
|
||||||
? { ...metaWithStatExp, max_domestic_critical: result.score }
|
? { ...metaWithStatExp, max_domestic_critical: result.score }
|
||||||
: { ...metaWithStatExp, max_domestic_critical: 0 };
|
: { ...metaWithStatExp, max_domestic_critical: 0 };
|
||||||
|
|
||||||
const scoreText = Math.round(result.score).toLocaleString();
|
const scoreText = Math.round(result.score).toLocaleString();
|
||||||
const logName = ACTION_NAME.replace(' ', '');
|
const josaUl = JosaUtil.pick(this.config.name, '을');
|
||||||
const josaUl = JosaUtil.pick(logName, '을');
|
|
||||||
if (result.pick === 'fail') {
|
if (result.pick === 'fail') {
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`${logName}${josaUl} <span class='ev_failed'>실패</span>하여 <C>${scoreText}</> 상승했습니다.`
|
`${this.config.name}${josaUl} <span class='ev_failed'>실패</span>하여 <C>${scoreText}</> 상승했습니다.`
|
||||||
);
|
);
|
||||||
} else if (result.pick === 'success') {
|
} else if (result.pick === 'success') {
|
||||||
context.addLog(`${logName}${josaUl} <S>성공</>하여 <C>${scoreText}</> 상승했습니다.`);
|
context.addLog(`${this.config.name}${josaUl} <S>성공</>하여 <C>${scoreText}</> 상승했습니다.`);
|
||||||
} else {
|
} else {
|
||||||
context.addLog(`${logName}${josaUl} 하여 <C>${scoreText}</> 상승했습니다.`);
|
context.addLog(`${this.config.name}${josaUl} 하여 <C>${scoreText}</> 상승했습니다.`);
|
||||||
}
|
}
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: this.config.name });
|
||||||
|
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
@@ -275,14 +385,22 @@ export class ActionResolver<
|
|||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, CommerceInvestmentArgs> {
|
> implements GeneralActionDefinition<TriggerState, CommerceInvestmentArgs> {
|
||||||
public readonly key = 'che_상업투자';
|
public readonly key: string;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name: string;
|
||||||
private readonly command: CommandResolver<TriggerState>;
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
private readonly config: InvestmentConfig;
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: InvestmentEnvironment) {
|
constructor(
|
||||||
this.command = new CommandResolver(modules, env);
|
modules: Array<GeneralActionModule<TriggerState> | null | undefined>,
|
||||||
this.resolver = new ActionResolver(modules, env);
|
env: InvestmentEnvironment,
|
||||||
|
config: InvestmentConfig = DEFAULT_CONFIG
|
||||||
|
) {
|
||||||
|
this.key = config.key;
|
||||||
|
this.name = config.name;
|
||||||
|
this.config = config;
|
||||||
|
this.command = new CommandResolver(modules, env, config);
|
||||||
|
this.resolver = new ActionResolver(modules, env, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): CommerceInvestmentArgs | null {
|
parseArgs(_raw: unknown): CommerceInvestmentArgs | null {
|
||||||
@@ -315,7 +433,7 @@ export class ActionDefinition<
|
|||||||
suppliedCity(),
|
suppliedCity(),
|
||||||
reqGeneralGold(getCost, requirements),
|
reqGeneralGold(getCost, requirements),
|
||||||
reqGeneralRice(() => 0, requirements),
|
reqGeneralRice(() => 0, requirements),
|
||||||
remainCityCapacity(CITY_KEY, ACTION_NAME),
|
remainCityCapacity(this.config.cityKey, this.config.name),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,8 +445,17 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export const actionContextBuilder: ActionContextBuilder = (
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
base: ActionContextBase,
|
||||||
|
options: ActionContextOptions
|
||||||
|
): ActionResolveContext => ({
|
||||||
|
...base,
|
||||||
|
city: base.city!,
|
||||||
|
nation: base.nation ?? null,
|
||||||
|
...(typeof options.scenarioMeta?.startYear === 'number'
|
||||||
|
? { relYear: options.world.currentYear - options.scenarioMeta.startYear }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_상업투자',
|
key: 'che_상업투자',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { 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 {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
@@ -25,13 +25,17 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
import {
|
||||||
|
buildStrategyActionContext,
|
||||||
|
CommandResolver as StrategyCommandResolver,
|
||||||
|
type FireAttackResolveContext,
|
||||||
|
} from './che_화계.js';
|
||||||
|
|
||||||
export interface AgitateResolveContext<
|
export interface AgitateResolveContext<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends GeneralActionResolveContext<TriggerState> {
|
> extends FireAttackResolveContext<TriggerState> {
|
||||||
destCity?: City;
|
|
||||||
env?: TurnCommandEnv;
|
env?: TurnCommandEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,39 +51,51 @@ export class ActionResolver<
|
|||||||
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
private readonly command: StrategyCommandResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = []) {
|
constructor(env: TurnCommandEnv) {
|
||||||
|
const modules = env.generalActionModules ?? [];
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
this.command = new StrategyCommandResolver<TriggerState>(modules, {
|
||||||
|
...env,
|
||||||
|
statKey: 'leadership',
|
||||||
|
damageMode: 'agitate',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const ctx = context as AgitateResolveContext<TriggerState>;
|
const ctx = context as AgitateResolveContext<TriggerState>;
|
||||||
const general = ctx.general;
|
const general = ctx.general;
|
||||||
const { destCityId } = args;
|
|
||||||
const destCity = ctx.destCity;
|
const destCity = ctx.destCity;
|
||||||
if (!destCity) throw new Error('Target city missing');
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = env?.develCost ?? 100;
|
|
||||||
|
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
const city = ctx.city;
|
||||||
// Damage calc
|
if (!city) throw new Error('Source city missing');
|
||||||
const min = env?.sabotageDamageMin ?? 10;
|
const result = this.command.resolve(
|
||||||
const max = env?.sabotageDamageMax ?? 30;
|
{
|
||||||
const rng = ctx.rng;
|
...ctx,
|
||||||
if (!rng) throw new Error('RNG missing');
|
city,
|
||||||
|
destCity,
|
||||||
const secuDmg = rng.nextInt(min, max + 1);
|
destGenerals: ctx.destGenerals,
|
||||||
const trustDmg = rng.nextInt(min, max + 1) / 50;
|
},
|
||||||
|
ctx.rng
|
||||||
const newSecu = Math.max(0, destCity.security - secuDmg);
|
);
|
||||||
|
general.gold = Math.max(0, general.gold - result.costGold);
|
||||||
|
general.rice = Math.max(0, general.rice - result.costRice);
|
||||||
|
general.experience += result.exp;
|
||||||
|
general.dedication += result.dedication;
|
||||||
|
general.meta.leadership_exp =
|
||||||
|
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1;
|
||||||
|
if (!result.success) {
|
||||||
|
ctx.addLog(
|
||||||
|
`<G><b>${destCity.name}</b></>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.`
|
||||||
|
);
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
||||||
|
const newSecu = Math.max(0, destCity.security - result.agriDamage);
|
||||||
const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50;
|
const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50;
|
||||||
const newTrust = Math.max(0, currentTrust - trustDmg);
|
const newTrust = Math.max(0, currentTrust - result.commDamage);
|
||||||
|
|
||||||
const actualSecuDmg = destCity.security - newSecu;
|
|
||||||
const actualTrustDmg = currentTrust - newTrust;
|
|
||||||
const injuryCount = 0;
|
|
||||||
|
|
||||||
// Log
|
// Log
|
||||||
const commandName = ACTION_NAME;
|
const commandName = ACTION_NAME;
|
||||||
@@ -89,9 +105,9 @@ export class ActionResolver<
|
|||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
ctx.addLog(
|
ctx.addLog(
|
||||||
`도시의 치안이 <C>${actualSecuDmg}</>, 민심이 <C>${actualTrustDmg.toFixed(
|
`도시의 치안이 <C>${result.agriDamage}</>, 민심이 <C>${result.commDamage.toFixed(
|
||||||
1
|
1
|
||||||
)}</>만큼 감소하고, 장수 <C>${injuryCount}</>명이 부상 당했습니다.`,
|
)}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
||||||
{
|
{
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
@@ -110,30 +126,14 @@ export class ActionResolver<
|
|||||||
trust: newTrust,
|
trust: newTrust,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
destCityId
|
args.destCityId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
consumeSuccessfulStrategyItem(this.pipeline, context);
|
consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
for (const injured of result.injuredGenerals) {
|
||||||
// General Update (Cost + Exp + LeaderExp)
|
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
|
||||||
effects.push(
|
}
|
||||||
createGeneralPatchEffect(
|
|
||||||
{
|
|
||||||
...general,
|
|
||||||
gold: Math.max(0, general.gold - cost),
|
|
||||||
rice: Math.max(0, general.rice - cost),
|
|
||||||
experience: general.experience + 50,
|
|
||||||
dedication: general.dedication + 30,
|
|
||||||
meta: {
|
|
||||||
...general.meta,
|
|
||||||
leadership_exp:
|
|
||||||
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
general.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
@@ -147,9 +147,7 @@ export class ActionDefinition<
|
|||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver<TriggerState>(
|
this.resolver = new ActionResolver<TriggerState>(env);
|
||||||
(env.generalActionModules ?? []) as GeneralActionModule<TriggerState>[]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): AgitateArgs | null {
|
parseArgs(raw: unknown): AgitateArgs | null {
|
||||||
@@ -158,13 +156,13 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
buildMinConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
buildMinConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = (env.develCost as number) ?? 100;
|
const cost = ((env.develCost as number) ?? 100) * 5;
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = (env.develCost as number) ?? 100;
|
const cost = ((env.develCost as number) ?? 100) * 5;
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
occupiedCity(),
|
occupiedCity(),
|
||||||
@@ -185,14 +183,10 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
const destCityId = options.actionArgs?.destCityId;
|
const strategyContext = buildStrategyActionContext(base, options);
|
||||||
let destCity = null;
|
if (!strategyContext) return null;
|
||||||
if (typeof destCityId === 'number' && options.worldRef) {
|
|
||||||
destCity = options.worldRef.getCityById(destCityId);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...strategyContext,
|
||||||
destCity,
|
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, AbdicationArgs, AbdicationResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, AbdicationArgs, AbdicationResolveContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): AbdicationArgs | null {
|
parseArgs(raw: unknown): AbdicationArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
@@ -49,7 +52,10 @@ export class ActionDefinition<
|
|||||||
return [beLord(), existsDestGeneral(), friendlyDestGeneral()];
|
return [beLord(), existsDestGeneral(), friendlyDestGeneral()];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: AbdicationResolveContext<TriggerState>, _args: AbdicationArgs): GeneralActionOutcome<TriggerState> {
|
resolve(
|
||||||
|
context: AbdicationResolveContext<TriggerState>,
|
||||||
|
_args: AbdicationArgs
|
||||||
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const nation = context.nation;
|
const nation = context.nation;
|
||||||
const destGeneral = context.destGeneral;
|
const destGeneral = context.destGeneral;
|
||||||
@@ -81,10 +87,13 @@ export class ActionDefinition<
|
|||||||
const josaYi = JosaUtil.pick(general.name, '이');
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||||
|
|
||||||
context.addLog(`<Y><b>【선양】</b></><Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>의 군주 자리를 <Y>${destGeneral.name}</>에게 선양했습니다.`, {
|
context.addLog(
|
||||||
scope: LogScope.SYSTEM,
|
`<Y><b>【선양】</b></><Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>의 군주 자리를 <Y>${destGeneral.name}</>에게 선양했습니다.`,
|
||||||
category: LogCategory.HISTORY,
|
{
|
||||||
});
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
}
|
||||||
|
);
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${destGeneral.name}</>에게 선양`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <Y>${destGeneral.name}</>에게 선양`, {
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
|
import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends CityDevelopmentActionDefinition<TriggerState> {
|
> extends DomesticActionDefinition<TriggerState> {
|
||||||
constructor(env: { develCost?: number; amount?: number } = {}) {
|
constructor(env: TurnCommandEnv) {
|
||||||
super(
|
super(env.generalActionModules ?? [], env, {
|
||||||
{
|
key: 'che_성벽보수',
|
||||||
key: 'che_성벽보수',
|
name: '성벽 보수',
|
||||||
name: '성벽 보수',
|
actionKey: '성벽',
|
||||||
statKey: 'wall',
|
statKey: 'strength',
|
||||||
maxKey: 'wallMax',
|
statExpKey: 'strength_exp',
|
||||||
label: '성벽',
|
cityKey: 'wall',
|
||||||
baseAmount: 50,
|
cityMaxKey: 'wallMax',
|
||||||
},
|
frontDebuff: 0.25,
|
||||||
env
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export { actionContextBuilder };
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_성벽보수',
|
key: 'che_성벽보수',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition
|
|||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { clamp } from 'es-toolkit';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
|
|
||||||
export interface DisbandArgs {}
|
export interface DisbandArgs {}
|
||||||
|
|
||||||
@@ -15,6 +15,11 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, DisbandArgs> {
|
> implements GeneralActionDefinition<TriggerState, DisbandArgs> {
|
||||||
public readonly key = 'che_소집해제';
|
public readonly key = 'che_소집해제';
|
||||||
public readonly name = '소집해제';
|
public readonly name = '소집해제';
|
||||||
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
|
||||||
|
constructor(env: TurnCommandEnv) {
|
||||||
|
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): DisbandArgs | null {
|
parseArgs(_raw: unknown): DisbandArgs | null {
|
||||||
return {};
|
return {};
|
||||||
@@ -35,13 +40,11 @@ export class ActionDefinition<
|
|||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const crew = general.crew;
|
const crewUp = this.pipeline.onCalcDomestic(context, '징집인구', 'score', general.crew);
|
||||||
const currentPop = city.population;
|
|
||||||
const maxPop = city.populationMax;
|
|
||||||
|
|
||||||
const nextPop = clamp(currentPop + crew, 0, maxPop);
|
|
||||||
general.crew = 0;
|
general.crew = 0;
|
||||||
city.population = nextPop;
|
city.population += Math.trunc(crewUp);
|
||||||
|
general.experience += 70;
|
||||||
|
general.dedication += 100;
|
||||||
|
|
||||||
context.addLog(`병사들을 <R>소집해제</>하였습니다.`);
|
context.addLog(`병사들을 <R>소집해제</>하였습니다.`);
|
||||||
|
|
||||||
@@ -56,5 +59,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
category: '군사',
|
category: '군사',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
|
||||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
|
import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends CityDevelopmentActionDefinition<TriggerState> {
|
> extends DomesticActionDefinition<TriggerState> {
|
||||||
constructor(env: { develCost?: number; amount?: number } = {}) {
|
constructor(env: TurnCommandEnv) {
|
||||||
super(
|
super(env.generalActionModules ?? [], env, {
|
||||||
{
|
key: 'che_수비강화',
|
||||||
key: 'che_수비강화',
|
name: '수비 강화',
|
||||||
name: '수비 강화',
|
actionKey: '수비',
|
||||||
statKey: 'defence',
|
statKey: 'strength',
|
||||||
maxKey: 'defenceMax',
|
statExpKey: 'strength_exp',
|
||||||
label: '수비',
|
cityKey: 'defence',
|
||||||
baseAmount: 50,
|
cityMaxKey: 'defenceMax',
|
||||||
},
|
frontDebuff: 0.5,
|
||||||
env
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export { actionContextBuilder };
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_수비강화',
|
key: 'che_수비강화',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface DexTransferContext<
|
|||||||
|
|
||||||
export interface DexTransferEnvironment {
|
export interface DexTransferEnvironment {
|
||||||
develCost?: number;
|
develCost?: number;
|
||||||
|
unitSet?: UnitSetDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '숙련전환';
|
const ACTION_NAME = '숙련전환';
|
||||||
@@ -49,7 +50,19 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): DexTransferArgs | null {
|
parseArgs(raw: unknown): DexTransferArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const armTypes = this.env.unitSet?.armTypes;
|
||||||
|
if (
|
||||||
|
armTypes &&
|
||||||
|
(!Object.prototype.hasOwnProperty.call(armTypes, String(parsed.srcArmType)) ||
|
||||||
|
!Object.prototype.hasOwnProperty.call(armTypes, String(parsed.destArmType)))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: DexTransferArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: DexTransferArgs): Constraint[] {
|
||||||
@@ -104,5 +117,9 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { srcArmType: 0, destArmType: 0 },
|
availabilityArgs: { srcArmType: 0, destArmType: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: ARGS_SCHEMA,
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
|
createDefinition: (env: TurnCommandEnv) =>
|
||||||
|
new ActionDefinition({
|
||||||
|
develCost: env.develCost,
|
||||||
|
...(env.unitSet ? { unitSet: env.unitSet } : {}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export interface RecoveryEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '요양';
|
const ACTION_NAME = '요양';
|
||||||
const DEFAULT_INJURY_DELTA = 10;
|
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
@@ -41,13 +40,14 @@ export class ActionDefinition<
|
|||||||
_args: RecoveryArgs
|
_args: RecoveryArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const delta = this.env.injuryDelta ?? DEFAULT_INJURY_DELTA;
|
const nextInjury = 0;
|
||||||
const nextInjury = Math.max(0, general.injury - delta);
|
|
||||||
const costGold = this.env.costGold ?? 0;
|
const costGold = this.env.costGold ?? 0;
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
// 직접 수정 (Immer Draft)
|
||||||
general.injury = nextInjury;
|
general.injury = nextInjury;
|
||||||
general.gold = Math.max(0, general.gold - costGold);
|
general.gold = Math.max(0, general.gold - costGold);
|
||||||
|
general.experience += 10;
|
||||||
|
general.dedication += 7;
|
||||||
|
|
||||||
context.addLog(`건강 회복을 위해 요양합니다.`);
|
context.addLog(`건강 회복을 위해 요양합니다.`);
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import type {
|
|||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
export interface RetireArgs {}
|
export interface RetireArgs {}
|
||||||
|
|
||||||
@@ -42,57 +43,91 @@ export class ActionResolver<
|
|||||||
resolve(context: GeneralActionResolveContext<TriggerState>, _args: RetireArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, _args: RetireArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
|
|
||||||
// Logs
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
context.addLog(`은퇴하였습니다.`, {
|
const nextMeta = { ...general.meta };
|
||||||
|
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
|
||||||
|
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
|
||||||
|
nextMeta[key] = Math.round(value * 0.5);
|
||||||
|
}
|
||||||
|
nextMeta.specAge = 0;
|
||||||
|
nextMeta.specAge2 = 0;
|
||||||
|
nextMeta.firenum = 0;
|
||||||
|
for (const key of [
|
||||||
|
'warnum',
|
||||||
|
'killnum',
|
||||||
|
'deathnum',
|
||||||
|
'killcrew',
|
||||||
|
'deathcrew',
|
||||||
|
'ttw',
|
||||||
|
'ttd',
|
||||||
|
'ttl',
|
||||||
|
'ttg',
|
||||||
|
'ttp',
|
||||||
|
'tlw',
|
||||||
|
'tld',
|
||||||
|
'tll',
|
||||||
|
'tlg',
|
||||||
|
'tlp',
|
||||||
|
'tsw',
|
||||||
|
'tsd',
|
||||||
|
'tsl',
|
||||||
|
'tsg',
|
||||||
|
'tsp',
|
||||||
|
'tiw',
|
||||||
|
'tid',
|
||||||
|
'til',
|
||||||
|
'tig',
|
||||||
|
'tip',
|
||||||
|
'betwin',
|
||||||
|
'betgold',
|
||||||
|
'betwingold',
|
||||||
|
'killcrew_person',
|
||||||
|
'deathcrew_person',
|
||||||
|
'occupied',
|
||||||
|
'inherit_earned',
|
||||||
|
'inherit_spent',
|
||||||
|
'inherit_earned_dyn',
|
||||||
|
'inherit_earned_act',
|
||||||
|
'inherit_spent_dyn',
|
||||||
|
]) {
|
||||||
|
nextMeta[`rank_${key}`] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
|
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.RAWTEXT,
|
||||||
|
});
|
||||||
|
context.addLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.', {
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
});
|
||||||
|
context.addLog('나이가 들어 은퇴하고, 자손에게 관직을 물려줌', {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
});
|
||||||
|
context.addLog('은퇴하였습니다.', {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rebirth Logic (Simulated)
|
|
||||||
// 1. Reset Stats (Randomize slightly?)
|
|
||||||
// Let's keep total stat points but re-distribute or small variation?
|
|
||||||
// Legacy: complete re-roll usually.
|
|
||||||
// Simple implementation: Random re-roll around average 70?
|
|
||||||
|
|
||||||
const rng = context.rng;
|
|
||||||
const newLead = rng.nextInt(30, 90);
|
|
||||||
const newStr = rng.nextInt(30, 90);
|
|
||||||
const newIntel = rng.nextInt(30, 90);
|
|
||||||
|
|
||||||
// 2. Reset Age
|
|
||||||
const newAge = 20;
|
|
||||||
|
|
||||||
// 3. Reset Exp/Ded
|
|
||||||
const newExp = 0;
|
|
||||||
const newDed = 0;
|
|
||||||
|
|
||||||
// 4. Reset Meta (Inheritance points?)
|
|
||||||
// Legacy: increases inheritance point.
|
|
||||||
// We'll increment inheritance point in meta.
|
|
||||||
const inheritance =
|
|
||||||
(typeof general.meta.inheritance_point === 'number' ? general.meta.inheritance_point : 0) + 1;
|
|
||||||
|
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
{
|
{
|
||||||
...general,
|
...general,
|
||||||
age: newAge,
|
age: 20,
|
||||||
stats: {
|
stats: {
|
||||||
leadership: newLead,
|
leadership: Math.max(10, Math.round(general.stats.leadership * 0.85)),
|
||||||
strength: newStr,
|
strength: Math.max(10, Math.round(general.stats.strength * 0.85)),
|
||||||
intelligence: newIntel,
|
intelligence: Math.max(10, Math.round(general.stats.intelligence * 0.85)),
|
||||||
},
|
|
||||||
experience: newExp,
|
|
||||||
dedication: newDed,
|
|
||||||
meta: {
|
|
||||||
...general.meta,
|
|
||||||
inheritance_point: inheritance,
|
|
||||||
// Clear other temp vars?
|
|
||||||
},
|
},
|
||||||
|
injury: 0,
|
||||||
|
experience: Math.round(general.experience * 0.5),
|
||||||
|
dedication: Math.round(general.dedication * 0.5),
|
||||||
|
meta: nextMeta,
|
||||||
},
|
},
|
||||||
general.id
|
general.id
|
||||||
)
|
)
|
||||||
@@ -113,6 +148,14 @@ export class ActionDefinition<
|
|||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPreReqTurn(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPostReqTurn(): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): RetireArgs | null {
|
parseArgs(_raw: unknown): RetireArgs | null {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ export class ActionResolver<
|
|||||||
|
|
||||||
const statKey = pickStatExpKey(context.rng, general);
|
const statKey = pickStatExpKey(context.rng, general);
|
||||||
const metaAfter = found ? addMetaNumber(general.meta, statKey, 3) : addMetaNumber(general.meta, statKey, 1);
|
const metaAfter = found ? addMetaNumber(general.meta, statKey, 3) : addMetaNumber(general.meta, statKey, 1);
|
||||||
|
if (found) {
|
||||||
|
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
|
||||||
|
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
|
||||||
|
}
|
||||||
|
|
||||||
const nextGold = Math.max(0, general.gold - reqGold);
|
const nextGold = Math.max(0, general.gold - reqGold);
|
||||||
const nextRice = Math.max(0, general.rice - reqRice);
|
const nextRice = Math.max(0, general.rice - reqRice);
|
||||||
@@ -319,12 +323,7 @@ export class ActionResolver<
|
|||||||
? this.env.decorateName(resolvedCandidate.name, NPC_TYPE)
|
? this.env.decorateName(resolvedCandidate.name, NPC_TYPE)
|
||||||
: resolvedCandidate.name;
|
: resolvedCandidate.name;
|
||||||
const meta: GeneralMeta = {
|
const meta: GeneralMeta = {
|
||||||
killturn: resolveKillturnFromDeathYear(
|
killturn: resolveKillturnFromDeathYear(context.currentYear, context.currentMonth, deathYear, context.rng),
|
||||||
context.currentYear,
|
|
||||||
context.currentMonth,
|
|
||||||
deathYear,
|
|
||||||
context.rng
|
|
||||||
),
|
|
||||||
npcType: NPC_TYPE,
|
npcType: NPC_TYPE,
|
||||||
crewTypeId: this.env.defaultCrewTypeId,
|
crewTypeId: this.env.defaultCrewTypeId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState, Nation } 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 {
|
||||||
beNeutral,
|
beNeutral,
|
||||||
@@ -10,14 +10,19 @@ import {
|
|||||||
} 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 { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type {
|
||||||
|
ActionContextBase,
|
||||||
|
ActionContextBuilder,
|
||||||
|
ActionContextOptions,
|
||||||
|
} from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
const ACTION_NAME = '임관';
|
const ACTION_NAME = '임관';
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
@@ -25,6 +30,14 @@ const ARGS_SCHEMA = z.object({
|
|||||||
});
|
});
|
||||||
export type AppointmentArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type AppointmentArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
|
interface AppointmentContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destNation?: Nation;
|
||||||
|
destNationGeneralCount: number;
|
||||||
|
destCityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
const parseNationId = (raw: unknown): number | null => {
|
const parseNationId = (raw: unknown): number | null => {
|
||||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -37,6 +50,11 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, AppointmentArgs> {
|
> implements GeneralActionDefinition<TriggerState, AppointmentArgs> {
|
||||||
public readonly key = 'che_임관';
|
public readonly key = 'che_임관';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
constructor(private readonly env: TurnCommandEnv) {}
|
||||||
|
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): AppointmentArgs | null {
|
parseArgs(raw: unknown): AppointmentArgs | null {
|
||||||
const data = parseArgsWithSchema(ARGS_SCHEMA, raw);
|
const data = parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
@@ -61,9 +79,11 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
||||||
const env = _ctx.env;
|
const env = _ctx.env;
|
||||||
const year = typeof env.year === 'number' ? env.year : 0;
|
const relYear =
|
||||||
const startYear = typeof env.startyear === 'number' ? env.startyear : 0;
|
typeof env.relYear === 'number'
|
||||||
const relYear = year - startYear;
|
? env.relYear
|
||||||
|
: (typeof env.year === 'number' ? env.year : 0) -
|
||||||
|
(typeof env.startYear === 'number' ? env.startYear : 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
||||||
@@ -75,31 +95,82 @@ export class ActionDefinition<
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(context: AppointmentContext<TriggerState>, args: AppointmentArgs): GeneralActionOutcome<TriggerState> {
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
const destNation = context.destNation;
|
||||||
args: AppointmentArgs
|
const destNationName = destNation?.name ?? `${args.destNationId}`;
|
||||||
): GeneralActionOutcome<TriggerState> {
|
|
||||||
const destNationName = context.nation?.name ?? `${args.destNationId}`;
|
|
||||||
context.addLog(`<D>${destNationName}</>에 임관했습니다.`, {
|
context.addLog(`<D>${destNationName}</>에 임관했습니다.`, {
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
context.addLog(`<D><b>${destNationName}</b></>에 임관`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
});
|
||||||
|
const josaYi = JosaUtil.pick(context.general.name, '이');
|
||||||
|
context.addLog(`<Y>${context.general.name}</>${josaYi} <D><b>${destNationName}</b></>에 <S>임관</>했습니다.`, {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.RAWTEXT,
|
||||||
|
});
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
const effects = [
|
const effects: GeneralActionOutcome<TriggerState>['effects'] = [
|
||||||
createGeneralPatchEffect<TriggerState>({
|
createGeneralPatchEffect<TriggerState>({
|
||||||
nationId: args.destNationId,
|
nationId: args.destNationId,
|
||||||
officerLevel: 1, // Common Officer
|
officerLevel: 1,
|
||||||
|
cityId: context.destCityId,
|
||||||
|
troopId: 0,
|
||||||
|
experience:
|
||||||
|
context.general.experience +
|
||||||
|
(context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100),
|
||||||
|
meta: {
|
||||||
|
...context.general.meta,
|
||||||
|
officer_city: 0,
|
||||||
|
belong: 1,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
if (destNation) {
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
...destNation.meta,
|
||||||
|
gennum: context.destNationGeneralCount + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
destNation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
export const actionContextBuilder: ActionContextBuilder<AppointmentArgs> = (
|
||||||
|
base: ActionContextBase,
|
||||||
|
options: ActionContextOptions<AppointmentArgs>
|
||||||
|
) => {
|
||||||
|
const worldRef = options.worldRef;
|
||||||
|
if (!worldRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const destNation = worldRef.getNationById(options.actionArgs.destNationId);
|
||||||
|
if (!destNation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nationGenerals = worldRef.listGenerals().filter((general) => general.nationId === destNation.id);
|
||||||
|
const monarch = nationGenerals.find((general) => general.officerLevel === 12);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destNation,
|
||||||
|
destNationGeneralCount: nationGenerals.length,
|
||||||
|
destCityId: monarch?.cityId ?? destNation.capitalCityId ?? base.general.cityId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_임관',
|
key: 'che_임관',
|
||||||
@@ -107,5 +178,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
reqArg: true,
|
reqArg: true,
|
||||||
availabilityArgs: { destNationId: 0 },
|
availabilityArgs: { destNationId: 0 },
|
||||||
argsSchema: ARGS_SCHEMA,
|
argsSchema: ARGS_SCHEMA,
|
||||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import { allowJoinAction, beNeutral, reqEnvValue, unknownOrDeny } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
allowJoinAction,
|
|
||||||
beNeutral,
|
|
||||||
reqEnvValue,
|
|
||||||
unknownOrDeny,
|
|
||||||
} 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 { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
@@ -93,7 +88,10 @@ const allowJoinDestNation = (destGeneralID: number): Constraint => ({
|
|||||||
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
|
return { kind: 'deny', reason: '국가 정보가 없습니다.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const relYear = typeof view.get({ kind: 'env', key: 'relYear' }) === 'number' ? (view.get({ kind: 'env', key: 'relYear' }) as number) : 0;
|
const relYear =
|
||||||
|
typeof view.get({ kind: 'env', key: 'relYear' }) === 'number'
|
||||||
|
? (view.get({ kind: 'env', key: 'relYear' }) as number)
|
||||||
|
: 0;
|
||||||
const openingPartYear =
|
const openingPartYear =
|
||||||
typeof view.get({ kind: 'env', key: 'openingPartYear' }) === 'number'
|
typeof view.get({ kind: 'env', key: 'openingPartYear' }) === 'number'
|
||||||
? (view.get({ kind: 'env', key: 'openingPartYear' }) as number)
|
? (view.get({ kind: 'env', key: 'openingPartYear' }) as number)
|
||||||
@@ -129,20 +127,23 @@ const allowJoinDestNation = (destGeneralID: number): Constraint => ({
|
|||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, FollowAppointmentArgs, FollowAppointmentResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<
|
||||||
|
TriggerState,
|
||||||
|
FollowAppointmentArgs,
|
||||||
|
FollowAppointmentResolveContext<TriggerState>
|
||||||
|
> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): FollowAppointmentArgs | null {
|
parseArgs(raw: unknown): FollowAppointmentArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
||||||
return [
|
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), beNeutral(), allowJoinAction()];
|
||||||
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
|
||||||
beNeutral(),
|
|
||||||
allowJoinAction(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, args: FollowAppointmentArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, args: FollowAppointmentArgs): Constraint[] {
|
||||||
@@ -233,7 +234,7 @@ export const actionContextBuilder: ActionContextBuilder<FollowAppointmentArgs> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destGeneral = worldRef.getGeneralById(destGeneralID) ?? undefined;
|
const destGeneral = worldRef.getGeneralById(destGeneralID) ?? undefined;
|
||||||
const destNation = destGeneral ? worldRef.getNationById(destGeneral.nationId) ?? undefined : undefined;
|
const destNation = destGeneral ? (worldRef.getNationById(destGeneral.nationId) ?? undefined) : undefined;
|
||||||
if (!destGeneral || !destNation) {
|
if (!destGeneral || !destNation) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralMeta, GeneralTriggerState, Nation } 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 {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
@@ -34,11 +34,6 @@ const readNationTech = (nation: Nation | null | undefined): number => {
|
|||||||
return typeof tech === 'number' ? tech : 0;
|
return typeof tech === 'number' ? tech : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const readBattleStanceTerm = (meta: Record<string, unknown>): number => {
|
|
||||||
const value = meta.battle_stance_term;
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, BattlePreparationArgs> {
|
> implements GeneralActionDefinition<TriggerState, BattlePreparationArgs> {
|
||||||
@@ -47,13 +42,20 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
constructor(private readonly env: TurnCommandEnv) {}
|
constructor(private readonly env: TurnCommandEnv) {}
|
||||||
|
|
||||||
|
getPreReqTurn(): number {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPostReqTurn(): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): BattlePreparationArgs | null {
|
parseArgs(_raw: unknown): BattlePreparationArgs | null {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: BattlePreparationArgs): Constraint[] {
|
buildConstraints(_ctx: ConstraintContext, _args: BattlePreparationArgs): Constraint[] {
|
||||||
const nationRequirement =
|
const nationRequirement = _ctx.nationId !== undefined ? [{ kind: 'nation', id: _ctx.nationId } as const] : [];
|
||||||
_ctx.nationId !== undefined ? [{ kind: 'nation', id: _ctx.nationId } as const] : [];
|
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
notWanderingNation(),
|
notWanderingNation(),
|
||||||
@@ -62,7 +64,9 @@ export class ActionDefinition<
|
|||||||
reqGeneralGold((ctx, view) => {
|
reqGeneralGold((ctx, view) => {
|
||||||
const general = view.get({ kind: 'general', id: ctx.actorId }) as { crew?: number } | null;
|
const general = view.get({ kind: 'general', id: ctx.actorId }) as { crew?: number } | null;
|
||||||
const nation =
|
const nation =
|
||||||
ctx.nationId !== undefined ? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null) : null;
|
ctx.nationId !== undefined
|
||||||
|
? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null)
|
||||||
|
: null;
|
||||||
const crew = typeof general?.crew === 'number' ? general.crew : 0;
|
const crew = typeof general?.crew === 'number' ? general.crew : 0;
|
||||||
const techCost = getTechCost(readNationTech(nation));
|
const techCost = getTechCost(readNationTech(nation));
|
||||||
return Math.round((crew / 100) * 3 * techCost);
|
return Math.round((crew / 100) * 3 * techCost);
|
||||||
@@ -78,13 +82,18 @@ export class ActionDefinition<
|
|||||||
_args: BattlePreparationArgs
|
_args: BattlePreparationArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const nation = context.nation;
|
|
||||||
const crew = general.crew;
|
const crew = general.crew;
|
||||||
const techCost = getTechCost(readNationTech(nation));
|
|
||||||
const costGold = Math.round((crew / 100) * 3 * techCost);
|
|
||||||
|
|
||||||
const previousTerm = readBattleStanceTerm(general.meta as Record<string, unknown>);
|
const lastTurn = general.lastTurn;
|
||||||
const term = previousTerm >= REQ_TERM ? 1 : previousTerm + 1;
|
const term =
|
||||||
|
lastTurn?.command !== ACTION_NAME
|
||||||
|
? 1
|
||||||
|
: lastTurn.term === REQ_TERM
|
||||||
|
? 1
|
||||||
|
: typeof lastTurn.term === 'number' && lastTurn.term < REQ_TERM
|
||||||
|
? lastTurn.term + 1
|
||||||
|
: 1;
|
||||||
|
const resultTurn = { command: ACTION_NAME, term };
|
||||||
|
|
||||||
if (term < REQ_TERM) {
|
if (term < REQ_TERM) {
|
||||||
context.addLog(`병사들을 열심히 훈련중... (${term}/3)`, {
|
context.addLog(`병사들을 열심히 훈련중... (${term}/3)`, {
|
||||||
@@ -95,11 +104,7 @@ export class ActionDefinition<
|
|||||||
return {
|
return {
|
||||||
effects: [
|
effects: [
|
||||||
createGeneralPatchEffect<TriggerState>({
|
createGeneralPatchEffect<TriggerState>({
|
||||||
gold: Math.max(0, general.gold - costGold),
|
lastTurn: resultTurn,
|
||||||
meta: {
|
|
||||||
...general.meta,
|
|
||||||
battle_stance_term: term,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -114,18 +119,26 @@ export class ActionDefinition<
|
|||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
||||||
|
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
|
||||||
|
const dexKey = crewType ? `dex${crewType.armType}` : null;
|
||||||
|
const nextMeta: GeneralMeta = {
|
||||||
|
...general.meta,
|
||||||
|
leadership_exp: leadershipExp + 3,
|
||||||
|
};
|
||||||
|
if (dexKey) {
|
||||||
|
const dex = typeof nextMeta[dexKey] === 'number' ? nextMeta[dexKey] : 0;
|
||||||
|
nextMeta[dexKey] = dex + (crew / 100) * 3;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
effects: [
|
effects: [
|
||||||
createGeneralPatchEffect<TriggerState>({
|
createGeneralPatchEffect<TriggerState>({
|
||||||
gold: Math.max(0, general.gold - costGold),
|
lastTurn: resultTurn,
|
||||||
|
train: Math.max(general.train, this.env.maxTrainByCommand - 5),
|
||||||
|
atmos: Math.max(general.atmos, this.env.maxAtmosByCommand - 5),
|
||||||
experience: general.experience + 300,
|
experience: general.experience + 300,
|
||||||
dedication: general.dedication + 210,
|
dedication: general.dedication + 210,
|
||||||
meta: {
|
meta: nextMeta,
|
||||||
...general.meta,
|
|
||||||
battle_stance_term: term,
|
|
||||||
leadership_exp: leadershipExp + 3,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { setMetaNumber } from '@sammo-ts/logic/war/utils.js';
|
import { setMetaNumber } from '@sammo-ts/logic/war/utils.js';
|
||||||
|
import { WAR_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/war/index.js';
|
||||||
|
|
||||||
export interface ResetSpecialWarArgs {}
|
export interface ResetSpecialWarArgs {}
|
||||||
|
|
||||||
@@ -38,6 +39,23 @@ export class ActionDefinition<
|
|||||||
public readonly key = 'che_전투특기초기화';
|
public readonly key = 'che_전투특기초기화';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
|
||||||
|
getPreReqTurn(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPostReqTurn(): number {
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProgressText(
|
||||||
|
_context: GeneralActionResolveContext<TriggerState>,
|
||||||
|
_args: ResetSpecialWarArgs,
|
||||||
|
term: number,
|
||||||
|
termMax: number
|
||||||
|
): string {
|
||||||
|
return `새로운 적성을 찾는 중... (${term}/${termMax})`;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): ResetSpecialWarArgs | null {
|
parseArgs(_raw: unknown): ResetSpecialWarArgs | null {
|
||||||
void _raw;
|
void _raw;
|
||||||
return {};
|
return {};
|
||||||
@@ -56,6 +74,13 @@ export class ActionDefinition<
|
|||||||
_args: ResetSpecialWarArgs
|
_args: ResetSpecialWarArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
|
const previous = general.meta.prev_types_special2;
|
||||||
|
const previousTypes = Array.isArray(previous)
|
||||||
|
? previous.filter((value): value is string => typeof value === 'string')
|
||||||
|
: [];
|
||||||
|
const nextPreviousTypes = [...previousTypes, general.role.specialWar!];
|
||||||
|
general.meta.prev_types_special2 =
|
||||||
|
nextPreviousTypes.length === WAR_TRAIT_KEYS.length ? [general.role.specialWar!] : nextPreviousTypes;
|
||||||
general.role.specialWar = null;
|
general.role.specialWar = null;
|
||||||
setMetaNumber(general.meta, 'specAge2', general.age + 1);
|
setMetaNumber(general.meta, 'specAge2', general.age + 1);
|
||||||
const specialName = ACTION_NAME.replace(' 초기화', '');
|
const specialName = ACTION_NAME.replace(' 초기화', '');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
notWanderingNation,
|
notWanderingNation,
|
||||||
@@ -12,74 +12,69 @@ import {
|
|||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import { clamp } from 'es-toolkit';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import {
|
||||||
|
actionContextBuilder,
|
||||||
|
buildDomesticContextFromView,
|
||||||
|
CommandResolver,
|
||||||
|
type DomesticActionContext,
|
||||||
|
type InvestmentConfig,
|
||||||
|
} from './che_상업투자.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
import { clamp } from 'es-toolkit';
|
||||||
|
|
||||||
export interface SettlementArgs {}
|
export interface SettlementArgs {}
|
||||||
type SettlementPick = 'success' | 'normal' | 'fail';
|
|
||||||
|
|
||||||
const pickByWeight = <T extends string>(rng: GeneralActionResolveContext['rng'], weights: Record<T, number>): T => {
|
const ACTION_NAME = '정착 장려';
|
||||||
const entries = Object.entries(weights) as Array<[T, number]>;
|
const CONFIG: InvestmentConfig = {
|
||||||
const first = entries[0];
|
key: 'che_정착장려',
|
||||||
if (!first) {
|
name: ACTION_NAME,
|
||||||
throw new Error('Empty weights');
|
actionKey: '인구',
|
||||||
}
|
statKey: 'leadership',
|
||||||
|
statExpKey: 'leadership_exp',
|
||||||
let total = 0;
|
cityKey: 'commerce',
|
||||||
for (const [, weight] of entries) {
|
cityMaxKey: 'commerceMax',
|
||||||
if (weight > 0) {
|
frontDebuff: 1,
|
||||||
total += weight;
|
useCityTrust: false,
|
||||||
}
|
scaleSuccessByTrust: false,
|
||||||
}
|
|
||||||
if (total <= 0) {
|
|
||||||
return first[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = rng.nextFloat1() * total;
|
|
||||||
for (const [key, weight] of entries) {
|
|
||||||
if (weight <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cursor -= weight;
|
|
||||||
if (cursor <= 0) {
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = entries[entries.length - 1];
|
|
||||||
return last ? last[0] : first[0];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, SettlementArgs> {
|
> implements GeneralActionDefinition<TriggerState, SettlementArgs> {
|
||||||
public readonly key = 'che_정착장려';
|
public readonly key = CONFIG.key;
|
||||||
public readonly name = '정착 장려';
|
public readonly name = ACTION_NAME;
|
||||||
private readonly env: { develCost?: number };
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(env: { develCost?: number } = {}) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.env = env;
|
this.command = new CommandResolver(
|
||||||
|
env.generalActionModules ?? [],
|
||||||
|
{ ...env, develCost: env.develCost * 2 },
|
||||||
|
CONFIG
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): SettlementArgs | null {
|
parseArgs(_raw: unknown): SettlementArgs | null {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(_ctx: ConstraintContext, _args: SettlementArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: SettlementArgs): Constraint[] {
|
||||||
const getRequiredRice = (_context: ConstraintContext, _view: StateView): number =>
|
const requirements: RequirementKey[] = [];
|
||||||
(this.env.develCost ?? 0) * 2;
|
if (ctx.cityId !== undefined) requirements.push({ kind: 'city', id: ctx.cityId });
|
||||||
|
if (ctx.nationId !== undefined) requirements.push({ kind: 'nation', id: ctx.nationId });
|
||||||
|
const getRiceCost = (context: ConstraintContext, view: StateView): number => {
|
||||||
|
const domesticContext = buildDomesticContextFromView<TriggerState>(context, view);
|
||||||
|
return domesticContext ? this.command.getCost(domesticContext).gold : 0;
|
||||||
|
};
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
notWanderingNation(),
|
notWanderingNation(),
|
||||||
occupiedCity(),
|
occupiedCity(),
|
||||||
suppliedCity(),
|
suppliedCity(),
|
||||||
reqGeneralGold(() => 0),
|
reqGeneralGold(() => 0, requirements),
|
||||||
remainCityCapacity('population', '인구'),
|
reqGeneralRice(getRiceCost, requirements),
|
||||||
reqGeneralRice(getRequiredRice),
|
remainCityCapacity('population', ACTION_NAME),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,54 +82,46 @@ export class ActionDefinition<
|
|||||||
context: GeneralActionResolveContext<TriggerState>,
|
context: GeneralActionResolveContext<TriggerState>,
|
||||||
_args: SettlementArgs
|
_args: SettlementArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
if (!context.city) {
|
||||||
const city = context.city;
|
|
||||||
if (!city) {
|
|
||||||
context.addLog('도시 정보를 찾지 못했습니다.');
|
context.addLog('도시 정보를 찾지 못했습니다.');
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
|
const domesticContext = context as DomesticActionContext<TriggerState>;
|
||||||
|
const result = this.command.resolve(domesticContext, context.rng);
|
||||||
|
const populationGain = result.score * 10;
|
||||||
|
context.city.population = clamp(context.city.population + populationGain, 0, context.city.populationMax);
|
||||||
|
context.general.rice = Math.max(0, context.general.rice - result.costGold);
|
||||||
|
context.general.experience += result.exp;
|
||||||
|
context.general.dedication += result.dedication;
|
||||||
|
const leadershipExp =
|
||||||
|
typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0;
|
||||||
|
context.general.meta = {
|
||||||
|
...context.general.meta,
|
||||||
|
leadership_exp: leadershipExp + 1,
|
||||||
|
max_domestic_critical: result.pick === 'success' ? result.score : 0,
|
||||||
|
};
|
||||||
|
|
||||||
const pick = pickByWeight<SettlementPick>(context.rng, {
|
const scoreText = populationGain.toLocaleString();
|
||||||
fail: 0.2,
|
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
success: 0.2,
|
if (result.pick === 'fail') {
|
||||||
normal: 0.6,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scoreCoef = pick === 'success' ? 1.3 : pick === 'fail' ? 0.7 : 1;
|
|
||||||
const baseAmount = Math.round(1000 * scoreCoef);
|
|
||||||
const current = city.population;
|
|
||||||
const max = city.populationMax;
|
|
||||||
|
|
||||||
const nextValue = clamp(current + baseAmount, 0, max);
|
|
||||||
const costRice = (this.env.develCost ?? 0) * 2;
|
|
||||||
|
|
||||||
city.population = nextValue;
|
|
||||||
general.rice = Math.max(0, general.rice - costRice);
|
|
||||||
|
|
||||||
const scoreText = (nextValue - current).toLocaleString();
|
|
||||||
const logName = this.name.replace(' ', '');
|
|
||||||
const josaUl = JosaUtil.pick(logName, '을');
|
|
||||||
if (pick === 'fail') {
|
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`${logName}${josaUl} <span class='ev_failed'>실패</span>하여 주민이 <C>${scoreText}</>명 증가했습니다.`
|
`${ACTION_NAME}${josaUl} <span class='ev_failed'>실패</span>하여 주민이 <C>${scoreText}</>명 증가했습니다.`
|
||||||
);
|
);
|
||||||
} else if (pick === 'success') {
|
} else if (result.pick === 'success') {
|
||||||
context.addLog(`${logName}${josaUl} <S>성공</>하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
|
context.addLog(`${ACTION_NAME}${josaUl} <S>성공</>하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
|
||||||
} else {
|
} else {
|
||||||
context.addLog(`${logName}${josaUl} 하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
|
context.addLog(`${ACTION_NAME}${josaUl} 하여 주민이 <C>${scoreText}</>명 증가했습니다.`);
|
||||||
}
|
}
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: '정착 장려' });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
export { actionContextBuilder };
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_정착장려',
|
key: 'che_정착장려',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { RandomGenerator } from '@sammo-ts/common';
|
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
|
|
||||||
import {
|
import {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
notWanderingNation,
|
notWanderingNation,
|
||||||
@@ -10,126 +8,81 @@ import {
|
|||||||
reqGeneralRice,
|
reqGeneralRice,
|
||||||
suppliedCity,
|
suppliedCity,
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
|
||||||
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import {
|
||||||
|
actionContextBuilder,
|
||||||
|
buildDomesticContextFromView,
|
||||||
|
CommandResolver,
|
||||||
|
type DomesticActionContext,
|
||||||
|
type InvestmentConfig,
|
||||||
|
} from './che_상업투자.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
|
|
||||||
export interface DomesticActionContext<
|
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
|
||||||
> extends GeneralActionContext<TriggerState> {
|
|
||||||
general: General<TriggerState>;
|
|
||||||
city: City;
|
|
||||||
nation?: Nation | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrustEnvironment {
|
|
||||||
develCost: number;
|
|
||||||
defaultTrust?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrustActionArgs {}
|
export interface TrustActionArgs {}
|
||||||
|
|
||||||
const ACTION_NAME = '주민 선정';
|
const ACTION_NAME = '주민 선정';
|
||||||
const ACTION_KEY = '민심';
|
|
||||||
const STAT_EXP_KEY = 'leadership_exp';
|
|
||||||
const DEFAULT_TRUST = 50;
|
const DEFAULT_TRUST = 50;
|
||||||
|
const CONFIG: InvestmentConfig = {
|
||||||
const getMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
|
key: 'che_주민선정',
|
||||||
const raw = meta[key];
|
name: ACTION_NAME,
|
||||||
return typeof raw === 'number' ? raw : null;
|
actionKey: '민심',
|
||||||
|
statKey: 'leadership',
|
||||||
|
statExpKey: 'leadership_exp',
|
||||||
|
cityKey: 'commerce',
|
||||||
|
cityMaxKey: 'commerceMax',
|
||||||
|
frontDebuff: 1,
|
||||||
|
useCityTrust: false,
|
||||||
|
scaleSuccessByTrust: false,
|
||||||
|
roundCriticalScore: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
|
const readTrust = (city: City): number => {
|
||||||
const current = getMetaNumber(meta, key) ?? 0;
|
const trust = city.meta.trust;
|
||||||
return { ...meta, [key]: current + delta };
|
return typeof trust === 'number' && Number.isFinite(trust) ? trust : DEFAULT_TRUST;
|
||||||
};
|
};
|
||||||
|
|
||||||
const randomRange = (rng: RandomGenerator, min: number, max: number): number => min + (max - min) * rng.nextFloat1();
|
|
||||||
|
|
||||||
const remainCityTrust = (): Constraint => ({
|
const remainCityTrust = (): Constraint => ({
|
||||||
name: 'remainCityTrust',
|
name: 'remainCityTrust',
|
||||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||||
test: (ctx, view) => {
|
test: (ctx, view) => {
|
||||||
const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null;
|
const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null;
|
||||||
if (!city) {
|
if (!city) return { kind: 'deny', reason: '도시 정보가 없습니다.' };
|
||||||
return { kind: 'deny', reason: '도시 정보가 없습니다.' };
|
return readTrust(city) >= 100 ? { kind: 'deny', reason: '민심이 충분합니다.' } : { kind: 'allow' };
|
||||||
}
|
|
||||||
const trust = getMetaNumber(city.meta, 'trust') ?? DEFAULT_TRUST;
|
|
||||||
if (trust >= 100) {
|
|
||||||
return { kind: 'deny', reason: '민심이 충분합니다.' };
|
|
||||||
}
|
|
||||||
return { kind: 'allow' };
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export class CommandResolver<
|
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
|
||||||
> {
|
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
|
||||||
private readonly env: TrustEnvironment;
|
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: TrustEnvironment) {
|
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
|
||||||
this.env = env;
|
|
||||||
}
|
|
||||||
|
|
||||||
calcBaseScore(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): number {
|
|
||||||
let score = this.pipeline.onCalcStat(context, 'leadership', context.general.stats.leadership);
|
|
||||||
score *= randomRange(rng, 0.8, 1.2);
|
|
||||||
return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'score', score);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): {
|
|
||||||
trustDelta: number;
|
|
||||||
exp: number;
|
|
||||||
dedication: number;
|
|
||||||
costRice: number;
|
|
||||||
} {
|
|
||||||
const costRice = (this.env.develCost ?? 0) * 2;
|
|
||||||
const baseScore = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER);
|
|
||||||
const exp = baseScore * 0.7;
|
|
||||||
const dedication = baseScore * 1.0;
|
|
||||||
const trustDelta = Math.round(baseScore) / 10;
|
|
||||||
return { trustDelta, exp, dedication, costRice };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, TrustActionArgs> {
|
> implements GeneralActionDefinition<TriggerState, TrustActionArgs> {
|
||||||
public readonly key = 'che_주민선정';
|
public readonly key = CONFIG.key;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
private readonly command: CommandResolver<TriggerState>;
|
private readonly command: CommandResolver<TriggerState>;
|
||||||
private readonly env: TrustEnvironment;
|
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: TrustEnvironment) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.env = env;
|
this.command = new CommandResolver(
|
||||||
this.command = new CommandResolver(modules, env);
|
env.generalActionModules ?? [],
|
||||||
|
{ ...env, develCost: env.develCost * 2 },
|
||||||
|
CONFIG
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): TrustActionArgs | null {
|
parseArgs(_raw: unknown): TrustActionArgs | null {
|
||||||
void _raw;
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: TrustActionArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: TrustActionArgs): Constraint[] {
|
||||||
void _args;
|
|
||||||
const requirements: RequirementKey[] = [];
|
const requirements: RequirementKey[] = [];
|
||||||
if (ctx.cityId !== undefined) {
|
if (ctx.cityId !== undefined) requirements.push({ kind: 'city', id: ctx.cityId });
|
||||||
requirements.push({ kind: 'city', id: ctx.cityId });
|
if (ctx.nationId !== undefined) requirements.push({ kind: 'nation', id: ctx.nationId });
|
||||||
}
|
const getRiceCost = (context: ConstraintContext, view: StateView): number => {
|
||||||
if (ctx.nationId !== undefined) {
|
const domesticContext = buildDomesticContextFromView<TriggerState>(context, view);
|
||||||
requirements.push({ kind: 'nation', id: ctx.nationId });
|
return domesticContext ? this.command.getCost(domesticContext).gold : 0;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getRiceCost = (): number => (this.env.develCost ?? 0) * 2;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
notWanderingNation(),
|
notWanderingNation(),
|
||||||
@@ -145,44 +98,48 @@ export class ActionDefinition<
|
|||||||
context: GeneralActionResolveContext<TriggerState>,
|
context: GeneralActionResolveContext<TriggerState>,
|
||||||
_args: TrustActionArgs
|
_args: TrustActionArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
if (!context.city) {
|
||||||
const city = context.city;
|
|
||||||
if (!city) {
|
|
||||||
context.addLog('도시 정보를 찾지 못했습니다.');
|
context.addLog('도시 정보를 찾지 못했습니다.');
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
const trust = getMetaNumber(city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
const result = this.command.resolve(context as DomesticActionContext<TriggerState>, context.rng);
|
||||||
const result = this.command.resolve(
|
const trustDelta = result.score / 10;
|
||||||
{
|
context.city.meta = {
|
||||||
...context,
|
...context.city.meta,
|
||||||
city,
|
trust: clamp(readTrust(context.city) + trustDelta, 0, 100),
|
||||||
nation: context.nation ?? null,
|
};
|
||||||
},
|
context.general.rice = Math.max(0, context.general.rice - result.costGold);
|
||||||
context.rng
|
context.general.experience += result.exp;
|
||||||
);
|
context.general.dedication += result.dedication;
|
||||||
|
const leadershipExp =
|
||||||
|
typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0;
|
||||||
|
context.general.meta = {
|
||||||
|
...context.general.meta,
|
||||||
|
leadership_exp: leadershipExp + 1,
|
||||||
|
max_domestic_critical: result.pick === 'success' ? result.score : 0,
|
||||||
|
};
|
||||||
|
|
||||||
const nextTrust = clamp(trust + result.trustDelta, 0, 100);
|
const scoreText = trustDelta.toFixed(1);
|
||||||
city.meta = { ...city.meta, trust: nextTrust };
|
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
general.rice = Math.max(0, general.rice - result.costRice);
|
if (result.pick === 'fail') {
|
||||||
general.experience += result.exp;
|
context.addLog(
|
||||||
general.dedication += result.dedication;
|
`${ACTION_NAME}${josaUl} <span class='ev_failed'>실패</span>하여 <C>${scoreText}</> 상승했습니다.`
|
||||||
general.meta = { ...addMetaNumber(general.meta, STAT_EXP_KEY, 1), max_domestic_critical: 0 };
|
);
|
||||||
|
} else if (result.pick === 'success') {
|
||||||
const scoreText = result.trustDelta.toFixed(1);
|
context.addLog(`${ACTION_NAME}${josaUl} <S>성공</>하여 <C>${scoreText}</> 상승했습니다.`);
|
||||||
const logName = ACTION_NAME.replace(' ', '');
|
} else {
|
||||||
const josaUl = JosaUtil.pick(logName, '을');
|
context.addLog(`${ACTION_NAME}${josaUl} 하여 <C>${scoreText}</> 상승했습니다.`);
|
||||||
context.addLog(`${logName}${josaUl} 하여 <C>${scoreText}</> 상승했습니다.`);
|
}
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
export { actionContextBuilder };
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_주민선정',
|
key: 'che_주민선정',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -290,6 +290,26 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
const base = this.pipeline.onCalcDomestic(context, '징집인구', 'score', amount);
|
const base = this.pipeline.onCalcDomestic(context, '징집인구', 'score', amount);
|
||||||
return Math.round(base);
|
return Math.round(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDexGain(
|
||||||
|
context: RecruitCalcContext<TriggerState>,
|
||||||
|
armType: number,
|
||||||
|
amount: number
|
||||||
|
): {
|
||||||
|
key: string;
|
||||||
|
amount: number;
|
||||||
|
} | null {
|
||||||
|
const dexArmType = armType === 0 ? 5 : armType;
|
||||||
|
if (dexArmType < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const typeMultiplier = dexArmType === 4 || dexArmType === 5 ? 0.9 : 1;
|
||||||
|
const amountWithType = (amount / 100) * typeMultiplier;
|
||||||
|
return {
|
||||||
|
key: `dex${dexArmType}`,
|
||||||
|
amount: this.pipeline.onCalcStat(context, 'addDex', amountWithType, { armType: dexArmType }),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
@@ -347,32 +367,36 @@ export class ActionResolver<
|
|||||||
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
|
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
|
||||||
const nextTrust = Math.max(baseTrust - trustLoss, 0);
|
const nextTrust = Math.max(baseTrust - trustLoss, 0);
|
||||||
|
|
||||||
let nextCrewTypeId = general.crewTypeId;
|
|
||||||
let nextCrew = general.crew;
|
|
||||||
let nextTrain = general.train;
|
|
||||||
let nextAtmos = general.atmos;
|
|
||||||
const actionName = this.env.actionName ?? ACTION_NAME;
|
const actionName = this.env.actionName ?? ACTION_NAME;
|
||||||
if (crewType.id === general.crewTypeId && general.crew > 0) {
|
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
|
||||||
nextCrew = general.crew + appliedCrew;
|
crewType.id === general.crewTypeId && general.crew > 0
|
||||||
nextTrain = Math.round(
|
? (() => {
|
||||||
(general.crew * general.train + appliedCrew * setTrain) / (general.crew + appliedCrew)
|
context.addLog(
|
||||||
);
|
`${crewType.name} <C>${appliedCrew.toLocaleString()}</>명을 추가${actionName}했습니다.`
|
||||||
nextAtmos = Math.round(
|
);
|
||||||
(general.crew * general.atmos + appliedCrew * setAtmos) / (general.crew + appliedCrew)
|
return [
|
||||||
);
|
general.crewTypeId,
|
||||||
context.addLog(`${crewType.name} <C>${appliedCrew.toLocaleString()}</>명을 추가${actionName}했습니다.`);
|
general.crew + appliedCrew,
|
||||||
} else {
|
Math.round(
|
||||||
nextCrewTypeId = crewType.id;
|
(general.crew * general.train + appliedCrew * setTrain) / (general.crew + appliedCrew)
|
||||||
nextCrew = appliedCrew;
|
),
|
||||||
nextTrain = Math.round(setTrain);
|
Math.round(
|
||||||
nextAtmos = Math.round(setAtmos);
|
(general.crew * general.atmos + appliedCrew * setAtmos) / (general.crew + appliedCrew)
|
||||||
context.addLog(`${crewType.name} <C>${appliedCrew.toLocaleString()}</>명을 ${actionName}했습니다.`);
|
),
|
||||||
}
|
] as const;
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
context.addLog(
|
||||||
|
`${crewType.name} <C>${appliedCrew.toLocaleString()}</>명을 ${actionName}했습니다.`
|
||||||
|
);
|
||||||
|
return [crewType.id, appliedCrew, Math.round(setTrain), Math.round(setAtmos)] as const;
|
||||||
|
})();
|
||||||
|
|
||||||
const nextGold = Math.max(0, general.gold - plan.gold);
|
const nextGold = Math.max(0, general.gold - plan.gold);
|
||||||
const nextRice = Math.max(0, general.rice - plan.rice);
|
const nextRice = Math.max(0, general.rice - plan.rice);
|
||||||
const expGain = Math.round(appliedCrew / 100);
|
const expGain = Math.round(appliedCrew / 100);
|
||||||
const dedGain = Math.round(appliedCrew / 100);
|
const dedGain = Math.round(appliedCrew / 100);
|
||||||
|
const dexGain = this.command.getDexGain(context, crewType.armType, appliedCrew);
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
// 직접 수정 (Immer Draft)
|
||||||
city.population = nextPopulation;
|
city.population = nextPopulation;
|
||||||
@@ -390,6 +414,9 @@ export class ActionResolver<
|
|||||||
general.experience += expGain;
|
general.experience += expGain;
|
||||||
general.dedication += dedGain;
|
general.dedication += dedGain;
|
||||||
general.meta = addMetaNumber(general.meta, 'leadership_exp', 1);
|
general.meta = addMetaNumber(general.meta, 'leadership_exp', 1);
|
||||||
|
if (dexGain) {
|
||||||
|
general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount);
|
||||||
|
}
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
|
|||||||
@@ -255,8 +255,8 @@ export class ActionResolver<
|
|||||||
...general,
|
...general,
|
||||||
gold: Math.max(0, general.gold - cost),
|
gold: Math.max(0, general.gold - cost),
|
||||||
rice: Math.max(0, general.rice - cost),
|
rice: Math.max(0, general.rice - cost),
|
||||||
experience: general.experience + 50,
|
experience: general.experience + ctx.rng.nextInt(1, 101),
|
||||||
dedication: general.dedication + 30,
|
dedication: general.dedication + ctx.rng.nextInt(1, 71),
|
||||||
meta: {
|
meta: {
|
||||||
...general.meta,
|
...general.meta,
|
||||||
leadership_exp:
|
leadership_exp:
|
||||||
@@ -276,6 +276,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, SpyArgs, GeneralActionResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, SpyArgs, GeneralActionResolveContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -295,11 +298,7 @@ export class ActionDefinition<
|
|||||||
buildConstraints(ctx: ConstraintContext, _args: SpyArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: SpyArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = ((env.develCost as number) ?? 100) * 3;
|
const cost = ((env.develCost as number) ?? 100) * 3;
|
||||||
return [
|
return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
||||||
notOccupiedDestCity(),
|
|
||||||
reqGeneralGold(() => cost),
|
|
||||||
reqGeneralRice(() => cost),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: SpyArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SpyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, DispatchArgs, DispatchResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, DispatchArgs, DispatchResolveContext<TriggerState>> {
|
||||||
public readonly key = 'che_출병';
|
public readonly key = 'che_출병';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
private readonly warModules: Array<WarActionModule<TriggerState>>;
|
private readonly warModules: Array<WarActionModule<TriggerState>>;
|
||||||
|
|
||||||
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined> = []) {
|
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined> = []) {
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
|
import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends CityDevelopmentActionDefinition<TriggerState> {
|
> extends DomesticActionDefinition<TriggerState> {
|
||||||
constructor(env: { develCost?: number; amount?: number } = {}) {
|
constructor(env: TurnCommandEnv) {
|
||||||
super(
|
super(env.generalActionModules ?? [], env, {
|
||||||
{
|
key: 'che_치안강화',
|
||||||
key: 'che_치안강화',
|
name: '치안 강화',
|
||||||
name: '치안 강화',
|
actionKey: '치안',
|
||||||
statKey: 'security',
|
statKey: 'strength',
|
||||||
maxKey: 'securityMax',
|
statExpKey: 'strength_exp',
|
||||||
label: '치안',
|
cityKey: 'security',
|
||||||
baseAmount: 50,
|
cityMaxKey: 'securityMax',
|
||||||
},
|
frontDebuff: 1,
|
||||||
env
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export { actionContextBuilder };
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_치안강화',
|
key: 'che_치안강화',
|
||||||
category: '내정',
|
category: '내정',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
|
||||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { 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 {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
@@ -17,11 +17,7 @@ import type {
|
|||||||
GeneralActionResolver,
|
GeneralActionResolver,
|
||||||
GeneralActionEffect,
|
GeneralActionEffect,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import {
|
import { createCityPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
createGeneralPatchEffect,
|
|
||||||
createCityPatchEffect,
|
|
||||||
createNationPatchEffect,
|
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
@@ -29,16 +25,20 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
import {
|
||||||
|
buildStrategyActionContext,
|
||||||
|
CommandResolver as StrategyCommandResolver,
|
||||||
|
type FireAttackResolveContext,
|
||||||
|
} from './che_화계.js';
|
||||||
|
|
||||||
export interface SeizeResolveContext<
|
export interface SeizeResolveContext<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends GeneralActionResolveContext<TriggerState> {
|
> extends FireAttackResolveContext<TriggerState> {
|
||||||
destCity?: City;
|
|
||||||
destNation?: Nation | null;
|
|
||||||
env?: TurnCommandEnv;
|
env?: TurnCommandEnv;
|
||||||
year?: number;
|
year?: number;
|
||||||
|
startYear?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '탈취';
|
const ACTION_NAME = '탈취';
|
||||||
@@ -53,41 +53,53 @@ export class ActionResolver<
|
|||||||
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
|
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
private readonly command: StrategyCommandResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = []) {
|
constructor(env: TurnCommandEnv) {
|
||||||
|
const modules = env.generalActionModules ?? [];
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
this.command = new StrategyCommandResolver<TriggerState>(modules, {
|
||||||
|
...env,
|
||||||
|
statKey: 'strength',
|
||||||
|
damageMode: 'seize',
|
||||||
|
injuryGeneral: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const ctx = context as SeizeResolveContext<TriggerState>;
|
const ctx = context as SeizeResolveContext<TriggerState>;
|
||||||
const general = ctx.general;
|
const general = ctx.general;
|
||||||
const nation = ctx.nation; // Own nation
|
const nation = ctx.nation; // Own nation
|
||||||
const { destCityId } = args;
|
|
||||||
const destCity = ctx.destCity;
|
const destCity = ctx.destCity;
|
||||||
const destNation = ctx.destNation;
|
const destNation = ctx.destNation;
|
||||||
|
|
||||||
if (!destCity) throw new Error('Target city missing');
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = env?.develCost ?? 100;
|
|
||||||
|
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
const city = ctx.city;
|
||||||
// Calculation
|
if (!city) throw new Error('Source city missing');
|
||||||
const min = env?.sabotageDamageMin ?? 10;
|
const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng);
|
||||||
const max = env?.sabotageDamageMax ?? 30;
|
general.gold = Math.max(0, general.gold - result.costGold);
|
||||||
const rng = ctx.rng;
|
general.rice = Math.max(0, general.rice - result.costRice);
|
||||||
if (!rng) throw new Error('RNG missing');
|
general.experience += result.exp;
|
||||||
|
general.dedication += result.dedication;
|
||||||
|
general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1;
|
||||||
|
if (!result.success) {
|
||||||
|
ctx.addLog(
|
||||||
|
`<G><b>${destCity.name}</b></>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.`
|
||||||
|
);
|
||||||
|
return { effects };
|
||||||
|
}
|
||||||
|
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
||||||
|
|
||||||
const currentYear = ctx.year ?? 200;
|
const currentYear = ctx.year ?? 200;
|
||||||
const startYear = env?.openingPartYear ?? currentYear;
|
const startYear = ctx.startYear ?? currentYear;
|
||||||
const yearCoef = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2;
|
const yearCoef = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2;
|
||||||
|
|
||||||
const commRatio = destCity.commerce / destCity.commerceMax;
|
const commRatio = destCity.commerce / destCity.commerceMax;
|
||||||
const agriRatio = destCity.agriculture / destCity.agricultureMax;
|
const agriRatio = destCity.agriculture / destCity.agricultureMax;
|
||||||
|
|
||||||
const rawGold = rng.nextInt(min, max + 1) * destCity.level * yearCoef * (0.25 + commRatio / 4);
|
const rawGold = result.agriDamage * destCity.level * yearCoef * (0.25 + commRatio / 4);
|
||||||
const rawRice = rng.nextInt(min, max + 1) * destCity.level * yearCoef * (0.25 + agriRatio / 4);
|
const rawRice = result.commDamage * destCity.level * yearCoef * (0.25 + agriRatio / 4);
|
||||||
|
|
||||||
let stolenGold = Math.floor(rawGold);
|
let stolenGold = Math.floor(rawGold);
|
||||||
let stolenRice = Math.floor(rawRice);
|
let stolenRice = Math.floor(rawRice);
|
||||||
@@ -95,8 +107,8 @@ export class ActionResolver<
|
|||||||
const isSupplied = destCity.supplyState === 1;
|
const isSupplied = destCity.supplyState === 1;
|
||||||
|
|
||||||
if (isSupplied && destNation) {
|
if (isSupplied && destNation) {
|
||||||
const minGold = 1000;
|
const minGold = 0;
|
||||||
const minRice = 1000;
|
const minRice = 0;
|
||||||
|
|
||||||
const availableGold = Math.max(0, destNation.gold - minGold);
|
const availableGold = Math.max(0, destNation.gold - minGold);
|
||||||
const availableRice = Math.max(0, destNation.rice - minRice);
|
const availableRice = Math.max(0, destNation.rice - minRice);
|
||||||
@@ -121,7 +133,7 @@ export class ActionResolver<
|
|||||||
...destCity,
|
...destCity,
|
||||||
state: 34,
|
state: 34,
|
||||||
},
|
},
|
||||||
destCityId
|
args.destCityId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -136,7 +148,7 @@ export class ActionResolver<
|
|||||||
agriculture: Math.max(0, destCity.agriculture - agriDmg),
|
agriculture: Math.max(0, destCity.agriculture - agriDmg),
|
||||||
state: 34,
|
state: 34,
|
||||||
},
|
},
|
||||||
destCityId
|
args.destCityId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,8 +157,8 @@ export class ActionResolver<
|
|||||||
let myShareRice = stolenRice;
|
let myShareRice = stolenRice;
|
||||||
|
|
||||||
if (nation && nation.id !== 0) {
|
if (nation && nation.id !== 0) {
|
||||||
const nationShareGold = Math.floor(stolenGold * 0.7);
|
const nationShareGold = Math.round(stolenGold * 0.7);
|
||||||
const nationShareRice = Math.floor(stolenRice * 0.7);
|
const nationShareRice = Math.round(stolenRice * 0.7);
|
||||||
myShareGold -= nationShareGold;
|
myShareGold -= nationShareGold;
|
||||||
myShareRice -= nationShareRice;
|
myShareRice -= nationShareRice;
|
||||||
|
|
||||||
@@ -174,24 +186,8 @@ export class ActionResolver<
|
|||||||
});
|
});
|
||||||
|
|
||||||
consumeSuccessfulStrategyItem(this.pipeline, context);
|
consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
general.gold += myShareGold;
|
||||||
effects.push(
|
general.rice += myShareRice;
|
||||||
createGeneralPatchEffect(
|
|
||||||
{
|
|
||||||
...general,
|
|
||||||
gold: Math.max(0, general.gold - cost + myShareGold),
|
|
||||||
rice: Math.max(0, general.rice - cost + myShareRice),
|
|
||||||
experience: general.experience + 50,
|
|
||||||
dedication: general.dedication + 30,
|
|
||||||
meta: {
|
|
||||||
...general.meta,
|
|
||||||
strength_exp:
|
|
||||||
(typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
general.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
@@ -205,9 +201,7 @@ export class ActionDefinition<
|
|||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver<TriggerState>(
|
this.resolver = new ActionResolver<TriggerState>(env);
|
||||||
(env.generalActionModules ?? []) as GeneralActionModule<TriggerState>[]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): SeizeArgs | null {
|
parseArgs(raw: unknown): SeizeArgs | null {
|
||||||
@@ -216,13 +210,13 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
buildMinConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
buildMinConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = (env.develCost as number) ?? 100;
|
const cost = ((env.develCost as number) ?? 100) * 5;
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = (env.develCost as number) ?? 100;
|
const cost = ((env.develCost as number) ?? 100) * 5;
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
occupiedCity(),
|
occupiedCity(),
|
||||||
@@ -243,21 +237,13 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
const destCityId = options.actionArgs?.destCityId;
|
const strategyContext = buildStrategyActionContext(base, options);
|
||||||
let destCity = null;
|
if (!strategyContext) return null;
|
||||||
let destNation = null;
|
|
||||||
if (typeof destCityId === 'number' && options.worldRef) {
|
|
||||||
destCity = options.worldRef.getCityById(destCityId);
|
|
||||||
if (destCity && destCity.nationId) {
|
|
||||||
destNation = options.worldRef.getNationById(destCity.nationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...strategyContext,
|
||||||
destCity,
|
|
||||||
destNation,
|
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
year: options.world.currentYear,
|
year: options.world.currentYear,
|
||||||
|
startYear: options.scenarioMeta?.startYear ?? options.world.currentYear,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { 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 {
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
@@ -25,13 +25,17 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac
|
|||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
import {
|
||||||
|
buildStrategyActionContext,
|
||||||
|
CommandResolver as StrategyCommandResolver,
|
||||||
|
type FireAttackResolveContext,
|
||||||
|
} from './che_화계.js';
|
||||||
|
|
||||||
export interface DestroyResolveContext<
|
export interface DestroyResolveContext<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends GeneralActionResolveContext<TriggerState> {
|
> extends FireAttackResolveContext<TriggerState> {
|
||||||
destCity?: City;
|
|
||||||
env?: TurnCommandEnv;
|
env?: TurnCommandEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,39 +51,41 @@ export class ActionResolver<
|
|||||||
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
|
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||||
|
private readonly command: StrategyCommandResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined> = []) {
|
constructor(env: TurnCommandEnv) {
|
||||||
|
const modules = env.generalActionModules ?? [];
|
||||||
this.pipeline = new GeneralActionPipeline(modules);
|
this.pipeline = new GeneralActionPipeline(modules);
|
||||||
|
this.command = new StrategyCommandResolver<TriggerState>(modules, {
|
||||||
|
...env,
|
||||||
|
statKey: 'strength',
|
||||||
|
damageMode: 'destroy',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const ctx = context as DestroyResolveContext<TriggerState>;
|
const ctx = context as DestroyResolveContext<TriggerState>;
|
||||||
const general = ctx.general;
|
const general = ctx.general;
|
||||||
const { destCityId } = args;
|
|
||||||
const destCity = ctx.destCity;
|
const destCity = ctx.destCity;
|
||||||
if (!destCity) throw new Error('Target city missing');
|
if (!destCity) throw new Error('Target city missing');
|
||||||
|
|
||||||
const env = ctx.env;
|
|
||||||
const cost = env?.develCost ?? 100; // 1x develCost
|
|
||||||
|
|
||||||
const effects: GeneralActionEffect<TriggerState>[] = [];
|
const effects: GeneralActionEffect<TriggerState>[] = [];
|
||||||
|
const city = ctx.city;
|
||||||
// Damage calc
|
if (!city) throw new Error('Source city missing');
|
||||||
const min = env?.sabotageDamageMin ?? 10;
|
const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng);
|
||||||
const max = env?.sabotageDamageMax ?? 30;
|
general.gold = Math.max(0, general.gold - result.costGold);
|
||||||
const rng = ctx.rng;
|
general.rice = Math.max(0, general.rice - result.costRice);
|
||||||
|
general.experience += result.exp;
|
||||||
if (!rng) throw new Error('RNG missing');
|
general.dedication += result.dedication;
|
||||||
|
general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1;
|
||||||
const defDmg = rng.nextInt(min, max + 1);
|
if (!result.success) {
|
||||||
const wallDmg = rng.nextInt(min, max + 1);
|
ctx.addLog(
|
||||||
|
`<G><b>${destCity.name}</b></>에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.`
|
||||||
const newDef = Math.max(0, destCity.defence - defDmg);
|
);
|
||||||
const newWall = Math.max(0, destCity.wall - wallDmg);
|
return { effects };
|
||||||
|
}
|
||||||
const actualDefDmg = destCity.defence - newDef;
|
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
|
||||||
const actualWallDmg = destCity.wall - newWall;
|
const newDef = Math.max(0, destCity.defence - result.agriDamage);
|
||||||
const injuryCount = 0;
|
const newWall = Math.max(0, destCity.wall - result.commDamage);
|
||||||
|
|
||||||
// Log
|
// Log
|
||||||
const commandName = ACTION_NAME;
|
const commandName = ACTION_NAME;
|
||||||
@@ -89,7 +95,7 @@ export class ActionResolver<
|
|||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
ctx.addLog(
|
ctx.addLog(
|
||||||
`도시의 수비가 <C>${actualDefDmg}</>, 성벽이 <C>${actualWallDmg}</>만큼 감소하고, 장수 <C>${injuryCount}</>명이 부상 당했습니다.`,
|
`도시의 수비가 <C>${result.agriDamage}</>, 성벽이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
|
||||||
{
|
{
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.PLAIN,
|
||||||
@@ -105,30 +111,14 @@ export class ActionResolver<
|
|||||||
wall: newWall,
|
wall: newWall,
|
||||||
state: 32, // Legacy sabotage state
|
state: 32, // Legacy sabotage state
|
||||||
},
|
},
|
||||||
destCityId
|
args.destCityId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
consumeSuccessfulStrategyItem(this.pipeline, context);
|
consumeSuccessfulStrategyItem(this.pipeline, context);
|
||||||
|
for (const injured of result.injuredGenerals) {
|
||||||
// General Update (Cost + Exp)
|
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
|
||||||
effects.push(
|
}
|
||||||
createGeneralPatchEffect(
|
|
||||||
{
|
|
||||||
...general,
|
|
||||||
gold: Math.max(0, general.gold - cost),
|
|
||||||
rice: Math.max(0, general.rice - cost),
|
|
||||||
experience: general.experience + 50,
|
|
||||||
dedication: general.dedication + 30,
|
|
||||||
meta: {
|
|
||||||
...general.meta,
|
|
||||||
strength_exp:
|
|
||||||
(typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
general.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
@@ -142,9 +132,7 @@ export class ActionDefinition<
|
|||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor(env: TurnCommandEnv) {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver<TriggerState>(
|
this.resolver = new ActionResolver<TriggerState>(env);
|
||||||
(env.generalActionModules ?? []) as GeneralActionModule<TriggerState>[]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): DestroyArgs | null {
|
parseArgs(raw: unknown): DestroyArgs | null {
|
||||||
@@ -153,13 +141,13 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
buildMinConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
buildMinConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = (env.develCost as number) ?? 100;
|
const cost = ((env.develCost as number) ?? 100) * 5;
|
||||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
|
||||||
const env = ctx.env;
|
const env = ctx.env;
|
||||||
const cost = (env.develCost as number) ?? 100;
|
const cost = ((env.develCost as number) ?? 100) * 5;
|
||||||
return [
|
return [
|
||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
occupiedCity(),
|
occupiedCity(),
|
||||||
@@ -180,14 +168,10 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
const destCityId = options.actionArgs?.destCityId;
|
const strategyContext = buildStrategyActionContext(base, options);
|
||||||
let destCity = null;
|
if (!strategyContext) return null;
|
||||||
if (typeof destCityId === 'number' && options.worldRef) {
|
|
||||||
destCity = options.worldRef.getCityById(destCityId);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...strategyContext,
|
||||||
destCity,
|
|
||||||
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
env: options.scenarioConfig.const as unknown as TurnCommandEnv,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { 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 { notBeNeutral, notLord } from '@sammo-ts/logic/constraints/presets.js';
|
import { notBeNeutral, notLord } 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';
|
||||||
@@ -8,12 +8,18 @@ import type {
|
|||||||
GeneralActionResolver,
|
GeneralActionResolver,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
export interface ResignArgs {}
|
export interface ResignArgs {}
|
||||||
|
interface ResignContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
troopMembers: General<TriggerState>[];
|
||||||
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '하야';
|
const ACTION_NAME = '하야';
|
||||||
const ACTION_KEY = 'che_하야';
|
const ACTION_KEY = 'che_하야';
|
||||||
@@ -22,8 +28,9 @@ export class ActionResolver<
|
|||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, ResignArgs> {
|
> implements GeneralActionResolver<TriggerState, ResignArgs> {
|
||||||
readonly key = ACTION_KEY;
|
readonly key = ACTION_KEY;
|
||||||
|
constructor(private readonly env: TurnCommandEnv) {}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, _args: ResignArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: ResignContext<TriggerState>, _args: ResignArgs): GeneralActionOutcome<TriggerState> {
|
||||||
const general = context.general;
|
const general = context.general;
|
||||||
const nation = context.nation;
|
const nation = context.nation;
|
||||||
|
|
||||||
@@ -31,11 +38,11 @@ export class ActionResolver<
|
|||||||
throw new Error('Resign requires a nation context.');
|
throw new Error('Resign requires a nation context.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const effects = [];
|
const effects: GeneralActionOutcome<TriggerState>['effects'] = [];
|
||||||
|
|
||||||
// Return resources
|
// Return resources
|
||||||
const maxKeepGold = 1000;
|
const maxKeepGold = this.env.defaultNpcGold;
|
||||||
const maxKeepRice = 1000;
|
const maxKeepRice = this.env.defaultNpcRice;
|
||||||
|
|
||||||
const newGold = Math.min(general.gold, maxKeepGold);
|
const newGold = Math.min(general.gold, maxKeepGold);
|
||||||
const newRice = Math.min(general.rice, maxKeepRice);
|
const newRice = Math.min(general.rice, maxKeepRice);
|
||||||
@@ -57,8 +64,8 @@ export class ActionResolver<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Penalty
|
// Penalty
|
||||||
const betrayal = (general.meta.betrayal as number) ?? 0;
|
const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0;
|
||||||
const penaltyRatio = 0.1 + betrayal * 0.05;
|
const penaltyRatio = betrayal * 0.1;
|
||||||
const nextExp = Math.floor(general.experience * (1 - penaltyRatio));
|
const nextExp = Math.floor(general.experience * (1 - penaltyRatio));
|
||||||
const nextDed = Math.floor(general.dedication * (1 - penaltyRatio));
|
const nextDed = Math.floor(general.dedication * (1 - penaltyRatio));
|
||||||
|
|
||||||
@@ -66,6 +73,16 @@ export class ActionResolver<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
context.addLog(`<D><b>${nation.name}</b></>에서 하야`, {
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
});
|
||||||
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
|
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${nation.name}</b></>에서 <R>하야</>했습니다.`, {
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.RAWTEXT,
|
||||||
|
});
|
||||||
|
|
||||||
effects.push(
|
effects.push(
|
||||||
createGeneralPatchEffect(
|
createGeneralPatchEffect(
|
||||||
@@ -73,18 +90,40 @@ export class ActionResolver<
|
|||||||
...general,
|
...general,
|
||||||
nationId: 0,
|
nationId: 0,
|
||||||
officerLevel: 0,
|
officerLevel: 0,
|
||||||
|
troopId: 0,
|
||||||
gold: newGold,
|
gold: newGold,
|
||||||
rice: newRice,
|
rice: newRice,
|
||||||
experience: nextExp,
|
experience: nextExp,
|
||||||
dedication: nextDed,
|
dedication: nextDed,
|
||||||
meta: {
|
meta: {
|
||||||
...general.meta,
|
...general.meta,
|
||||||
betrayal: betrayal + 1,
|
betray: Math.min(9, betrayal + 1),
|
||||||
|
belong: 0,
|
||||||
|
makelimit: 12,
|
||||||
|
officer_city: 0,
|
||||||
|
permission: 'normal',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
general.id
|
general.id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (general.troopId === general.id) {
|
||||||
|
for (const member of context.troopMembers) {
|
||||||
|
effects.push(createGeneralPatchEffect({ troopId: 0 }, member.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gennum = typeof nation.meta.gennum === 'number' ? nation.meta.gennum : 0;
|
||||||
|
effects.push(
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
...nation.meta,
|
||||||
|
gennum: Math.max(0, gennum - (general.npcState === 5 ? 0 : 1)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nation.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
@@ -92,13 +131,16 @@ export class ActionResolver<
|
|||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, ResignArgs, GeneralActionResolveContext<TriggerState>> {
|
> implements GeneralActionDefinition<TriggerState, ResignArgs, ResignContext<TriggerState>> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
private readonly resolver: ActionResolver<TriggerState>;
|
private readonly resolver: ActionResolver<TriggerState>;
|
||||||
|
|
||||||
constructor() {
|
constructor(env: TurnCommandEnv) {
|
||||||
this.resolver = new ActionResolver();
|
this.resolver = new ActionResolver(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
parseArgs(_raw: unknown): ResignArgs | null {
|
parseArgs(_raw: unknown): ResignArgs | null {
|
||||||
@@ -109,17 +151,20 @@ export class ActionDefinition<
|
|||||||
return [notBeNeutral(), notLord()];
|
return [notBeNeutral(), notLord()];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: ResignArgs): GeneralActionOutcome<TriggerState> {
|
resolve(context: ResignContext<TriggerState>, args: ResignArgs): GeneralActionOutcome<TriggerState> {
|
||||||
return this.resolver.resolve(context, args);
|
return this.resolver.resolve(context, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||||
|
...base,
|
||||||
|
troopMembers: options.worldRef?.listGenerals().filter((general) => general.troopId === base.general.id) ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: ACTION_KEY,
|
key: ACTION_KEY,
|
||||||
category: '인사',
|
category: '인사',
|
||||||
reqArg: false,
|
reqArg: false,
|
||||||
|
|
||||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,9 +50,8 @@ export class ActionResolver<
|
|||||||
|
|
||||||
const realAmount = Math.max(0, Math.min(amount, currentRes));
|
const realAmount = Math.max(0, Math.min(amount, currentRes));
|
||||||
|
|
||||||
// Exp/Ded calculation
|
const exp = 70;
|
||||||
const exp = Math.floor(realAmount / 100);
|
const ded = 100;
|
||||||
const ded = Math.floor(realAmount / 50);
|
|
||||||
|
|
||||||
const amountText = realAmount.toLocaleString();
|
const amountText = realAmount.toLocaleString();
|
||||||
context.addLog(`${resName} <C>${amountText}</>을 헌납했습니다.`, {
|
context.addLog(`${resName} <C>${amountText}</>을 헌납했습니다.`, {
|
||||||
@@ -70,6 +69,11 @@ export class ActionResolver<
|
|||||||
[resKey]: currentRes - realAmount,
|
[resKey]: currentRes - realAmount,
|
||||||
experience: general.experience + exp,
|
experience: general.experience + exp,
|
||||||
dedication: general.dedication + ded,
|
dedication: general.dedication + ded,
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
leadership_exp:
|
||||||
|
(typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
general.id
|
general.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -32,11 +32,16 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.
|
|||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type {
|
||||||
|
ActionContextBase,
|
||||||
|
ActionContextBuilder,
|
||||||
|
ActionContextOptions,
|
||||||
|
} from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js';
|
||||||
|
import { searchDistance } from '@sammo-ts/logic/world/distance.js';
|
||||||
|
|
||||||
export interface FireAttackEnvironment {
|
export interface FireAttackEnvironment {
|
||||||
develCost: number;
|
develCost: number;
|
||||||
@@ -50,6 +55,8 @@ export interface FireAttackEnvironment {
|
|||||||
getDistance?: (sourceCityId: number, destCityId: number) => number | null;
|
getDistance?: (sourceCityId: number, destCityId: number) => number | null;
|
||||||
getDefenceCorrection?: (context: FireAttackContext, defender: General) => number;
|
getDefenceCorrection?: (context: FireAttackContext, defender: General) => number;
|
||||||
getInjuryProbability?: (context: FireAttackContext, defender: General) => number;
|
getInjuryProbability?: (context: FireAttackContext, defender: General) => number;
|
||||||
|
damageMode?: 'fire' | 'agitate' | 'destroy' | 'seize';
|
||||||
|
injuryGeneral?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FireAttackContext<
|
export interface FireAttackContext<
|
||||||
@@ -61,6 +68,7 @@ export interface FireAttackContext<
|
|||||||
destCity: City;
|
destCity: City;
|
||||||
destNation?: Nation | null;
|
destNation?: Nation | null;
|
||||||
destGenerals: General<TriggerState>[];
|
destGenerals: General<TriggerState>[];
|
||||||
|
distance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FireAttackResolveContext<
|
export interface FireAttackResolveContext<
|
||||||
@@ -69,6 +77,7 @@ export interface FireAttackResolveContext<
|
|||||||
destCity: City;
|
destCity: City;
|
||||||
destNation?: Nation | null;
|
destNation?: Nation | null;
|
||||||
destGenerals: General<TriggerState>[];
|
destGenerals: General<TriggerState>[];
|
||||||
|
distance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FireAttackResult<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export interface FireAttackResult<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
@@ -145,7 +154,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
let probCorrection = 0;
|
let probCorrection = 0;
|
||||||
let affectCount = 0;
|
let affectCount = 0;
|
||||||
|
|
||||||
for (const defender of context.destGenerals) {
|
for (const defender of context.destGenerals ?? []) {
|
||||||
if (defender.nationId !== destNationId) {
|
if (defender.nationId !== destNationId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -166,7 +175,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
|
|
||||||
resolve(context: FireAttackContext<TriggerState>, rng: RandomGenerator): FireAttackResult<TriggerState> {
|
resolve(context: FireAttackContext<TriggerState>, rng: RandomGenerator): FireAttackResult<TriggerState> {
|
||||||
const { gold: costGold, rice: costRice } = this.getCost();
|
const { gold: costGold, rice: costRice } = this.getCost();
|
||||||
const distance = this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99;
|
const distance = context.distance ?? this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99;
|
||||||
|
|
||||||
const attackProb = this.calcAttackProb(context);
|
const attackProb = this.calcAttackProb(context);
|
||||||
const defenceProb = this.calcDefenceProb(context);
|
const defenceProb = this.calcDefenceProb(context);
|
||||||
@@ -175,10 +184,6 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
probability = clamp(probability, 0, this.env.maxSuccessProbability ?? DEFAULT_MAX_PROB);
|
probability = clamp(probability, 0, this.env.maxSuccessProbability ?? DEFAULT_MAX_PROB);
|
||||||
|
|
||||||
const success = rng.nextBool(probability);
|
const success = rng.nextBool(probability);
|
||||||
const expRange: [number, number] = success ? [201, 300] : [1, 100];
|
|
||||||
const dedRange: [number, number] = success ? [141, 210] : [1, 70];
|
|
||||||
const exp = randomRangeInt(rng, expRange[0], expRange[1]);
|
|
||||||
const dedication = randomRangeInt(rng, dedRange[0], dedRange[1]);
|
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return {
|
return {
|
||||||
@@ -187,8 +192,8 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
distance,
|
distance,
|
||||||
costGold,
|
costGold,
|
||||||
costRice,
|
costRice,
|
||||||
exp,
|
exp: randomRangeInt(rng, 1, 100),
|
||||||
dedication,
|
dedication: randomRangeInt(rng, 1, 70),
|
||||||
agriDamage: 0,
|
agriDamage: 0,
|
||||||
commDamage: 0,
|
commDamage: 0,
|
||||||
injuryCount: 0,
|
injuryCount: 0,
|
||||||
@@ -196,23 +201,12 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const agriDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.agriculture
|
|
||||||
);
|
|
||||||
const commDamage = clamp(
|
|
||||||
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
|
||||||
0,
|
|
||||||
context.destCity.commerce
|
|
||||||
);
|
|
||||||
|
|
||||||
const injuryProbDefault = 0.3;
|
const injuryProbDefault = 0.3;
|
||||||
const injuredGenerals: Array<{
|
const injuredGenerals: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
patch: Partial<General<TriggerState>>;
|
patch: Partial<General<TriggerState>>;
|
||||||
}> = [];
|
}> = [];
|
||||||
for (const defender of context.destGenerals) {
|
for (const defender of this.env.injuryGeneral === false ? [] : (context.destGenerals ?? [])) {
|
||||||
if (defender.nationId !== context.destCity.nationId) {
|
if (defender.nationId !== context.destCity.nationId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -226,19 +220,64 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
patch: {
|
patch: {
|
||||||
injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX),
|
injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX),
|
||||||
crew: Math.floor(defender.crew * 0.98),
|
crew: Math.floor(defender.crew * 0.98),
|
||||||
|
atmos: Math.floor(defender.atmos * 0.98),
|
||||||
train: Math.floor(defender.train * 0.98),
|
train: Math.floor(defender.train * 0.98),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const damageMode = this.env.damageMode ?? 'fire';
|
||||||
|
let agriDamage: number;
|
||||||
|
let commDamage: number;
|
||||||
|
if (damageMode === 'agitate') {
|
||||||
|
agriDamage = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.security
|
||||||
|
);
|
||||||
|
const trust = typeof context.destCity.meta.trust === 'number' ? context.destCity.meta.trust : 0;
|
||||||
|
commDamage = clamp(
|
||||||
|
(this.env.sabotageDamageMin +
|
||||||
|
rng.nextFloat1() * (this.env.sabotageDamageMax - this.env.sabotageDamageMin)) /
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
trust
|
||||||
|
);
|
||||||
|
} else if (damageMode === 'destroy') {
|
||||||
|
agriDamage = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.defence
|
||||||
|
);
|
||||||
|
commDamage = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.wall
|
||||||
|
);
|
||||||
|
} else if (damageMode === 'seize') {
|
||||||
|
agriDamage = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax);
|
||||||
|
commDamage = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax);
|
||||||
|
} else {
|
||||||
|
agriDamage = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.agriculture
|
||||||
|
);
|
||||||
|
commDamage = clamp(
|
||||||
|
randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax),
|
||||||
|
0,
|
||||||
|
context.destCity.commerce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success,
|
success,
|
||||||
probability,
|
probability,
|
||||||
distance,
|
distance,
|
||||||
costGold,
|
costGold,
|
||||||
costRice,
|
costRice,
|
||||||
exp,
|
exp: randomRangeInt(rng, 201, 300),
|
||||||
dedication,
|
dedication: randomRangeInt(rng, 141, 210),
|
||||||
agriDamage,
|
agriDamage,
|
||||||
commDamage,
|
commDamage,
|
||||||
injuryCount: injuredGenerals.length,
|
injuryCount: injuredGenerals.length,
|
||||||
@@ -414,8 +453,30 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
|
export const buildStrategyActionContext = (base: ActionContextBase, options: ActionContextOptions) => {
|
||||||
export const actionContextBuilder = defaultActionContextBuilder;
|
const destCityId = options.actionArgs.destCityId;
|
||||||
|
if (typeof destCityId !== 'number' || !options.worldRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const destCity = options.worldRef.getCityById(destCityId);
|
||||||
|
if (!destCity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const destNation = destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null;
|
||||||
|
const destGenerals = options.worldRef
|
||||||
|
.listGenerals()
|
||||||
|
.filter((general) => general.cityId === destCity.id && general.nationId === destCity.nationId);
|
||||||
|
const distance = options.map ? (searchDistance(options.map, base.general.cityId, 5)[destCity.id] ?? 99) : 99;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
destCity,
|
||||||
|
destNation,
|
||||||
|
destGenerals,
|
||||||
|
distance,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionContextBuilder: ActionContextBuilder = buildStrategyActionContext;
|
||||||
|
|
||||||
export const commandSpec: GeneralTurnCommandSpec = {
|
export const commandSpec: GeneralTurnCommandSpec = {
|
||||||
key: 'che_화계',
|
key: 'che_화계',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { clamp } from 'es-toolkit';
|
|
||||||
|
|
||||||
export interface TrainingArgs {}
|
export interface TrainingArgs {}
|
||||||
|
|
||||||
@@ -21,10 +20,10 @@ export interface TrainingEnvironment {
|
|||||||
trainDelta?: number;
|
trainDelta?: number;
|
||||||
maxTrainByCommand?: number;
|
maxTrainByCommand?: number;
|
||||||
costGold?: number;
|
costGold?: number;
|
||||||
|
unitSet?: TurnCommandEnv['unitSet'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '훈련';
|
const ACTION_NAME = '훈련';
|
||||||
const DEFAULT_TRAIN_DELTA = 5;
|
|
||||||
const DEFAULT_MAX_TRAIN = 100;
|
const DEFAULT_MAX_TRAIN = 100;
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
@@ -52,7 +51,13 @@ export class ActionDefinition<
|
|||||||
this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0
|
this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0
|
||||||
? this.env.maxTrainByCommand
|
? this.env.maxTrainByCommand
|
||||||
: DEFAULT_MAX_TRAIN;
|
: DEFAULT_MAX_TRAIN;
|
||||||
return [notBeNeutral(), notWanderingNation(), occupiedCity(), reqGeneralCrew(), reqGeneralTrainMargin(maxTrain)];
|
return [
|
||||||
|
notBeNeutral(),
|
||||||
|
notWanderingNation(),
|
||||||
|
occupiedCity(),
|
||||||
|
reqGeneralCrew(),
|
||||||
|
reqGeneralTrainMargin(maxTrain),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
@@ -64,16 +69,30 @@ export class ActionDefinition<
|
|||||||
this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0
|
this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0
|
||||||
? this.env.maxTrainByCommand
|
? this.env.maxTrainByCommand
|
||||||
: DEFAULT_MAX_TRAIN;
|
: DEFAULT_MAX_TRAIN;
|
||||||
const delta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : DEFAULT_TRAIN_DELTA;
|
const trainDelta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : 0;
|
||||||
const nextTrain = clamp(general.train + delta, 0, maxTrain);
|
const score = Math.max(
|
||||||
const applied = nextTrain - general.train;
|
0,
|
||||||
|
Math.min(
|
||||||
|
Math.round((general.stats.leadership * 100 * trainDelta) / Math.max(general.crew, 1)),
|
||||||
|
maxTrain - general.train
|
||||||
|
)
|
||||||
|
);
|
||||||
const costGold = this.env.costGold ?? 0;
|
const costGold = this.env.costGold ?? 0;
|
||||||
|
|
||||||
// 직접 수정 (Immer Draft)
|
general.train += score;
|
||||||
general.train = nextTrain;
|
|
||||||
general.gold = Math.max(0, general.gold - costGold);
|
general.gold = Math.max(0, general.gold - costGold);
|
||||||
|
general.experience += 100;
|
||||||
|
general.dedication += 70;
|
||||||
|
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
||||||
|
general.meta.leadership_exp = leadershipExp + 1;
|
||||||
|
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
|
||||||
|
if (crewType) {
|
||||||
|
const dexKey = `dex${crewType.armType}`;
|
||||||
|
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
|
||||||
|
general.meta[dexKey] = dex + score;
|
||||||
|
}
|
||||||
|
|
||||||
context.addLog(`훈련치가 <C>${applied}</> 상승했습니다.`);
|
context.addLog(`훈련치가 <C>${score.toLocaleString()}</> 상승했습니다.`);
|
||||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||||
|
|
||||||
return { effects: [] };
|
return { effects: [] };
|
||||||
@@ -92,5 +111,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
|||||||
new ActionDefinition({
|
new ActionDefinition({
|
||||||
trainDelta: env.trainDelta,
|
trainDelta: env.trainDelta,
|
||||||
maxTrainByCommand: env.maxTrainByCommand,
|
maxTrainByCommand: env.maxTrainByCommand,
|
||||||
|
unitSet: env.unitSet,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface CityDevelopmentEnvironment {
|
|||||||
amount?: number;
|
amount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NumberKeys<T> = { [K in keyof T]: T[K] extends number ? K : never }[keyof T];
|
type NumberKeys<T> = { [K in keyof T]-?: T[K] extends number ? K : never }[keyof T];
|
||||||
|
|
||||||
export interface CityDevelopmentConfig {
|
export interface CityDevelopmentConfig {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export class ActionDefinition<
|
|||||||
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
|
||||||
public readonly key = ACTION_KEY;
|
public readonly key = ACTION_KEY;
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
getInheritanceActiveActionAmount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
parseArgs(raw: unknown): FoundingArgs | null {
|
parseArgs(raw: unknown): FoundingArgs | null {
|
||||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||||
@@ -126,10 +129,13 @@ export class ActionDefinition<
|
|||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
});
|
});
|
||||||
context.addLog(`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`, {
|
context.addLog(
|
||||||
category: LogCategory.HISTORY,
|
`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
|
||||||
scope: LogScope.SYSTEM,
|
{
|
||||||
});
|
category: LogCategory.HISTORY,
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
}
|
||||||
|
);
|
||||||
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
|
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
|
||||||
category: LogCategory.HISTORY,
|
category: LogCategory.HISTORY,
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export const GENERAL_TURN_COMMAND_KEYS = [
|
|||||||
'che_탈취',
|
'che_탈취',
|
||||||
'che_NPC능동',
|
'che_NPC능동',
|
||||||
'che_강행',
|
'che_강행',
|
||||||
'che_귀환',
|
|
||||||
'휴식',
|
'휴식',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -130,12 +130,18 @@ export class ActionDefinition<
|
|||||||
|
|
||||||
const broadcastMessage = `<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>을 지원했습니다.`;
|
const broadcastMessage = `<D><b>${destNation.name}</b></>${josaRo} 금<C>${goldText}</> 쌀<C>${riceText}</>을 지원했습니다.`;
|
||||||
const recvAssist =
|
const recvAssist =
|
||||||
typeof destNation.meta.recv_assist === 'object' && destNation.meta.recv_assist !== null
|
typeof destNation.meta.recv_assist === 'object' &&
|
||||||
|
destNation.meta.recv_assist !== null &&
|
||||||
|
!Array.isArray(destNation.meta.recv_assist)
|
||||||
? { ...destNation.meta.recv_assist }
|
? { ...destNation.meta.recv_assist }
|
||||||
: {};
|
: {};
|
||||||
const recvKey = `n${nation.id}`;
|
const recvKey = `n${nation.id}`;
|
||||||
const priorEntry =
|
const priorEntry =
|
||||||
typeof recvAssist[recvKey] === 'object' && recvAssist[recvKey] !== null ? recvAssist[recvKey] : {};
|
typeof recvAssist[recvKey] === 'object' &&
|
||||||
|
recvAssist[recvKey] !== null &&
|
||||||
|
!Array.isArray(recvAssist[recvKey])
|
||||||
|
? recvAssist[recvKey]
|
||||||
|
: {};
|
||||||
const priorAmount = Number(priorEntry['1'] ?? 0);
|
const priorAmount = Number(priorEntry['1'] ?? 0);
|
||||||
recvAssist[recvKey] = {
|
recvAssist[recvKey] = {
|
||||||
0: nation.id,
|
0: nation.id,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ export interface StatBlock {
|
|||||||
export type TriggerValuePrimitive = boolean | number | string;
|
export type TriggerValuePrimitive = boolean | number | string;
|
||||||
|
|
||||||
export interface TriggerValueObject {
|
export interface TriggerValueObject {
|
||||||
[key: string]: TriggerValuePrimitive | TriggerValueObject;
|
[key: string]: TriggerValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TriggerValue = TriggerValuePrimitive | TriggerValueObject;
|
export type TriggerValue = TriggerValuePrimitive | TriggerValueObject | TriggerValue[];
|
||||||
|
|
||||||
export interface GeneralTriggerState {
|
export interface GeneralTriggerState {
|
||||||
// Trigger 시스템에서 사용하는 확장 슬롯.
|
// Trigger 시스템에서 사용하는 확장 슬롯.
|
||||||
@@ -67,6 +67,14 @@ export type GeneralMeta = Record<string, TriggerValue> & {
|
|||||||
killturn: number;
|
killturn: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 레거시 general.last_turn JSON. 연속 실행 턴의 선행 턴 누적 상태를 보존한다.
|
||||||
|
export interface GeneralLastTurn {
|
||||||
|
command: string;
|
||||||
|
arg?: Record<string, unknown>;
|
||||||
|
term?: number;
|
||||||
|
seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface General<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export interface General<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
id: GeneralId;
|
id: GeneralId;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -91,6 +99,7 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
|
|||||||
// 아이템의 canonical 소유/상태 모델. role.items는 레거시 DB 컬럼/API용 projection이다.
|
// 아이템의 canonical 소유/상태 모델. role.items는 레거시 DB 컬럼/API용 projection이다.
|
||||||
itemInventory?: GeneralItemInventory;
|
itemInventory?: GeneralItemInventory;
|
||||||
meta: GeneralMeta;
|
meta: GeneralMeta;
|
||||||
|
lastTurn?: GeneralLastTurn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface City {
|
export interface City {
|
||||||
@@ -113,6 +122,7 @@ export interface City {
|
|||||||
defenceMax: number;
|
defenceMax: number;
|
||||||
wall: number;
|
wall: number;
|
||||||
wallMax: number;
|
wallMax: number;
|
||||||
|
conflict?: Record<string, TriggerValue>;
|
||||||
meta: Record<string, TriggerValue>;
|
meta: Record<string, TriggerValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ export type TriggerStrategicVarType = 'delay' | 'globalDelay';
|
|||||||
export type TriggerNationalIncomeType = 'gold' | 'rice' | 'pop';
|
export type TriggerNationalIncomeType = 'gold' | 'rice' | 'pop';
|
||||||
|
|
||||||
export type GeneralStatName =
|
export type GeneralStatName =
|
||||||
'leadership' | 'strength' | 'intelligence' | 'experience' | 'dedication' | 'sabotageDefence' | 'sabotageAttack';
|
| 'leadership'
|
||||||
|
| 'strength'
|
||||||
|
| 'intelligence'
|
||||||
|
| 'experience'
|
||||||
|
| 'dedication'
|
||||||
|
| 'sabotageDefence'
|
||||||
|
| 'sabotageAttack'
|
||||||
|
| 'addDex';
|
||||||
|
|
||||||
export type WarStatName =
|
export type WarStatName =
|
||||||
| GeneralStatName
|
| GeneralStatName
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { RandUtil } from '@sammo-ts/common';
|
import type { RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type {
|
import type { GeneralTriggerState, Nation, TriggerValue } from '@sammo-ts/logic/domain/entities.js';
|
||||||
GeneralTriggerState,
|
|
||||||
Nation,
|
|
||||||
TriggerValue,
|
|
||||||
} from '@sammo-ts/logic/domain/entities.js';
|
|
||||||
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||||
import type { WarEngineConfig } from '../types.js';
|
import type { WarEngineConfig } from '../types.js';
|
||||||
@@ -14,7 +10,7 @@ import { clampMin, getDexLog, getMetaNumber, round } from '../utils.js';
|
|||||||
export const WAR_CRITICAL_RANGE: [number, number] = [1.3, 2.0];
|
export const WAR_CRITICAL_RANGE: [number, number] = [1.3, 2.0];
|
||||||
|
|
||||||
export const resolveNationTech = (nation: Nation | null): number =>
|
export const resolveNationTech = (nation: Nation | null): number =>
|
||||||
(nation ? getMetaNumber(nation.meta, 'tech', 0) : 0);
|
nation ? getMetaNumber(nation.meta, 'tech', 0) : 0;
|
||||||
|
|
||||||
const resolveNationVar = (nation: Nation | null, key: string): TriggerValue | null => {
|
const resolveNationVar = (nation: Nation | null, key: string): TriggerValue | null => {
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
@@ -246,7 +242,8 @@ export abstract class WarUnit<TriggerState extends GeneralTriggerState = General
|
|||||||
if (warPower < 100) {
|
if (warPower < 100) {
|
||||||
warPower = clampMin(warPower, 0);
|
warPower = clampMin(warPower, 0);
|
||||||
warPower = (warPower + 100) / 2;
|
warPower = (warPower + 100) / 2;
|
||||||
warPower = this.rng.nextRangeInt(warPower, 100);
|
// PHP의 int parameter coercion은 소수 하한을 0 방향으로 절삭한다.
|
||||||
|
warPower = this.rng.nextRangeInt(Math.trunc(warPower), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
warPower *= this.getComputedAtmos();
|
warPower *= this.getComputedAtmos();
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ describe('Domestic Affairs Scenario', () => {
|
|||||||
|
|
||||||
// 4. Verify
|
// 4. Verify
|
||||||
const updatedCity = world.getCity(1)!;
|
const updatedCity = world.getCity(1)!;
|
||||||
expect(updatedCity.agriculture).toBe(600);
|
// 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다.
|
||||||
|
expect(updatedCity.agriculture).toBe(664);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not increase agriculture when city is already maxed', async () => {
|
it('should not increase agriculture when city is already maxed', async () => {
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
// 2. Donate
|
// 2. Donate
|
||||||
const donateDef = donateSpec.createDefinition(systemEnv);
|
const donateDef = donateSpec.createDefinition(systemEnv);
|
||||||
|
const expBeforeDonate = world.getGeneral(1)!.experience;
|
||||||
await runner.runTurn([
|
await runner.runTurn([
|
||||||
{
|
{
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
@@ -207,6 +208,8 @@ describe('General Commands New Scenario', () => {
|
|||||||
const g1_after_donate = world.getGeneral(1)!;
|
const g1_after_donate = world.getGeneral(1)!;
|
||||||
const n1_after_donate = world.getNation(1)!;
|
const n1_after_donate = world.getNation(1)!;
|
||||||
expect(g1_after_donate.gold).toBe(900); // 1000 - 100 (Procure cost 0)
|
expect(g1_after_donate.gold).toBe(900); // 1000 - 100 (Procure cost 0)
|
||||||
|
expect(g1_after_donate.experience).toBe(expBeforeDonate + 70);
|
||||||
|
expect(g1_after_donate.meta.leadership_exp).toBe(1);
|
||||||
expect(n1_after_donate.gold).toBeGreaterThan(10100); // 10000 + Procure + 100
|
expect(n1_after_donate.gold).toBeGreaterThan(10100); // 10000 + Procure + 100
|
||||||
|
|
||||||
// 3. Move
|
// 3. Move
|
||||||
@@ -270,7 +273,8 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
const g1_after_retire = world.getGeneral(1)!;
|
const g1_after_retire = world.getGeneral(1)!;
|
||||||
expect(g1_after_retire.age).toBe(20);
|
expect(g1_after_retire.age).toBe(20);
|
||||||
expect(g1_after_retire.experience).toBe(0);
|
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
|
||||||
|
expect(g1_after_retire.experience).toBe(142);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should execute employ and sabotage commands', async () => {
|
it('should execute employ and sabotage commands', async () => {
|
||||||
@@ -414,6 +418,7 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
// 1. Employ (G1 -> G2)
|
// 1. Employ (G1 -> G2)
|
||||||
const employDef = employSpec.createDefinition(systemEnv);
|
const employDef = employSpec.createDefinition(systemEnv);
|
||||||
|
const goldBeforeEmploy = world.getGeneral(1)!.gold;
|
||||||
await runner.runTurn([
|
await runner.runTurn([
|
||||||
{
|
{
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
@@ -423,6 +428,8 @@ describe('General Commands New Scenario', () => {
|
|||||||
context: { destGeneral: gen2, env: systemEnv }, // Manual inject for resolver context
|
context: { destGeneral: gen2, env: systemEnv }, // Manual inject for resolver context
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
// 레거시 비용: round(develcost + (대상 경험 + 공헌) / 1000) * 10.
|
||||||
|
expect(world.getGeneral(1)!.gold).toBe(goldBeforeEmploy - 1_000);
|
||||||
|
|
||||||
// Verify Logs? (Runner doesn't expose logs easily, but we checks no throw)
|
// Verify Logs? (Runner doesn't expose logs easily, but we checks no throw)
|
||||||
|
|
||||||
@@ -500,7 +507,22 @@ describe('General Commands New Scenario', () => {
|
|||||||
commandKey: 'che_선동',
|
commandKey: 'che_선동',
|
||||||
resolver: agitateDef,
|
resolver: agitateDef,
|
||||||
args: { destCityId: 2 },
|
args: { destCityId: 2 },
|
||||||
context: { destCity: city2, env: systemEnv },
|
context: {
|
||||||
|
destCity: city2,
|
||||||
|
destNation: nation2,
|
||||||
|
destGenerals: [gen2],
|
||||||
|
env: systemEnv,
|
||||||
|
rng: {
|
||||||
|
real: () => 0,
|
||||||
|
int: (min: number, _max: number) => min,
|
||||||
|
nextInt: (min: number, _max: number) => min,
|
||||||
|
next: () => 0,
|
||||||
|
nextBool: () => true,
|
||||||
|
nextRange: (min: number, _max: number) => min,
|
||||||
|
nextRangeInt: (min: number, _max: number) => min,
|
||||||
|
nextFloat1: () => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -521,7 +543,24 @@ describe('General Commands New Scenario', () => {
|
|||||||
commandKey: 'che_탈취',
|
commandKey: 'che_탈취',
|
||||||
resolver: seizeDef,
|
resolver: seizeDef,
|
||||||
args: { destCityId: 2 },
|
args: { destCityId: 2 },
|
||||||
context: { destCity: city2, destNation: nation2, env: systemEnv },
|
context: {
|
||||||
|
destCity: city2,
|
||||||
|
destNation: nation2,
|
||||||
|
destGenerals: [gen2],
|
||||||
|
env: systemEnv,
|
||||||
|
year: 200,
|
||||||
|
startYear: 200,
|
||||||
|
rng: {
|
||||||
|
real: () => 0,
|
||||||
|
int: (min: number, _max: number) => min,
|
||||||
|
nextInt: (min: number, _max: number) => min,
|
||||||
|
next: () => 0,
|
||||||
|
nextBool: () => true,
|
||||||
|
nextRange: (min: number, _max: number) => min,
|
||||||
|
nextRangeInt: (min: number, _max: number) => min,
|
||||||
|
nextFloat1: () => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -530,7 +569,8 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
const g1_after_seize = world.getGeneral(1)!;
|
const g1_after_seize = world.getGeneral(1)!;
|
||||||
|
|
||||||
expect(g1_after_seize.gold).toBeGreaterThan(goldBeforeSeize - systemEnv.develCost);
|
// 레거시는 개발비의 5배를 먼저 소모한 뒤 탈취량의 30%를 개인 몫으로 지급한다.
|
||||||
|
expect(g1_after_seize.gold).toBe(goldBeforeSeize - systemEnv.develCost * 5 + 1);
|
||||||
expect(getEquippedItemInstance(g1_after_seize, 'item')).toBeNull();
|
expect(getEquippedItemInstance(g1_after_seize, 'item')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ describe('Troop Management Scenario', () => {
|
|||||||
|
|
||||||
const generalAfterDraft = world.getGeneral(1)!;
|
const generalAfterDraft = world.getGeneral(1)!;
|
||||||
expect(generalAfterDraft.crew).toBe(1000);
|
expect(generalAfterDraft.crew).toBe(1000);
|
||||||
|
// General::addDex(): 보병(armType 1)은 징병 인원 / 100만큼 숙련도가 오른다.
|
||||||
|
expect(generalAfterDraft.meta.dex1).toBe(10);
|
||||||
|
|
||||||
// 3. Train
|
// 3. Train
|
||||||
const trainDef = trainSpec.createDefinition(systemEnv);
|
const trainDef = trainSpec.createDefinition(systemEnv);
|
||||||
@@ -158,7 +160,8 @@ describe('Troop Management Scenario', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const generalAfterTrain = world.getGeneral(1)!;
|
const generalAfterTrain = world.getGeneral(1)!;
|
||||||
expect(generalAfterTrain.train).toBe(75);
|
// 레거시 훈련식: round(통솔 * 100 * trainDelta / 병력), 상한까지 적용.
|
||||||
|
expect(generalAfterTrain.train).toBe(100);
|
||||||
|
|
||||||
// 4. Boost Morale
|
// 4. Boost Morale
|
||||||
const atmosDef = atmosSpec.createDefinition(systemEnv);
|
const atmosDef = atmosSpec.createDefinition(systemEnv);
|
||||||
@@ -172,6 +175,7 @@ describe('Troop Management Scenario', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const generalAfterAtmos = world.getGeneral(1)!;
|
const generalAfterAtmos = world.getGeneral(1)!;
|
||||||
expect(generalAfterAtmos.atmos).toBe(75);
|
// 레거시 사기진작식: round(통솔 * 100 / 병력 * atmosDelta), 명령 상한까지 적용.
|
||||||
|
expect(generalAfterAtmos.atmos).toBe(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,21 +15,64 @@
|
|||||||
"regex": []
|
"regex": []
|
||||||
},
|
},
|
||||||
"General/che_모반시도": {
|
"General/che_모반시도": {
|
||||||
"templates": [
|
"templates": ["<Y>${}</>에게 군주의 자리를 뺏겼습니다."],
|
||||||
"<Y>${}</>에게 군주의 자리를 뺏겼습니다."
|
|
||||||
],
|
|
||||||
"regex": []
|
"regex": []
|
||||||
},
|
},
|
||||||
"General/che_선양": {
|
"General/che_선양": {
|
||||||
"templates": [
|
"templates": ["<Y>${}</>에게서 군주의 자리를 물려받습니다."],
|
||||||
"<Y>${}</>에게서 군주의 자리를 물려받습니다."
|
|
||||||
],
|
|
||||||
"regex": []
|
"regex": []
|
||||||
},
|
},
|
||||||
"General/che_장비매매": {
|
"General/che_장비매매": {
|
||||||
|
"templates": ["${}"],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_기술연구": {
|
||||||
"templates": [
|
"templates": [
|
||||||
"${}"
|
"${}${} 하여 <C>${}</> 상승했습니다.",
|
||||||
|
"기술 연구${} <span class='ev_failed'>실패</span>하여 <C>${}</> 상승했습니다.",
|
||||||
|
"기술 연구${} <S>성공</>하여 <C>${}</> 상승했습니다.",
|
||||||
|
"기술 연구${} 하여 <C>${}</> 상승했습니다."
|
||||||
],
|
],
|
||||||
"regex": []
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_내정특기초기화": {
|
||||||
|
"templates": ["새로운 ${}를 가질 준비가 되었습니다.", "새로운 내정 특기를 가질 준비가 되었습니다."],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_선동": {
|
||||||
|
"templates": ["<G>${}</>에 선동${} 실패했습니다."],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_은퇴": {
|
||||||
|
"templates": ["나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다."],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_정착장려": {
|
||||||
|
"templates": [
|
||||||
|
"${}${} <span class='ev_failed'>실패</span>하여 주민이 <C>${}</>명 증가했습니다.",
|
||||||
|
"${}${} <S>성공</>하여 주민이 <C>${}</>명 증가했습니다.",
|
||||||
|
"${}${} 하여 주민이 <C>${}</>명 증가했습니다.",
|
||||||
|
"정착 장려${} <span class='ev_failed'>실패</span>하여 주민이 <C>${}</>명 증가했습니다.",
|
||||||
|
"정착 장려${} <S>성공</>하여 주민이 <C>${}</>명 증가했습니다.",
|
||||||
|
"정착 장려${} 하여 주민이 <C>${}</>명 증가했습니다."
|
||||||
|
],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_주민선정": {
|
||||||
|
"templates": [
|
||||||
|
"${}${} 하여 <C>${}</> 상승했습니다.",
|
||||||
|
"주민 선정${} <span class='ev_failed'>실패</span>하여 <C>${}</> 상승했습니다.",
|
||||||
|
"주민 선정${} <S>성공</>하여 <C>${}</> 상승했습니다.",
|
||||||
|
"주민 선정${} 하여 <C>${}</> 상승했습니다."
|
||||||
|
],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_탈취": {
|
||||||
|
"templates": ["<G>${}</>에 탈취${} 실패했습니다."],
|
||||||
|
"regex": []
|
||||||
|
},
|
||||||
|
"General/che_파괴": {
|
||||||
|
"templates": ["<G>${}</>에 파괴${} 실패했습니다."],
|
||||||
|
"regex": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const DEFAULT_MODE = 'action';
|
|||||||
const DEFAULT_EXCLUDE_GUARDS = true;
|
const DEFAULT_EXCLUDE_GUARDS = true;
|
||||||
const DEFAULT_EXCLUDE_TARGET = true;
|
const DEFAULT_EXCLUDE_TARGET = true;
|
||||||
const DEFAULT_IGNORE_FILE = 'tools/compare-command-logs.ignore.json';
|
const DEFAULT_IGNORE_FILE = 'tools/compare-command-logs.ignore.json';
|
||||||
|
const PHP_INHERITED_LOG_SOURCE_WHEN_EMPTY = new Map([['General/che_내정특기초기화', 'General/che_전투특기초기화']]);
|
||||||
|
|
||||||
const ARG_HELP = `
|
const ARG_HELP = `
|
||||||
Usage: node tools/compare-command-logs.mjs [options]
|
Usage: node tools/compare-command-logs.mjs [options]
|
||||||
@@ -894,6 +895,7 @@ const diffTemplateCounts = (lhsCounts, rhsCounts) => {
|
|||||||
const loadPhpLogs = async () => {
|
const loadPhpLogs = async () => {
|
||||||
const files = (await collectFiles(PHP_ROOT)).filter((file) => file.endsWith('.php'));
|
const files = (await collectFiles(PHP_ROOT)).filter((file) => file.endsWith('.php'));
|
||||||
const logsByKey = new Map();
|
const logsByKey = new Map();
|
||||||
|
const sourceByKey = new Map();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const baseName = path.basename(file, '.php');
|
const baseName = path.basename(file, '.php');
|
||||||
@@ -909,6 +911,7 @@ const loadPhpLogs = async () => {
|
|||||||
const text = await fs.readFile(file, 'utf-8');
|
const text = await fs.readFile(file, 'utf-8');
|
||||||
const assignments = findPhpAssignments(text);
|
const assignments = findPhpAssignments(text);
|
||||||
const logs = extractPhpLogCalls(text, assignments);
|
const logs = extractPhpLogCalls(text, assignments);
|
||||||
|
sourceByKey.set(key, { file, logs });
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -940,6 +943,42 @@ const loadPhpLogs = async () => {
|
|||||||
logsByKey.set(key, filtered);
|
logsByKey.set(key, filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [key, parentKey] of PHP_INHERITED_LOG_SOURCE_WHEN_EMPTY) {
|
||||||
|
if (!filterCommandKey(key) || logsByKey.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parent = sourceByKey.get(parentKey);
|
||||||
|
if (!parent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inherited = parent.logs
|
||||||
|
.map((log) => ({
|
||||||
|
file: path.relative(ROOT_DIR, parent.file),
|
||||||
|
line: log.line,
|
||||||
|
template: log.template,
|
||||||
|
raw: log.raw,
|
||||||
|
category: log.category,
|
||||||
|
scope: log.scope,
|
||||||
|
format: log.format,
|
||||||
|
hasGeneralId: log.hasGeneralId,
|
||||||
|
}))
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!shouldIncludeEntryByMode(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (excludeGuards && isGuardLog(entry.template)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (excludeTarget && isTargetLog(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (inherited.length > 0) {
|
||||||
|
logsByKey.set(key, inherited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return logsByKey;
|
return logsByKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const phpDir = path.join(root, 'legacy/hwe/sammo/Command/General');
|
||||||
|
const tsDir = path.join(root, 'packages/logic/src/actions/turn/general');
|
||||||
|
const check = process.argv.includes('--check');
|
||||||
|
|
||||||
|
const activeAction = new Map([
|
||||||
|
['che_거병', 1],
|
||||||
|
['che_건국', 1],
|
||||||
|
['che_등용수락', 1],
|
||||||
|
['che_랜덤임관', 1],
|
||||||
|
['che_모반시도', 1],
|
||||||
|
['che_무작위건국', 1],
|
||||||
|
['che_방랑', 1],
|
||||||
|
['che_임관', 1],
|
||||||
|
['che_장수대상임관', 1],
|
||||||
|
['che_선양', 1],
|
||||||
|
['che_출병', 1],
|
||||||
|
['che_첩보', 0.5],
|
||||||
|
['che_하야', 1],
|
||||||
|
['cr_건국', 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const readSources = async (dir, extension) => {
|
||||||
|
const result = new Map();
|
||||||
|
for (const name of await fs.readdir(dir)) {
|
||||||
|
if (!name.endsWith(extension)) continue;
|
||||||
|
const key = name.slice(0, -extension.length);
|
||||||
|
if (!(key.startsWith('che_') || key.startsWith('cr_') || key === '휴식')) continue;
|
||||||
|
result.set(key, await fs.readFile(path.join(dir, name), 'utf8'));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const php = await readSources(phpDir, '.php');
|
||||||
|
const ts = await readSources(tsDir, '.ts');
|
||||||
|
const handlerSource = await fs.readFile(path.join(root, 'app/game-engine/src/turn/reservedTurnHandler.ts'), 'utf8');
|
||||||
|
|
||||||
|
const phpParent = new Map();
|
||||||
|
for (const [key, source] of php) {
|
||||||
|
const classMatch = source.match(/\bclass\s+\S+\s+extends\s+(?:Command\\GeneralCommand|([^\s{]+))/);
|
||||||
|
const rawParent = classMatch?.[1];
|
||||||
|
if (rawParent) phpParent.set(key, rawParent.split('\\').at(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const literalMethod = (source, method) => {
|
||||||
|
const match = source.match(
|
||||||
|
new RegExp(`function\\s+${method}\\s*\\([^)]*\\)\\s*:[^{]+\\{[\\s\\S]*?return\\s+(-?\\d+(?:\\.\\d+)?)\\s*;`)
|
||||||
|
);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePhpMethod = (key, method, seen = new Set()) => {
|
||||||
|
if (seen.has(key)) return null;
|
||||||
|
seen.add(key);
|
||||||
|
const source = php.get(key);
|
||||||
|
if (!source) return null;
|
||||||
|
const own = literalMethod(source, method);
|
||||||
|
if (own !== null) return own;
|
||||||
|
const parent = phpParent.get(key);
|
||||||
|
return parent ? resolvePhpMethod(parent, method, seen) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tsMethod = (source, method) => {
|
||||||
|
const match = source.match(
|
||||||
|
new RegExp(`${method}\\s*\\([^)]*\\)\\s*:\\s*number\\s*\\{[\\s\\S]*?return\\s+(-?\\d+(?:\\.\\d+)?)\\s*;`)
|
||||||
|
);
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingTs = [...php.keys()].filter((key) => !ts.has(key)).sort();
|
||||||
|
const extraTs = [...ts.keys()].filter((key) => !php.has(key)).sort();
|
||||||
|
const timingMismatches = [];
|
||||||
|
const activeMismatches = [];
|
||||||
|
|
||||||
|
for (const key of [...php.keys()].filter((value) => ts.has(value)).sort()) {
|
||||||
|
const source = ts.get(key);
|
||||||
|
const phpPre = resolvePhpMethod(key, 'getPreReqTurn');
|
||||||
|
const phpPost = resolvePhpMethod(key, 'getPostReqTurn');
|
||||||
|
const tsPre = tsMethod(source, 'getPreReqTurn');
|
||||||
|
const tsPost = tsMethod(source, 'getPostReqTurn');
|
||||||
|
if (phpPre !== tsPre || phpPost !== tsPost) {
|
||||||
|
timingMismatches.push({ key, php: [phpPre, phpPost], ts: [tsPre, tsPost] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedActive = activeAction.get(key) ?? 0;
|
||||||
|
const actualActive = tsMethod(source, 'getInheritanceActiveActionAmount');
|
||||||
|
if (expectedActive !== actualActive) {
|
||||||
|
activeMismatches.push({ key, php: expectedActive, ts: actualActive });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicChecks = [
|
||||||
|
{
|
||||||
|
key: 'che_인재탐색',
|
||||||
|
ok: /inherit_active_action[\s\S]*Math\.max\(Math\.sqrt\(1\s*\/\s*prop\),\s*1\)/.test(
|
||||||
|
ts.get('che_인재탐색') ?? ''
|
||||||
|
),
|
||||||
|
contract: '성공 시 max(sqrt(1 / 발견확률), 1)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'generalCommand RNG seed',
|
||||||
|
ok: /kind === 'general' \? 'generalCommand' : 'nationCommand'[\s\S]*currentYear[\s\S]*currentMonth[\s\S]*currentGeneral\.id,[\s\S]*key/.test(
|
||||||
|
handlerSource
|
||||||
|
),
|
||||||
|
contract: 'hiddenSeed, generalCommand, year, month, generalId, raw command key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'preprocess RNG seed',
|
||||||
|
ok: /buildSeedBase\(context\.world\),[\s\S]*'preprocess',[\s\S]*currentYear,[\s\S]*currentMonth,[\s\S]*currentGeneral\.id/.test(
|
||||||
|
handlerSource
|
||||||
|
),
|
||||||
|
contract: 'hiddenSeed, preprocess, year, month, generalId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'post-turn myset',
|
||||||
|
ok: /myset:\s*Math\.min\([\s\S]*9,[\s\S]*currentGeneral\.meta\.myset[\s\S]*\+\s*3/.test(handlerSource),
|
||||||
|
contract: '매 턴 +3, 상한 9',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(`General command inventory: PHP ${php.size}, TS ${ts.size}`);
|
||||||
|
console.log(`Missing TS: ${missingTs.length}; Extra TS: ${extraTs.length}`);
|
||||||
|
console.log(`Timing mismatches: ${timingMismatches.length}`);
|
||||||
|
console.log(`Active-action mismatches: ${activeMismatches.length}`);
|
||||||
|
for (const mismatch of timingMismatches) console.log('timing', JSON.stringify(mismatch));
|
||||||
|
for (const mismatch of activeMismatches) console.log('active', JSON.stringify(mismatch));
|
||||||
|
for (const item of dynamicChecks) console.log(`dynamic ${item.key}: ${item.ok ? 'ok' : 'missing'} (${item.contract})`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
check &&
|
||||||
|
(missingTs.length > 0 ||
|
||||||
|
extraTs.length > 0 ||
|
||||||
|
timingMismatches.length > 0 ||
|
||||||
|
activeMismatches.length > 0 ||
|
||||||
|
dynamicChecks.some((item) => !item.ok))
|
||||||
|
) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user