fix general military preparation parity
This commit is contained in:
@@ -18,12 +18,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
|
|||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import {
|
import { equipNewItem, removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
|
||||||
cloneItemInventory,
|
|
||||||
ensureItemInventory,
|
|
||||||
equipNewItem,
|
|
||||||
removeEquippedItem,
|
|
||||||
} from '@sammo-ts/logic/items/inventory.js';
|
|
||||||
|
|
||||||
const ACTION_NAME = '장비매매';
|
const ACTION_NAME = '장비매매';
|
||||||
const ACTION_KEY = 'che_장비매매';
|
const ACTION_KEY = 'che_장비매매';
|
||||||
@@ -167,17 +162,12 @@ export class ActionDefinition<
|
|||||||
const josaUl = JosaUtil.pick(itemRawName, '을');
|
const josaUl = JosaUtil.pick(itemRawName, '을');
|
||||||
|
|
||||||
const nextGold = buying ? Math.max(0, general.gold - itemCost) : general.gold + Math.floor(itemCost / 2);
|
const nextGold = buying ? Math.max(0, general.gold - itemCost) : general.gold + Math.floor(itemCost / 2);
|
||||||
const nextGeneral = {
|
|
||||||
...general,
|
|
||||||
role: { ...general.role, items: { ...general.role.items } },
|
|
||||||
itemInventory: cloneItemInventory(ensureItemInventory(general)),
|
|
||||||
};
|
|
||||||
if (buying) {
|
if (buying) {
|
||||||
equipNewItem(nextGeneral, itemType, finalItemCode, {
|
equipNewItem(general, itemType, finalItemCode, {
|
||||||
...(item?.initialCharges === undefined ? {} : { charges: item.initialCharges }),
|
...(item?.initialCharges === undefined ? {} : { charges: item.initialCharges }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
removeEquippedItem(nextGeneral, itemType);
|
removeEquippedItem(general, itemType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buying) {
|
if (buying) {
|
||||||
@@ -198,7 +188,7 @@ export class ActionDefinition<
|
|||||||
const josaYi = JosaUtil.pick(general.name, '이');
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
context.addLog(`<Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.SUMMARY,
|
||||||
});
|
});
|
||||||
context.addLog(
|
context.addLog(
|
||||||
`<R><b>【판매】</b></><D><b>${nation.name}</b></>의 <Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`,
|
`<R><b>【판매】</b></><D><b>${nation.name}</b></>의 <Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`,
|
||||||
@@ -216,8 +206,6 @@ export class ActionDefinition<
|
|||||||
createGeneralPatchEffect<TriggerState>({
|
createGeneralPatchEffect<TriggerState>({
|
||||||
gold: nextGold,
|
gold: nextGold,
|
||||||
experience: general.experience + 10,
|
experience: general.experience + 10,
|
||||||
role: nextGeneral.role,
|
|
||||||
itemInventory: nextGeneral.itemInventory,
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,14 +68,21 @@ export const ARGS_SCHEMA = z.preprocess(
|
|||||||
return { ...record, crewType };
|
return { ...record, crewType };
|
||||||
},
|
},
|
||||||
z.object({
|
z.object({
|
||||||
crewType: z.preprocess(
|
crewType: z.number().int().positive(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value) : value),
|
amount: z.preprocess((value) => {
|
||||||
z.number().int().positive()
|
if (typeof value === 'number') {
|
||||||
),
|
return Number.isFinite(value) ? Math.trunc(value) : value;
|
||||||
amount: z.preprocess(
|
}
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value) : value),
|
if (typeof value === 'string') {
|
||||||
z.number().int().min(0)
|
const trimmed = value.trim();
|
||||||
),
|
if (!/^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$/.test(trimmed)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const numeric = Number(trimmed);
|
||||||
|
return Number.isFinite(numeric) ? Math.trunc(numeric) : value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}, z.number().int().min(0)),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
export type RecruitArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type RecruitArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
@@ -107,6 +114,10 @@ const readCityTrust = (city: City, fallback: number): number => {
|
|||||||
return typeof trust === 'number' ? trust : fallback;
|
return typeof trust === 'number' ? trust : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
|
||||||
|
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
|
||||||
|
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
|
||||||
|
|
||||||
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
|
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
|
||||||
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
|
||||||
return { ...meta, [key]: current + delta };
|
return { ...meta, [key]: current + delta };
|
||||||
@@ -365,7 +376,7 @@ export class ActionResolver<
|
|||||||
const nextPopulation = Math.max(city.population - recruitPop, 0);
|
const nextPopulation = Math.max(city.population - recruitPop, 0);
|
||||||
const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST);
|
const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST);
|
||||||
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
|
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
|
||||||
const nextTrust = Math.max(baseTrust - trustLoss, 0);
|
const nextTrust = toLegacyStoredTrust(Math.max(baseTrust - trustLoss, 0));
|
||||||
|
|
||||||
const actionName = this.env.actionName ?? ACTION_NAME;
|
const actionName = this.env.actionName ?? ACTION_NAME;
|
||||||
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
|
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
|
||||||
|
|||||||
@@ -1104,6 +1104,285 @@ integration('general domestic command boundary, value, and log parity', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface MilitaryPreparationBoundaryCase {
|
||||||
|
name: string;
|
||||||
|
action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련';
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
actorPatch?: Record<string, unknown>;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const militaryPreparationBoundaryCases: MilitaryPreparationBoundaryCase[] = [
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects a numeric-string crew type',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: '1100', amount: 100 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects a fractional crew type',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100.9, amount: 100 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment accepts a numeric-string amount',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: '100' },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects a non-decimal numeric-looking amount',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: '0x64' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment truncates a fractional amount',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100.9 },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment raises zero amount to the minimum',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 0 },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects a negative amount',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: -1 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects an unknown crew type',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 9999, amount: 100 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects a neutral actor',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
actorPatch: { nationId: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects a foreign-occupied city',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects population below the fixed reserve',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
fixturePatches: { cities: { 3: { population: 30_099 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment accepts the exact population reserve',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
fixturePatches: { cities: { 3: { population: 30_100 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects trust below twenty',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
fixturePatches: { cities: { 3: { trust: 19.99 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment accepts trust exactly twenty',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
fixturePatches: { cities: { 3: { trust: 20 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects insufficient gold',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
actorPatch: { gold: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment rejects insufficient rice',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
actorPatch: { rice: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recruitment clamps the same crew type at leadership capacity',
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1100, amount: 99_999 },
|
||||||
|
fixturePatches: { cities: { 3: { population: 200_000 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mercenary recruitment keeps its doubled price and higher readiness',
|
||||||
|
action: 'che_모병',
|
||||||
|
args: { crewType: 1100, amount: 100 },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade rejects an unknown item type',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'armor', itemCode: 'che_무기_01_단도' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade rejects an item from a different slot',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'book', itemCode: 'che_무기_01_단도' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade rejects an unbuyable item',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'che_무기_11_고정도' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade does not treat the city trade value as trader availability',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' },
|
||||||
|
fixturePatches: { cities: { 3: { trade: 0 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade rejects insufficient gold',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' },
|
||||||
|
actorPatch: { gold: 999 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade rejects buying the equipped item',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' },
|
||||||
|
actorPatch: { itemWeapon: 'che_무기_01_단도' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade rejects selling an empty slot',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'None' },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade sells a normal item for half price',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'None' },
|
||||||
|
actorPatch: { itemWeapon: 'che_무기_01_단도' },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'equipment trade permits selling a unique item and records global logs',
|
||||||
|
action: 'che_장비매매',
|
||||||
|
args: { itemType: 'weapon', itemCode: 'None' },
|
||||||
|
actorPatch: { itemWeapon: 'che_무기_11_고정도' },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disband rejects zero crew',
|
||||||
|
action: 'che_소집해제',
|
||||||
|
actorPatch: { crew: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disband allows a neutral actor and exceeds population capacity',
|
||||||
|
action: 'che_소집해제',
|
||||||
|
actorPatch: { nationId: 0, crew: 1001 },
|
||||||
|
fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training rejects a neutral actor',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
actorPatch: { nationId: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training rejects a wandering nation',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
fixturePatches: { nations: { 1: { level: 0 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training rejects a foreign-occupied city',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
fixturePatches: { cities: { 3: { nationId: 2 } } },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training rejects zero crew',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
actorPatch: { crew: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training rejects training at the command maximum',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
actorPatch: { train: 100 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training enforces the advertised rice cost before execution',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
actorPatch: { rice: 0 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fierce training succeeds with morale already capped',
|
||||||
|
action: 'cr_맹훈련',
|
||||||
|
actorPatch: { train: 99, atmos: 100, crew: 1001 },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('general military preparation boundary, value, RNG, and log parity', () => {
|
||||||
|
it.each(militaryPreparationBoundaryCases)(
|
||||||
|
'$name',
|
||||||
|
async ({ name, action, args, actorPatch, fixturePatches, completed }) => {
|
||||||
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
||||||
|
request.setup!.world!.hiddenSeed = `general-military-preparation-${name}`;
|
||||||
|
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: action,
|
||||||
|
actionKey: completed ? action : '휴식',
|
||||||
|
usedFallback: !completed,
|
||||||
|
});
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
||||||
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
integration('명장일람 rank_data command parity', () => {
|
integration('명장일람 rank_data command parity', () => {
|
||||||
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
||||||
const request = buildRequest(
|
const request = buildRequest(
|
||||||
|
|||||||
Reference in New Issue
Block a user