fix nation symbol legacy boundaries
This commit is contained in:
@@ -32,12 +32,13 @@ import {
|
|||||||
resolveUniqueConfig,
|
resolveUniqueConfig,
|
||||||
rollUniqueLottery,
|
rollUniqueLottery,
|
||||||
getNextTurnAt,
|
getNextTurnAt,
|
||||||
|
getBillByLevel,
|
||||||
type ItemModule,
|
type ItemModule,
|
||||||
type UniqueLotteryRunner,
|
type UniqueLotteryRunner,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ const applyLegacyGeneralProgression = (
|
|||||||
general: TurnGeneral,
|
general: TurnGeneral,
|
||||||
previousGeneral: TurnGeneral,
|
previousGeneral: TurnGeneral,
|
||||||
actionKey: string,
|
actionKey: string,
|
||||||
env: TurnCommandEnv
|
env: TurnCommandEnv,
|
||||||
|
logs: LogEntryDraft[]
|
||||||
): TurnGeneral => {
|
): TurnGeneral => {
|
||||||
const maxStatLevel = env.maxStatLevel ?? 255;
|
const maxStatLevel = env.maxStatLevel ?? 255;
|
||||||
const maxDedicationLevel = env.maxDedicationLevel ?? 30;
|
const maxDedicationLevel = env.maxDedicationLevel ?? 30;
|
||||||
@@ -126,10 +128,40 @@ const applyLegacyGeneralProgression = (
|
|||||||
const forceRefreshLevel = actionKey === 'che_하야';
|
const forceRefreshLevel = actionKey === 'che_하야';
|
||||||
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
|
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
|
||||||
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
|
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
|
||||||
|
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
|
||||||
meta.explevel = expLevel;
|
meta.explevel = expLevel;
|
||||||
|
if (expLevel !== previousExpLevel) {
|
||||||
|
const josaRo = JosaUtil.pick(String(expLevel), '로');
|
||||||
|
logs.push({
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
text:
|
||||||
|
expLevel > previousExpLevel
|
||||||
|
? `<C>Lv ${expLevel}</>${josaRo} <C>레벨업</>!`
|
||||||
|
: `<C>Lv ${expLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
|
if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
|
||||||
|
const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0);
|
||||||
meta.dedlevel = dedicationLevel;
|
meta.dedlevel = dedicationLevel;
|
||||||
|
if (dedicationLevel !== previousDedicationLevel) {
|
||||||
|
const dedicationLevelText =
|
||||||
|
dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`;
|
||||||
|
const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US');
|
||||||
|
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||||
|
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||||
|
logs.push({
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
text:
|
||||||
|
dedicationLevel > previousDedicationLevel
|
||||||
|
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||||
|
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!LEGACY_STAT_CHANGE_GENERAL_ACTIONS.has(actionKey)) {
|
if (!LEGACY_STAT_CHANGE_GENERAL_ACTIONS.has(actionKey)) {
|
||||||
@@ -935,6 +967,16 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
|
...(worldView
|
||||||
|
? {
|
||||||
|
worldView: {
|
||||||
|
listGenerals: () => worldView.listGenerals(),
|
||||||
|
listGeneralsByCity: (cityId: number) =>
|
||||||
|
worldView.listGenerals().filter((general) => general.cityId === cityId),
|
||||||
|
listNations: () => worldView.listNations(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
rng: actionRng,
|
rng: actionRng,
|
||||||
uniqueLottery,
|
uniqueLottery,
|
||||||
};
|
};
|
||||||
@@ -1059,7 +1101,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
currentGeneral,
|
currentGeneral,
|
||||||
generalBeforeExecution,
|
generalBeforeExecution,
|
||||||
actionKey,
|
actionKey,
|
||||||
env
|
env,
|
||||||
|
logs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -159,6 +159,29 @@ const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('legacy general turn lifecycle', () => {
|
describe('legacy general turn lifecycle', () => {
|
||||||
|
it('emits legacy plain logs when command gains cross experience and dedication levels', async () => {
|
||||||
|
const harness = await createTurnTestHarness({
|
||||||
|
snapshot: makeSnapshot([
|
||||||
|
makeGeneral({
|
||||||
|
injury: 10,
|
||||||
|
experience: 995,
|
||||||
|
dedication: 899,
|
||||||
|
meta: { killturn: 24, explevel: 9, dedlevel: 3 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
state: makeState(),
|
||||||
|
schedule,
|
||||||
|
map,
|
||||||
|
});
|
||||||
|
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} };
|
||||||
|
|
||||||
|
await harness.runOneTick();
|
||||||
|
|
||||||
|
const logTexts = harness.world.peekDirtyState().logs.map((log) => log.text);
|
||||||
|
expect(logTexts).toContain('<C>Lv 10</>으로 <C>레벨업</>!');
|
||||||
|
expect(logTexts).toContain('<Y>27품관</>으로 <C>승급</>하여 봉록이 <C>1,200</>으로 <C>상승</>했습니다!');
|
||||||
|
});
|
||||||
|
|
||||||
it('runs preprocess before commands and restores crew population when rice is insufficient', async () => {
|
it('runs preprocess before commands and restores crew population when rice is insufficient', async () => {
|
||||||
const harness = await createTurnTestHarness({
|
const harness = await createTurnTestHarness({
|
||||||
snapshot: makeSnapshot([makeGeneral({ injury: 25, crew: 200, rice: 1 })]),
|
snapshot: makeSnapshot([makeGeneral({ injury: 25, crew: 200, rice: 1 })]),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { UniqueLotteryRunner } from '@sammo-ts/logic/rewards/uniqueLottery.
|
|||||||
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
||||||
import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js';
|
import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js';
|
||||||
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
|
import type { GeneralWorldView } from '@sammo-ts/logic/triggers/general.js';
|
||||||
|
|
||||||
export interface ActionRandomSource {
|
export interface ActionRandomSource {
|
||||||
nextFloat1(): number;
|
nextFloat1(): number;
|
||||||
@@ -18,6 +19,7 @@ export type ActionContextBase = {
|
|||||||
general: ActionContextGeneral;
|
general: ActionContextGeneral;
|
||||||
city?: City;
|
city?: City;
|
||||||
nation?: Nation | null;
|
nation?: Nation | null;
|
||||||
|
worldView?: GeneralWorldView;
|
||||||
rng: ActionRandomSource;
|
rng: ActionRandomSource;
|
||||||
uniqueLottery?: UniqueLotteryRunner;
|
uniqueLottery?: UniqueLotteryRunner;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,12 +53,28 @@ const NATION_COLORS = [
|
|||||||
'#A9A9A9',
|
'#A9A9A9',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const resolveNationColorIndex = (value: number | string | boolean): number | null => {
|
||||||
|
let index: number;
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
index = value ? 1 : 0;
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
index = Math.trunc(value);
|
||||||
|
} else {
|
||||||
|
if (!/^(?:0|[1-9]\d*|-[1-9]\d*)$/.test(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
index = Number(value);
|
||||||
|
}
|
||||||
|
return Number.isSafeInteger(index) && index >= 0 && index < NATION_COLORS.length ? index : null;
|
||||||
|
};
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
colorType: z
|
colorType: z
|
||||||
.number()
|
.union([z.number(), z.string(), z.boolean()])
|
||||||
.int()
|
.refine((value) => resolveNationColorIndex(value) !== null),
|
||||||
.min(0)
|
|
||||||
.max(NATION_COLORS.length - 1),
|
|
||||||
});
|
});
|
||||||
export type ChangeFlagArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type ChangeFlagArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -100,7 +116,11 @@ export class ActionDefinition<
|
|||||||
return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] };
|
return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = NATION_COLORS[args.colorType];
|
const colorIndex = resolveNationColorIndex(args.colorType);
|
||||||
|
if (colorIndex === null) {
|
||||||
|
return { effects: [] };
|
||||||
|
}
|
||||||
|
const color = NATION_COLORS[colorIndex];
|
||||||
const generalName = general.name;
|
const generalName = general.name;
|
||||||
const nationName = nation.name;
|
const nationName = nation.name;
|
||||||
|
|
||||||
@@ -120,11 +140,11 @@ export class ActionDefinition<
|
|||||||
),
|
),
|
||||||
// Global Action Log
|
// Global Action Log
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다`,
|
||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// Global History Log
|
// Global History Log
|
||||||
@@ -138,7 +158,7 @@ export class ActionDefinition<
|
|||||||
),
|
),
|
||||||
// Actor Nation History Log
|
// Actor Nation History Log
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다.`,
|
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다`,
|
||||||
{
|
{
|
||||||
scope: LogScope.NATION,
|
scope: LogScope.NATION,
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import type { NationTurnCommandSpec } from './index.js';
|
import type { NationTurnCommandSpec } from './index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
nationName: z.string().trim().min(1).max(8),
|
nationName: z.string().superRefine((nationName, ctx) => {
|
||||||
|
if (nationName === '' || getLegacyStringWidth(nationName) > 18) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: '국호는 전각 9자 또는 반각 18자 이하여야 합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
export type ChangeNationNameArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type ChangeNationNameArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
|
|
||||||
@@ -65,8 +73,21 @@ export class ActionDefinition<
|
|||||||
const oldNationName = nation.name;
|
const oldNationName = nation.name;
|
||||||
const newNationName = args.nationName;
|
const newNationName = args.nationName;
|
||||||
|
|
||||||
|
if (context.worldView?.listNations?.().some((candidate) => candidate.name === newNationName)) {
|
||||||
|
return {
|
||||||
|
completed: false,
|
||||||
|
effects: [
|
||||||
|
createLogEffect(`이미 같은 국호를 가진 곳이 있습니다. ${ACTION_NAME} 실패`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const josaYi = JosaUtil.pick(generalName, '이');
|
const josaYi = JosaUtil.pick(generalName, '이');
|
||||||
const josaYiNation = JosaUtil.pick(newNationName, '이');
|
const josaYiNation = JosaUtil.pick(oldNationName, '이');
|
||||||
const josaRo = JosaUtil.pick(newNationName, '로');
|
const josaRo = JosaUtil.pick(newNationName, '로');
|
||||||
|
|
||||||
const effects: Array<GeneralActionEffect<TriggerState>> = [
|
const effects: Array<GeneralActionEffect<TriggerState>> = [
|
||||||
@@ -83,8 +104,8 @@ export class ActionDefinition<
|
|||||||
// Global Action Log
|
// Global Action Log
|
||||||
createLogEffect(`<Y>${generalName}</>${josaYi} 국호를 <D><b>${newNationName}</b></>${josaRo} 변경합니다.`, {
|
createLogEffect(`<Y>${generalName}</>${josaYi} 국호를 <D><b>${newNationName}</b></>${josaRo} 변경합니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}),
|
}),
|
||||||
// Global History Log
|
// Global History Log
|
||||||
createLogEffect(
|
createLogEffect(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TriggerCaller, type Trigger } from './core.js';
|
|||||||
export interface GeneralWorldView<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
export interface GeneralWorldView<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
listGenerals(): General<TriggerState>[];
|
listGenerals(): General<TriggerState>[];
|
||||||
listGeneralsByCity?(cityId: number): General<TriggerState>[];
|
listGeneralsByCity?(cityId: number): General<TriggerState>[];
|
||||||
|
listNations?(): Nation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneralActionLogSink {
|
export interface GeneralActionLogSink {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import type { City, General, Nation } from '../../../src/domain/entities.js';
|
|||||||
import { resolveGeneralAction } from '../../../src/actions/engine.js';
|
import { resolveGeneralAction } from '../../../src/actions/engine.js';
|
||||||
import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/nation/che_선전포고.js';
|
import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/nation/che_선전포고.js';
|
||||||
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
|
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
|
||||||
import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js';
|
import {
|
||||||
|
ActionDefinition as ChangeNationNameAction,
|
||||||
|
commandSpec as changeNationNameCommandSpec,
|
||||||
|
} from '../../../src/actions/turn/nation/che_국호변경.js';
|
||||||
|
import {
|
||||||
|
ActionDefinition as ChangeNationFlagAction,
|
||||||
|
commandSpec as changeNationFlagCommandSpec,
|
||||||
|
} from '../../../src/actions/turn/nation/che_국기변경.js';
|
||||||
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
|
||||||
import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js';
|
import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js';
|
||||||
import { ActionDefinition as DeceptionAction } from '../../../src/actions/turn/nation/che_허보.js';
|
import { ActionDefinition as DeceptionAction } from '../../../src/actions/turn/nation/che_허보.js';
|
||||||
@@ -97,6 +104,8 @@ const buildNation = (id: number, name = 'Nation'): Nation => ({
|
|||||||
const schedule: TurnSchedule = {
|
const schedule: TurnSchedule = {
|
||||||
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
entries: [{ startMinute: 0, tickMinutes: 60 }],
|
||||||
};
|
};
|
||||||
|
const changeNationNameArgsSchema = changeNationNameCommandSpec.argsSchema!;
|
||||||
|
const changeNationFlagArgsSchema = changeNationFlagCommandSpec.argsSchema!;
|
||||||
|
|
||||||
describe('Nation Actions', () => {
|
describe('Nation Actions', () => {
|
||||||
describe('che_선전포고 (Declare War)', () => {
|
describe('che_선전포고 (Declare War)', () => {
|
||||||
@@ -312,6 +321,12 @@ describe('Nation Actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('che_국호변경 (Change Nation Name)', () => {
|
describe('che_국호변경 (Change Nation Name)', () => {
|
||||||
|
it('uses the legacy display width without trimming the name', () => {
|
||||||
|
expect(changeNationNameArgsSchema.safeParse({ nationName: '가나다라마바사아자' }).success).toBe(true);
|
||||||
|
expect(changeNationNameArgsSchema.safeParse({ nationName: ' ' }).success).toBe(true);
|
||||||
|
expect(changeNationNameArgsSchema.safeParse({ nationName: '가나다라마바사아자차' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('changes nation name', () => {
|
it('changes nation name', () => {
|
||||||
const nation = buildNation(1, 'OldName');
|
const nation = buildNation(1, 'OldName');
|
||||||
const general = buildGeneral(1, 1, 1);
|
const general = buildGeneral(1, 1, 1);
|
||||||
@@ -333,6 +348,75 @@ describe('Nation Actions', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a failed original action when any nation already has the name', () => {
|
||||||
|
const nation = buildNation(1, 'OldName');
|
||||||
|
const duplicateNation = buildNation(2, 'Duplicate');
|
||||||
|
const general = buildGeneral(1, 1, 1);
|
||||||
|
const definition = new ChangeNationNameAction();
|
||||||
|
|
||||||
|
const resolution = definition.resolve(
|
||||||
|
{
|
||||||
|
general,
|
||||||
|
nation,
|
||||||
|
worldView: {
|
||||||
|
listGenerals: () => [general],
|
||||||
|
listNations: () => [nation, duplicateNation],
|
||||||
|
},
|
||||||
|
rng: {} as any,
|
||||||
|
addLog: () => {},
|
||||||
|
},
|
||||||
|
{ nationName: duplicateNation.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.completed).toBe(false);
|
||||||
|
expect(resolution.effects).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'log',
|
||||||
|
entry: expect.objectContaining({
|
||||||
|
text: '이미 같은 국호를 가진 곳이 있습니다. 국호변경 실패',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('che_국기변경 (Change Nation Flag)', () => {
|
||||||
|
it('accepts the same scalar array keys as PHP and preserves the raw argument', () => {
|
||||||
|
for (const colorType of [0, '1', true, false, 1.9, -0.9, 32.9]) {
|
||||||
|
const parsed = changeNationFlagArgsSchema.safeParse({ colorType });
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
if (parsed.success) {
|
||||||
|
expect(parsed.data.colorType).toBe(colorType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const colorType of ['01', '1.5', 33, -1, null]) {
|
||||||
|
expect(changeNationFlagArgsSchema.safeParse({ colorType }).success).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a numeric string to the matching legacy color', () => {
|
||||||
|
const nation = buildNation(1);
|
||||||
|
const general = buildGeneral(1, 1, 1);
|
||||||
|
const definition = new ChangeNationFlagAction();
|
||||||
|
|
||||||
|
const resolution = definition.resolve(
|
||||||
|
{
|
||||||
|
general,
|
||||||
|
nation,
|
||||||
|
rng: {} as any,
|
||||||
|
addLog: () => {},
|
||||||
|
},
|
||||||
|
{ colorType: '1' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.effects).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'nation:patch',
|
||||||
|
patch: expect.objectContaining({ color: '#800000' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('che_증축 (City Expansion)', () => {
|
describe('che_증축 (City Expansion)', () => {
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ const readNationMeta = (row: { meta?: unknown } | undefined): Record<string, unk
|
|||||||
const readGeneralMeta = readNationMeta;
|
const readGeneralMeta = readNationMeta;
|
||||||
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...';
|
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...';
|
||||||
|
|
||||||
|
const normalizeStoredLogText = (value: unknown): string =>
|
||||||
|
String(value)
|
||||||
|
.replace(/^(?:<C>●<\/>|<S>◆<\/>|<R>★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '')
|
||||||
|
.replace(/ <1>\d{2}:\d{2}<\/>$/, '');
|
||||||
|
|
||||||
|
const semanticLogSignatures = (logs: Array<Record<string, unknown>>): string[] =>
|
||||||
|
logs
|
||||||
|
.map((entry) =>
|
||||||
|
JSON.stringify({
|
||||||
|
scope: String(entry.scope).toLowerCase(),
|
||||||
|
category: String(entry.category).toLowerCase(),
|
||||||
|
generalId: Number(entry.generalId) || null,
|
||||||
|
nationId: Number(entry.nationId) || null,
|
||||||
|
text: normalizeStoredLogText(entry.text),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const nationCommandLogs = (logs: Array<Record<string, unknown>>): Array<Record<string, unknown>> =>
|
||||||
|
logs.filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.');
|
||||||
|
|
||||||
const ignoredLifecyclePaths = [
|
const ignoredLifecyclePaths = [
|
||||||
/^generalTurns/,
|
/^generalTurns/,
|
||||||
/^nationTurns/,
|
/^nationTurns/,
|
||||||
@@ -679,6 +700,173 @@ integration('nation command success matrix', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nationNameBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
nationName: string;
|
||||||
|
completed: boolean;
|
||||||
|
duplicate?: boolean;
|
||||||
|
}> = [
|
||||||
|
{ name: 'accepts nine full-width characters at width eighteen', nationName: '가나다라마바사아자', completed: true },
|
||||||
|
{ name: 'accepts eighteen half-width characters', nationName: '123456789012345678', completed: true },
|
||||||
|
{ name: 'preserves surrounding spaces', nationName: ' 신국 ', completed: true },
|
||||||
|
{ name: 'accepts a single space', nationName: ' ', completed: true },
|
||||||
|
{ name: 'rejects ten full-width characters at width twenty', nationName: '가나다라마바사아자차', completed: false },
|
||||||
|
{ name: 'rejects nineteen half-width characters', nationName: '1234567890123456789', completed: false },
|
||||||
|
{ name: 'rejects another nation duplicate', nationName: '타국', completed: false, duplicate: true },
|
||||||
|
{ name: 'rejects the current nation duplicate', nationName: '아국', completed: false, duplicate: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const nationColors = [
|
||||||
|
'#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 nationFlagBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
colorType: unknown;
|
||||||
|
completed: boolean;
|
||||||
|
expectedIndex?: number;
|
||||||
|
}> = [
|
||||||
|
{ name: 'accepts integer zero', colorType: 0, completed: true, expectedIndex: 0 },
|
||||||
|
{ name: 'accepts an integer numeric string', colorType: '1', completed: true, expectedIndex: 1 },
|
||||||
|
{ name: 'accepts boolean true as key one', colorType: true, completed: true, expectedIndex: 1 },
|
||||||
|
{ name: 'accepts boolean false as key zero', colorType: false, completed: true, expectedIndex: 0 },
|
||||||
|
{ name: 'truncates a positive fractional key', colorType: 1.9, completed: true, expectedIndex: 1 },
|
||||||
|
{ name: 'truncates a negative fraction to zero', colorType: -0.9, completed: true, expectedIndex: 0 },
|
||||||
|
{ name: 'accepts the upper fractional key', colorType: 32.9, completed: true, expectedIndex: 32 },
|
||||||
|
{ name: 'rejects a leading-zero numeric string', colorType: '01', completed: false },
|
||||||
|
{ name: 'rejects a fractional numeric string', colorType: '1.5', completed: false },
|
||||||
|
{ name: 'rejects the first out-of-range integer', colorType: 33, completed: false },
|
||||||
|
{ name: 'rejects a negative integer', colorType: -1, completed: false },
|
||||||
|
{ name: 'rejects null', colorType: null, completed: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const addedReferenceLogs = (
|
||||||
|
before: { watermarks: { logId: number; historyLogId: number } },
|
||||||
|
afterLogs: Array<Record<string, unknown>>
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
afterLogs.filter((entry) => {
|
||||||
|
const scope = String(entry.scope).toLowerCase();
|
||||||
|
const category = String(entry.category).toLowerCase();
|
||||||
|
const watermark =
|
||||||
|
scope === 'nation' || (scope === 'system' && category === 'history')
|
||||||
|
? before.watermarks.historyLogId
|
||||||
|
: before.watermarks.logId;
|
||||||
|
return (Number(entry.id) || 0) > watermark;
|
||||||
|
});
|
||||||
|
|
||||||
|
integration('nation name boundary parity', () => {
|
||||||
|
it.each(nationNameBoundaryCases)(
|
||||||
|
'$name',
|
||||||
|
async ({ nationName, completed, duplicate }) => {
|
||||||
|
const request = buildRequest('che_국호변경', { nationName });
|
||||||
|
request.observe!.includeNationHistoryLogs = true;
|
||||||
|
request.observe!.includeGlobalHistoryLogs = true;
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
if (duplicate) {
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_국호변경',
|
||||||
|
actionKey: 'che_국호변경',
|
||||||
|
usedFallback: false,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_국호변경',
|
||||||
|
actionKey: completed ? 'che_국호변경' : '휴식',
|
||||||
|
usedFallback: !completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
if (completed) {
|
||||||
|
expect(core.after.nations.find((entry) => entry.id === 1)?.name).toBe(nationName);
|
||||||
|
expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual(
|
||||||
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
integration('nation flag boundary parity', () => {
|
||||||
|
it.each(nationFlagBoundaryCases)(
|
||||||
|
'$name',
|
||||||
|
async ({ colorType, completed, expectedIndex }) => {
|
||||||
|
const request = buildRequest('che_국기변경', { colorType });
|
||||||
|
request.observe!.includeNationHistoryLogs = true;
|
||||||
|
request.observe!.includeGlobalHistoryLogs = true;
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: 'che_국기변경',
|
||||||
|
actionKey: completed ? 'che_국기변경' : '휴식',
|
||||||
|
usedFallback: !completed,
|
||||||
|
});
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
if (completed) {
|
||||||
|
expect(core.after.nations.find((entry) => entry.id === 1)?.color).toBe(nationColors[expectedIndex!]);
|
||||||
|
expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual(
|
||||||
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed';
|
type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed';
|
||||||
|
|
||||||
const volunteerRecruitBoundaryCases: Array<{
|
const volunteerRecruitBoundaryCases: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user