fix nation symbol legacy boundaries

This commit is contained in:
2026-07-26 13:39:05 +00:00
parent 6d642bb759
commit 935f6ebe97
8 changed files with 399 additions and 17 deletions
@@ -32,12 +32,13 @@ import {
resolveUniqueConfig,
rollUniqueLottery,
getNextTurnAt,
getBillByLevel,
type ItemModule,
type UniqueLotteryRunner,
} from '@sammo-ts/logic';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
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';
@@ -105,7 +106,8 @@ const applyLegacyGeneralProgression = (
general: TurnGeneral,
previousGeneral: TurnGeneral,
actionKey: string,
env: TurnCommandEnv
env: TurnCommandEnv,
logs: LogEntryDraft[]
): TurnGeneral => {
const maxStatLevel = env.maxStatLevel ?? 255;
const maxDedicationLevel = env.maxDedicationLevel ?? 30;
@@ -126,10 +128,40 @@ const applyLegacyGeneralProgression = (
const forceRefreshLevel = actionKey === 'che_하야';
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
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)) {
const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0);
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)) {
@@ -935,6 +967,16 @@ export const createReservedTurnHandler = async (options: {
general: currentGeneral,
city: currentCity,
nation: currentNation,
...(worldView
? {
worldView: {
listGenerals: () => worldView.listGenerals(),
listGeneralsByCity: (cityId: number) =>
worldView.listGenerals().filter((general) => general.cityId === cityId),
listNations: () => worldView.listNations(),
},
}
: {}),
rng: actionRng,
uniqueLottery,
};
@@ -1059,7 +1101,8 @@ export const createReservedTurnHandler = async (options: {
currentGeneral,
generalBeforeExecution,
actionKey,
env
env,
logs
);
}
if (
@@ -159,6 +159,29 @@ const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
});
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 () => {
const harness = await createTurnTestHarness({
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 { ScenarioMeta } 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 {
nextFloat1(): number;
@@ -18,6 +19,7 @@ export type ActionContextBase = {
general: ActionContextGeneral;
city?: City;
nation?: Nation | null;
worldView?: GeneralWorldView;
rng: ActionRandomSource;
uniqueLottery?: UniqueLotteryRunner;
};
@@ -53,12 +53,28 @@ const NATION_COLORS = [
'#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({
colorType: z
.number()
.int()
.min(0)
.max(NATION_COLORS.length - 1),
.union([z.number(), z.string(), z.boolean()])
.refine((value) => resolveNationColorIndex(value) !== null),
});
export type ChangeFlagArgs = z.infer<typeof ARGS_SCHEMA>;
@@ -100,7 +116,11 @@ export class ActionDefinition<
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 nationName = nation.name;
@@ -120,11 +140,11 @@ export class ActionDefinition<
),
// Global Action Log
createLogEffect(
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다.`,
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다`,
{
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
}
),
// Global History Log
@@ -138,7 +158,7 @@ export class ActionDefinition<
),
// Actor Nation History Log
createLogEffect(
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다.`,
`<Y>${generalName}</>${josaYi} <span style='color:${color};'><b>국기</b></span>를 변경하였습니다`,
{
scope: LogScope.NATION,
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 { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
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>;
@@ -65,8 +73,21 @@ export class ActionDefinition<
const oldNationName = nation.name;
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 josaYiNation = JosaUtil.pick(newNationName, '이');
const josaYiNation = JosaUtil.pick(oldNationName, '이');
const josaRo = JosaUtil.pick(newNationName, '로');
const effects: Array<GeneralActionEffect<TriggerState>> = [
@@ -83,8 +104,8 @@ export class ActionDefinition<
// Global Action Log
createLogEffect(`<Y>${generalName}</>${josaYi} 국호를 <D><b>${newNationName}</b></>${josaRo} 변경합니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
}),
// Global History Log
createLogEffect(
+1
View File
@@ -6,6 +6,7 @@ import { TriggerCaller, type Trigger } from './core.js';
export interface GeneralWorldView<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
listGenerals(): General<TriggerState>[];
listGeneralsByCity?(cityId: number): General<TriggerState>[];
listNations?(): Nation[];
}
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 { ActionDefinition as DeclareWarAction } 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 LastStandAction } 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 = {
entries: [{ startMinute: 0, tickMinutes: 60 }],
};
const changeNationNameArgsSchema = changeNationNameCommandSpec.argsSchema!;
const changeNationFlagArgsSchema = changeNationFlagCommandSpec.argsSchema!;
describe('Nation Actions', () => {
describe('che_선전포고 (Declare War)', () => {
@@ -312,6 +321,12 @@ describe('Nation Actions', () => {
});
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', () => {
const nation = buildNation(1, 'OldName');
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)', () => {
@@ -25,6 +25,27 @@ const readNationMeta = (row: { meta?: unknown } | undefined): Record<string, unk
const readGeneralMeta = readNationMeta;
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 = [
/^generalTurns/,
/^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';
const volunteerRecruitBoundaryCases: Array<{