fix general founding and rebellion parity

This commit is contained in:
2026-07-26 17:50:56 +00:00
parent 80a2c9502f
commit 3fb75ccf03
16 changed files with 473 additions and 221 deletions
@@ -154,12 +154,10 @@ export const do거병 = (ai: GeneralAI) => {
};
export const do건국 = (ai: GeneralAI) => {
const mapName = ai.scenarioConfig.environment.mapName ?? 'sammo';
const prefix = mapName.endsWith('_') ? mapName : `${mapName}_`;
const nationType =
ai.aiConst.availableNationTypes.length > 0
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: `${prefix}def`;
: 'che_도적';
const colorType = ai.rng.nextRangeInt(0, 32);
const nationName = `${Array.from(ai.general.name).slice(1).join('')}`;
@@ -1261,6 +1261,12 @@ export const createReservedTurnHandler = async (options: {
if (resolution.created?.nations) {
const newNations = resolution.created.nations as Nation[];
createdNations.push(...newNations);
if (actionKey === 'che_거병') {
for (const nation of newNations) {
options.reservedTurns.ensureNationTurns(nation.id, 12);
options.reservedTurns.ensureNationTurns(nation.id, 11);
}
}
if (worldOverlay) {
for (const nation of newNations) {
worldOverlay.syncNation(nation);
@@ -787,7 +787,7 @@ describe('Reserved Turn Execution Integration', () => {
generalId: 1,
turnIdx: 1,
actionCode: 'che_건국',
arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 },
arg: { nationName: 'NewEmpire', nationType: 'che_도적', colorType: 0 },
},
{ generalId: 2, turnIdx: 1, actionCode: 'che_농지개간', arg: {} },
// 2
@@ -798,7 +798,7 @@ describe('Reserved Turn Execution Integration', () => {
generalId: 1,
turnIdx: 3,
actionCode: 'che_건국',
arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 },
arg: { nationName: 'NewEmpire', nationType: 'che_도적', colorType: 0 },
},
{ generalId: 2, turnIdx: 3, actionCode: 'che_농지개간', arg: {} },
// 4
@@ -809,7 +809,7 @@ describe('Reserved Turn Execution Integration', () => {
generalId: 1,
turnIdx: 5,
actionCode: 'che_건국',
arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 },
arg: { nationName: 'NewEmpire', nationType: 'che_도적', colorType: 0 },
},
{ generalId: 2, turnIdx: 5, actionCode: 'che_농지개간', arg: {} },
];
@@ -1058,7 +1058,7 @@ describe('Reserved Turn Execution Integration', () => {
generalId: 1,
turnIdx: 0,
actionCode: 'che_건국',
arg: { nationName: 'Empire', nationType: 'che_def', colorType: 0 },
arg: { nationName: 'Empire', nationType: 'che_도적', colorType: 0 },
},
];
@@ -1102,7 +1102,7 @@ describe('Reserved Turn Execution Integration', () => {
turnsG1[0] = { action: 'che_거병', args: {} };
turnsG1[1] = {
action: 'che_건국',
args: { nationName: 'Empire', nationType: 'che_def', colorType: 0 },
args: { nationName: 'Empire', nationType: 'che_도적', colorType: 0 },
};
await reservedTurnStore.flushChanges();
@@ -1112,6 +1112,11 @@ describe('Reserved Turn Execution Integration', () => {
maxGenerals: 100,
catchUpCap: 10,
});
const uprisingNationId = world.getGeneralById(1)!.nationId;
expect(reservedTurnStore.peekDirtyState().nationInitializationKeys).toEqual([
`${uprisingNationId}:12`,
`${uprisingNationId}:11`,
]);
world.consumeDirtyState(); // clear logs
// Execute Founding attempt (Turn 2)
@@ -1145,7 +1150,7 @@ describe('Reserved Turn Execution Integration', () => {
const turnsG1_v2 = reservedTurnStore.getGeneralTurns(1);
turnsG1_v2[0] = {
action: 'che_건국',
args: { nationName: 'Empire', nationType: 'che_def', colorType: 0 },
args: { nationName: 'Empire', nationType: 'che_도적', colorType: 0 },
};
await reservedTurnStore.flushChanges();
@@ -13,11 +13,12 @@ import {
createDiplomacyPatchEffect,
createNationAddEffect,
} 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 { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
export interface UprisingArgs {}
@@ -29,6 +30,19 @@ export interface UprisingContext extends ActionContextBase {
}
const ACTION_NAME = '거병';
const truncateLegacyWidth = (value: string, maxWidth: number): string => {
let result = '';
let width = 0;
for (const character of value) {
const characterWidth = getLegacyStringWidth(character);
if (width + characterWidth > maxWidth) {
break;
}
result += character;
width += characterWidth;
}
return result;
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -65,8 +79,7 @@ export class ActionDefinition<
const nations = uprisingCtx.listNations ? uprisingCtx.listNations() : [];
if (nations.some((n) => n.name === nationName)) {
nationName = '㉥' + nationName;
if (nationName.length > 18) nationName = nationName.substring(0, 18);
nationName = truncateLegacyWidth('㉥' + nationName, 18);
}
if (nations.some((n) => n.name === nationName)) {
@@ -88,7 +101,7 @@ export class ActionDefinition<
color: '#330000',
typeCode: 'che_중립',
level: 0,
capitalCityId: null,
capitalCityId: 0,
chiefGeneralId: general.id,
gold: 0,
rice: uprisingCtx.baseRice,
@@ -110,24 +123,29 @@ export class ActionDefinition<
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`${general.name}${josaYi} ${cityName}에 거병하였습니다.`, {
category: LogCategory.USER,
format: LogFormat.PLAIN,
context.addLog(`<Y>${general.name}</>${josaYi} <G><b>${cityName}</b></>에 거병하였습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
context.addLog(`【거병】${general.name}${josaYi} 세력을 결성하였습니다.`, {
context.addLog(
`<Y><b>【거병】</b></><D><b>${general.name}</b></>${josaYi} 세력을 결성하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
context.addLog(`<G><b>${cityName}</b></>에서 거병`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
context.addLog(`${cityName}에서 거병`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
context.addLog(`${general.name}${josaYi} ${cityName}에서 거병`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
format: LogFormat.YEAR_MONTH,
});
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
tryApplyUniqueLottery(context, {
acquireType: '아이템',
reason: ACTION_NAME,
nationName,
});
const effects: GeneralActionEffect<TriggerState>[] = [
createNationAddEffect(newNation),
@@ -19,57 +19,26 @@ import {
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
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 { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
import {
FOUNDING_ARGS_SCHEMA,
getNationTypeDisplayName,
NATION_COLORS,
type FoundingArgs,
} from './foundingShared.js';
const ACTION_NAME = '건국';
const ARGS_SCHEMA = z.object({
nationName: z.string().min(1),
nationType: z.string().min(1),
colorType: z.number(),
});
export type FoundingArgs = z.infer<typeof ARGS_SCHEMA>;
const NATION_COLORS = [
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9',
];
interface FoundingResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState>
extends GeneralActionResolveContext<TriggerState> {
currentYearMonth?: number;
initYearMonth?: number;
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -81,7 +50,7 @@ export class ActionDefinition<
}
parseArgs(raw: unknown): FoundingArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
return parseArgsWithSchema(FOUNDING_ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
@@ -106,15 +75,20 @@ export class ActionDefinition<
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
context: FoundingResolveContext<TriggerState>,
args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation!;
const cityId = general.cityId!;
if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) {
throw new Error('Invalid color type');
if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) {
context.addLog('다음 턴부터 건국할 수 있습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } };
}
const color = NATION_COLORS[args.colorType];
@@ -123,33 +97,42 @@ export class ActionDefinition<
const josaGeneralYi = JosaUtil.pick(general.name, '이');
const city = context.city;
context.addLog(`${args.nationName}${josaNationUl} 건국하였습니다.`, {
category: LogCategory.USER,
format: LogFormat.PLAIN,
});
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국하였습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
context.addLog(`${general.name}${josaGeneralYi} ${city?.name}에 국가를 건설하였습니다.`, {
category: LogCategory.ACTION,
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <G><b>${city?.name}</b></>에 국가를 건설하였습니다.`, {
category: LogCategory.SUMMARY,
scope: LogScope.SYSTEM,
format: LogFormat.PLAIN,
format: LogFormat.MONTH,
});
context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, {
context.addLog(
`<Y><b>【건국】</b></>${getNationTypeDisplayName(args.nationType)} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
{
category: LogCategory.HISTORY,
scope: LogScope.SYSTEM,
format: LogFormat.YEAR_MONTH,
}
);
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
context.addLog(`${args.nationName}${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
});
context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
scope: LogScope.GENERAL,
format: LogFormat.YEAR_MONTH,
});
context.addLog(
`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`,
{
category: LogCategory.HISTORY,
scope: LogScope.NATION,
format: LogFormat.YEAR_MONTH,
}
);
tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME });
tryApplyUniqueLottery(context, {
acquireType: '건국',
reason: ACTION_NAME,
nationName: args.nationName,
});
const effects = [
createNationPatchEffect(
@@ -169,6 +152,7 @@ export class ActionDefinition<
createCityPatchEffect(
{
nationId: nation.id,
conflict: {},
},
cityId
),
@@ -183,7 +167,14 @@ export class ActionDefinition<
}
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
export const actionContextBuilder = defaultActionContextBuilder;
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
return {
...base,
currentYearMonth: options.world.currentYear * 12 + options.world.currentMonth - 1,
initYearMonth: init.year * 12 + init.month - 1,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: 'che_건국',
@@ -194,6 +185,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
nationType: 'string',
colorType: 'number',
},
argsSchema: ARGS_SCHEMA,
argsSchema: FOUNDING_ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -14,7 +14,11 @@ import type {
GeneralActionOutcome,
GeneralActionResolveContext,
} 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 { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
@@ -118,7 +122,7 @@ export class ActionDefinition<
createGeneralPatchEffect(
{
officerLevel: 1,
experience: Math.floor(lord.experience * 0.7),
experience: Math.round(lord.experience * 0.7),
meta: {
...lord.meta,
officer_city: 0,
@@ -126,7 +130,8 @@ export class ActionDefinition<
},
},
lord.id
)
),
createNationPatchEffect({ chiefGeneralId: general.id }, nation.id)
);
return { effects };
@@ -21,7 +21,6 @@ import {
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
@@ -29,15 +28,15 @@ import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContext
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
import {
FOUNDING_ARGS_SCHEMA,
getNationTypeDisplayName,
NATION_COLORS,
type FoundingArgs,
} from './foundingShared.js';
const ACTION_NAME = '무작위 도시 건국';
const ACTION_KEY = 'che_무작위건국';
const ARGS_SCHEMA = z.object({
nationName: z.string().min(1),
nationType: z.string().min(1),
colorType: z.number(),
});
export type FoundingArgs = z.infer<typeof ARGS_SCHEMA>;
export interface RandomFoundingResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -48,41 +47,14 @@ export interface RandomFoundingResolveContext<
initYearMonth?: number;
}
const NATION_COLORS = [
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9',
];
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => {
const inclusive = rng as InclusiveRandomGenerator;
return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length);
};
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -94,7 +66,7 @@ export class ActionDefinition<
}
parseArgs(raw: unknown): FoundingArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
return parseArgsWithSchema(FOUNDING_ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
@@ -131,10 +103,6 @@ export class ActionDefinition<
return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } };
}
if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) {
throw new Error('Invalid color type');
}
const candidates = (context.allCities ?? []).filter(
(city) => city.nationId === 0 && [5, 6].includes(city.level)
);
@@ -147,7 +115,7 @@ export class ActionDefinition<
return { effects: [], alternative: { commandKey: 'che_해산', args: {} } };
}
const picked = candidates[context.rng.nextInt(0, candidates.length)]!;
const picked = candidates[legacyChoiceIndex(context.rng, candidates.length)]!;
const cityId = picked.id;
const josaNationUl = JosaUtil.pick(args.nationName, '을');
@@ -161,25 +129,33 @@ export class ActionDefinition<
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <G><b>${picked.name}</b></>에 국가를 건설하였습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
context.addLog(
`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
`<Y><b>【건국】</b></>${getNationTypeDisplayName(args.nationType)} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
});
tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME });
tryApplyUniqueLottery(context, {
acquireType: '건국',
reason: ACTION_NAME,
nationName: args.nationName,
});
const effects: Array<GeneralActionEffect<TriggerState>> = [
createNationPatchEffect(
@@ -200,6 +176,7 @@ export class ActionDefinition<
createCityPatchEffect(
{
nationId: nation.id,
conflict: {},
},
cityId
),
@@ -248,6 +225,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
nationType: 'string',
colorType: 'number',
},
argsSchema: ARGS_SCHEMA,
argsSchema: FOUNDING_ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -18,58 +18,27 @@ import {
createNationPatchEffect,
} from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { z } from 'zod';
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 { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
import {
FOUNDING_ARGS_SCHEMA,
getNationTypeDisplayName,
NATION_COLORS,
type FoundingArgs,
} from './foundingShared.js';
const ACTION_NAME = '건국';
const ACTION_KEY = 'cr_건국';
const ARGS_SCHEMA = z.object({
nationName: z.string().min(1),
nationType: z.string().min(1),
colorType: z.number(),
});
export type FoundingArgs = z.infer<typeof ARGS_SCHEMA>;
const NATION_COLORS = [
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9',
];
interface FoundingResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState>
extends GeneralActionResolveContext<TriggerState> {
currentYearMonth?: number;
initYearMonth?: number;
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -81,7 +50,7 @@ export class ActionDefinition<
}
parseArgs(raw: unknown): FoundingArgs | null {
return parseArgsWithSchema(ARGS_SCHEMA, raw);
return parseArgsWithSchema(FOUNDING_ARGS_SCHEMA, raw);
}
buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
@@ -101,7 +70,7 @@ export class ActionDefinition<
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
context: FoundingResolveContext<TriggerState>,
args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
@@ -111,8 +80,13 @@ export class ActionDefinition<
}
const cityId = general.cityId;
if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) {
throw new Error('Invalid color type');
if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) {
context.addLog('다음 턴부터 건국할 수 있습니다.', {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } };
}
const color = NATION_COLORS[args.colorType];
@@ -126,26 +100,34 @@ export class ActionDefinition<
format: LogFormat.MONTH,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <G><b>${cityName}</b></>에 국가를 건설하였습니다.`, {
category: LogCategory.ACTION,
category: LogCategory.SUMMARY,
scope: LogScope.SYSTEM,
format: LogFormat.MONTH,
});
context.addLog(
`<Y><b>【건국】</b></>${args.nationType} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
`<Y><b>【건국】</b></>${getNationTypeDisplayName(args.nationType)} <D><b>${args.nationName}</b></>${josaNationYi} 새로이 등장하였습니다.`,
{
category: LogCategory.HISTORY,
scope: LogScope.SYSTEM,
format: LogFormat.YEAR_MONTH,
}
);
context.addLog(`<D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
scope: LogScope.GENERAL,
format: LogFormat.YEAR_MONTH,
});
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
scope: LogScope.NATION,
format: LogFormat.YEAR_MONTH,
});
tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME });
tryApplyUniqueLottery(context, {
acquireType: '건국',
reason: ACTION_NAME,
nationName: args.nationName,
});
const effects = [
createNationPatchEffect(
@@ -165,6 +147,7 @@ export class ActionDefinition<
createCityPatchEffect(
{
nationId: nation.id,
conflict: {},
},
cityId
),
@@ -178,7 +161,14 @@ export class ActionDefinition<
}
}
export const actionContextBuilder = defaultActionContextBuilder;
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
return {
...base,
currentYearMonth: options.world.currentYear * 12 + options.world.currentMonth - 1,
initYearMonth: init.year * 12 + init.month - 1,
};
};
export const commandSpec: GeneralTurnCommandSpec = {
key: ACTION_KEY,
@@ -189,6 +179,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
nationType: 'string',
colorType: 'number',
},
argsSchema: ARGS_SCHEMA,
argsSchema: FOUNDING_ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
};
@@ -0,0 +1,59 @@
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/nation/index.js';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import { z } from 'zod';
export const NATION_COLORS = [
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#FFA500',
'#FFDAB9',
'#FFD700',
'#FFFF00',
'#7CFC00',
'#00FF00',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#20B2AA',
'#6495ED',
'#7FFFD4',
'#AFEEEE',
'#87CEEB',
'#00FFFF',
'#00BFFF',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#BA55D3',
'#800080',
'#FF00FF',
'#FFC0CB',
'#F5F5DC',
'#E0FFFF',
'#FFFFFF',
'#A9A9A9',
] as const;
const SELECTABLE_NATION_TYPES = new Set<string>(NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립'));
export const FOUNDING_ARGS_SCHEMA = z.object({
nationName: z
.string()
.min(1)
.refine((value) => getLegacyStringWidth(value) <= 18),
nationType: z.string().refine((value) => SELECTABLE_NATION_TYPES.has(value)),
colorType: z
.number()
.int()
.min(0)
.max(NATION_COLORS.length - 1),
});
export type FoundingArgs = z.infer<typeof FOUNDING_ARGS_SCHEMA>;
export const getNationTypeDisplayName = (nationType: string): string =>
nationType.startsWith('che_') ? nationType.slice(4) : nationType;
+1 -1
View File
@@ -268,7 +268,7 @@ export const noPenalty = (penaltyKey: string): Constraint => ({
return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.');
}
const penalty = general.meta.penalty;
const penalty = (general as General & { penalty?: unknown }).penalty ?? general.meta.penalty;
if (!penalty || typeof penalty !== 'object' || Array.isArray(penalty)) {
return allow();
}
+12 -11
View File
@@ -265,24 +265,16 @@ export const differentDestNation = (): Constraint => ({
export const reqNationGeneralCount = (min: number): Constraint => ({
name: 'reqNationGeneralCount',
requires: (ctx) => {
const reqs: RequirementKey[] = [{ kind: 'generalList' }];
const reqs: RequirementKey[] = [];
if (ctx.nationId !== undefined) {
reqs.push({ kind: 'nation', id: ctx.nationId });
} else {
reqs.push({ kind: 'general', id: ctx.actorId });
reqs.push({ kind: 'nationList' });
}
return reqs;
},
test: (ctx, view) => {
const listReq: RequirementKey = { kind: 'generalList' };
if (!view.has(listReq)) {
return unknownOrDeny(ctx, [listReq], '장수가 없습니다.');
}
const generals = view.get(listReq) as General[] | null;
if (!generals) {
return unknownOrDeny(ctx, [listReq], '장수가 없습니다.');
}
let baseNationId = ctx.nationId;
if (baseNationId === undefined) {
const general = readGeneral(ctx, view);
@@ -296,7 +288,16 @@ export const reqNationGeneralCount = (min: number): Constraint => ({
baseNationId = general.nationId;
}
const count = generals.filter((g) => g.nationId === baseNationId).length;
const nationReq: RequirementKey =
ctx.nationId !== undefined ? { kind: 'nation', id: baseNationId } : { kind: 'nationList' };
const nation =
ctx.nationId !== undefined
? (view.get(nationReq) as Nation | null)
: ((view.get(nationReq) as Nation[] | null)?.find((entry) => entry.id === baseNationId) ?? null);
if (!nation) {
return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.');
}
const count = typeof nation.meta.gennum === 'number' ? nation.meta.gennum : 0;
if (count >= min) {
return allow();
}
@@ -144,7 +144,7 @@ describe('Blank Start Scenario', () => {
const FOUNDING_ARGS = {
nationName: 'NewChosen',
nationType: 'che_def',
nationType: 'che_도적',
colorType: 1,
};
@@ -218,6 +218,7 @@ describe('Blank Start Scenario', () => {
nationId: newNationId,
officerLevel: 1,
} as General;
world.getNation(newNationId)!.meta.gennum = 2;
// --- Step 3: Gen 0 performs Founding ---
// First, check name duplicate
@@ -232,7 +233,7 @@ describe('Blank Start Scenario', () => {
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
meta: { gennum: 2 },
};
world.snapshot.nations.push(duplicateNation);
const ctxDuplicate = createConstraintContext(world.getGeneral(gen0.id)!, 189, {
@@ -263,6 +264,10 @@ describe('Blank Start Scenario', () => {
commandKey: 'che_건국',
resolver: foundNationDef,
args: FOUNDING_ARGS,
context: {
currentYearMonth: 189 * 12 + 2,
initYearMonth: 189 * 12,
},
},
]);
@@ -293,7 +298,7 @@ describe('Blank Start Scenario', () => {
power: 0,
level: 0,
typeCode: 'che_def',
meta: {},
meta: { gennum: 2 },
};
world.snapshot.nations.push(newNation);
const gen0Idx = world.snapshot.generals.findIndex((g) => g.id === gen0.id);
@@ -195,6 +195,7 @@ describe('migrated general commands', () => {
expect(updatedChief.officerLevel).toBe(12);
expect(updatedLord.officerLevel).toBe(1);
expect(updatedLord.experience).toBe(700);
expect(world.getNation(nation.id)?.chiefGeneralId).toBe(chief.id);
});
it('che_선양: 제약 없는 대상에게 선양한다', async () => {
@@ -316,7 +317,7 @@ describe('migrated general commands', () => {
capitalCityId: null,
level: 0,
typeCode: 'None',
meta: {},
meta: { gennum: 2 },
});
const city = makeCity({ id: 1, nationId: 0, level: 5 });
@@ -330,7 +331,11 @@ describe('migrated general commands', () => {
generalId: lord.id,
commandKey: 'cr_건국',
resolver: foundNationSpec.createDefinition(SYSTEM_ENV),
args: { nationName: '신국', nationType: 'che_def', colorType: 1 },
args: { nationName: '신국', nationType: 'che_도적', colorType: 1 },
context: {
currentYearMonth: 201 * 12,
initYearMonth: 200 * 12,
},
},
]);
@@ -155,6 +155,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
dex5: readNumber(meta, 'dex5'),
specAge: readNumber(meta, 'specage'),
specAge2: readNumber(meta, 'specage2'),
makeLimit: readNumber(meta, 'makelimit'),
penalty: asRecord(row.penalty),
killTurn: readNumber(meta, 'killturn'),
mySet: readNumber(meta, 'myset'),
@@ -254,6 +254,7 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
...meta,
specage: readNumber(row, 'specAge', readNumber(meta, 'specage')),
specage2: readNumber(row, 'specAge2', readNumber(meta, 'specage2')),
makelimit: readNumber(row, 'makeLimit', readNumber(meta, 'makelimit')),
killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)),
leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')),
strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')),
@@ -573,6 +574,7 @@ const projectWorld = (
dex5: toDatabaseInt(readNumber(general.meta, 'dex5')),
specAge: toDatabaseInt(readNumber(general.meta, 'specage')),
specAge2: toDatabaseInt(readNumber(general.meta, 'specage2')),
makeLimit: toDatabaseInt(readNumber(general.meta, 'makelimit')),
penalty: asRecord(general.penalty),
killTurn: readNumber(general.meta, 'killturn'),
mySet: readNumber(general.meta, 'myset'),
@@ -721,7 +723,10 @@ export const runCoreTurnCommandTrace = async (
...(request.observe?.generalIds ?? []),
]),
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
nationIds: new Set([
...referenceBefore.nations.map((row) => readNumber(row, 'id')),
...(request.observe?.nationIds ?? []),
]),
initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))),
generalCooldowns: request.observe?.generalCooldowns ?? [],
nationCooldowns: request.observe?.nationCooldowns ?? [],
@@ -2240,6 +2240,192 @@ integration('general resignation, retirement, and abdication boundary, state, an
);
});
type GeneralFoundingRebellionBoundaryCase = {
name: string;
action: 'che_거병' | 'che_모반시도' | 'che_건국' | 'cr_건국' | 'che_무작위건국';
args?: Record<string, unknown>;
actorPatch?: Record<string, unknown>;
fixturePatches?: FixturePatches;
completed: boolean;
expectedActionKey?: string;
compareLogs?: boolean;
};
const foundingArgs = { nationName: '신국', nationType: 'che_도적', colorType: 1 };
const wanderingFoundingFixture = (overrides: FixturePatches = {}): FixturePatches => ({
...overrides,
world: { startYear: 180, initYear: 180, initMonth: 1, year: 181, ...overrides.world },
nations: {
...overrides.nations,
1: { level: 0, capitalCityId: 0, typeCode: 'None', generalCount: 2, ...overrides.nations?.[1] },
},
cities: {
...overrides.cities,
3: { nationId: 0, level: 5, ...overrides.cities?.[3] },
},
});
const generalFoundingRebellionBoundaryCases: GeneralFoundingRebellionBoundaryCase[] = [
{
name: 'uprising rejects a no-founding penalty from the general column',
action: 'che_거병',
actorPatch: { nationId: 0, officerLevel: 0, penalty: { noFoundNation: true } },
fixturePatches: { world: { startYear: 180, year: 181 } },
completed: false,
},
{
name: 'uprising rejects a remaining join-action cooldown',
action: 'che_거병',
actorPatch: { nationId: 0, officerLevel: 0, makeLimit: 1 },
fixturePatches: { world: { startYear: 180, year: 181 } },
completed: false,
},
{
name: 'uprising trims a duplicate full-width nation name and preserves logs',
action: 'che_거병',
actorPatch: { name: '가나다라마바사아자차', nationId: 0, officerLevel: 0 },
fixturePatches: {
world: { startYear: 180, year: 181 },
nations: { 1: { name: '가나다라마바사아자차' } },
},
completed: true,
compareLogs: true,
},
{
name: 'rebellion rejects an active monarch',
action: 'che_모반시도',
actorPatch: { officerLevel: 11 },
fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 999_999 } } },
completed: false,
},
{
name: 'rebellion rejects an npc monarch',
action: 'che_모반시도',
actorPatch: { officerLevel: 11 },
fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, npcState: 2 } } },
completed: false,
},
{
name: 'rebellion rounds the former monarch experience and transfers the chief cache',
action: 'che_모반시도',
actorPatch: { officerLevel: 11 },
fixturePatches: {
generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, experience: 1001 } },
},
completed: true,
compareLogs: true,
},
...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({
name: `${action} rejects a fractional color index`,
action,
args: { ...foundingArgs, colorType: 1.5 },
fixturePatches: wanderingFoundingFixture(),
completed: false,
})),
{
name: 'founding rejects a nation name wider than eighteen legacy columns',
action: 'che_건국',
args: { ...foundingArgs, nationName: '가나다라마바사아자차' },
fixturePatches: wanderingFoundingFixture(),
completed: false,
},
{
name: 'founding rejects an unavailable nation type',
action: 'che_건국',
args: { ...foundingArgs, nationType: 'che_없는국가형' },
fixturePatches: wanderingFoundingFixture(),
completed: false,
},
...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({
name: `${action} uses the cached nation general count`,
action,
args: foundingArgs,
fixturePatches: wanderingFoundingFixture({ nations: { 1: { generalCount: 1 } } }),
completed: false,
})),
{
name: 'founding continues into talent scout during the initial month',
action: 'che_건국',
args: foundingArgs,
fixturePatches: wanderingFoundingFixture({
world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 },
}),
completed: false,
expectedActionKey: 'che_인재탐색',
},
{
name: 'restricted founding continues into talent scout during the initial month',
action: 'cr_건국',
args: foundingArgs,
fixturePatches: wanderingFoundingFixture({
world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 },
}),
completed: false,
expectedActionKey: 'che_인재탐색',
},
...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({
name: `${action} clears the founding city conflict and preserves success logs`,
action,
args: foundingArgs,
fixturePatches: wanderingFoundingFixture({
cities: { 3: { conflict: { 2: 7 } } },
randomFoundingCandidateCityIds: action === 'che_무작위건국' ? [3] : undefined,
}),
completed: true,
compareLogs: true,
})),
];
integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => {
it.each(generalFoundingRebellionBoundaryCases)(
'$name',
async ({
name,
action,
args,
actorPatch,
fixturePatches,
completed,
expectedActionKey,
compareLogs,
}) => {
const request = buildRequest(action, args, actorPatch, fixturePatches);
request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`;
if (action === 'che_거병') {
request.observe!.nationIds!.push(3);
}
request.observe!.includeGlobalHistoryLogs = true;
request.observe!.includeNationHistoryLogs = true;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const actionKey = expectedActionKey ?? (completed ? action : '휴식');
expect(reference.execution.outcome).toMatchObject({ completed });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey,
usedFallback: !completed && actionKey === '휴식',
});
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
if (compareLogs) {
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
}
},
120_000
);
});
type SabotageProbabilityClampCase = {
name: string;
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';