fix nation symbol legacy boundaries
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user