merge: scenario 2400 long-run parity
This commit is contained in:
@@ -82,6 +82,15 @@ export interface ScenarioSeedResult {
|
|||||||
|
|
||||||
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
||||||
|
|
||||||
|
export const calculateInitialTurnTick = (
|
||||||
|
clock: GameClock,
|
||||||
|
baseTick: number,
|
||||||
|
initialTurnOffsetMicros: number
|
||||||
|
): number => {
|
||||||
|
const offsetTicks = Math.floor((initialTurnOffsetMicros * clock.ticksPerSecond) / 1_000_000);
|
||||||
|
return clock.addTicks(baseTick, offsetTicks);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateTime = (date: Date): string => {
|
const formatDateTime = (date: Date): string => {
|
||||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
return [
|
return [
|
||||||
@@ -277,6 +286,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
initYear: startState.currentYear,
|
initYear: startState.currentYear,
|
||||||
initMonth: startState.currentMonth,
|
initMonth: startState.currentMonth,
|
||||||
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
|
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
|
||||||
|
// Ref ResetHelper keeps the active-user expiry horizon in game_env.
|
||||||
|
// User commands refresh to this value unless they are running in AI mode.
|
||||||
|
killturn: install?.npcMode === 1 ? Math.trunc(4800 / turnTermMinutes / 3) : 4800 / turnTermMinutes,
|
||||||
// Ref seeds game_env.develcost before the first general turn. The
|
// Ref seeds game_env.develcost before the first general turn. The
|
||||||
// monthly pre-handler recalculates the same value at each boundary.
|
// monthly pre-handler recalculates the same value at each boundary.
|
||||||
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
||||||
@@ -539,15 +551,12 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
turnTick: BigInt(
|
turnTick: BigInt(
|
||||||
initialClock.dateToTick(
|
calculateInitialTurnTick(
|
||||||
new Date(
|
initialClock,
|
||||||
now.getTime() +
|
initialClockTick,
|
||||||
Math.floor(
|
typeof general.meta.initialTurnOffsetMicros === 'number'
|
||||||
(typeof general.meta.initialTurnOffsetMicros === 'number'
|
? general.meta.initialTurnOffsetMicros
|
||||||
? general.meta.initialTurnOffsetMicros
|
: 0
|
||||||
: 0) / 1_000
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
age: resolveGeneralAge(startState.currentYear, general.birthYear),
|
age: resolveGeneralAge(startState.currentYear, general.birthYear),
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const resolveConstraintEnv = (
|
|||||||
month: world.currentMonth,
|
month: world.currentMonth,
|
||||||
startYear,
|
startYear,
|
||||||
relYear,
|
relYear,
|
||||||
|
// Ref asks each concrete command for its full constraints while the AI
|
||||||
|
// is still choosing a command. Cost-bearing commands therefore need
|
||||||
|
// the current yearly game_env.develcost at this boundary as well.
|
||||||
|
develCost: env.develCost,
|
||||||
openingPartYear: env.openingPartYear,
|
openingPartYear: env.openingPartYear,
|
||||||
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
||||||
...(Number.isFinite(killturn) ? { killturn } : {}),
|
...(Number.isFinite(killturn) ? { killturn } : {}),
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { GeneralActionPipeline } from '@sammo-ts/logic';
|
|
||||||
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||||
|
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||||
|
|
||||||
import type { GeneralAI } from '../core.js';
|
import type { GeneralAI } from '../core.js';
|
||||||
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
|
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
|
||||||
|
|
||||||
export const do금쌀구매 = (ai: GeneralAI) => {
|
export const do금쌀구매 = (ai: GeneralAI) => {
|
||||||
const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id));
|
const traceEnabled = [
|
||||||
|
...(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []),
|
||||||
|
...(process.env.GUI_PARITY_CORE_TRACE_GENERAL_IDS?.split(',') ?? []),
|
||||||
|
].includes(String(ai.general.id));
|
||||||
const trace = (stage: string, values: Record<string, unknown> = {}) => {
|
const trace = (stage: string, values: Record<string, unknown> = {}) => {
|
||||||
if (!traceEnabled) {
|
if (!traceEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.stderr.write(
|
process.stderr.write(`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`);
|
||||||
`AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n`
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const city = ai.city;
|
const city = ai.city;
|
||||||
if (!city) {
|
if (!city) {
|
||||||
@@ -55,35 +56,26 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
|||||||
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
|
const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0);
|
||||||
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
||||||
const crewAmount = fullLeadership * 100;
|
const crewAmount = fullLeadership * 100;
|
||||||
const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0;
|
const recruitContext = {
|
||||||
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
|
general: ai.general,
|
||||||
const goldCost = Math.round(
|
nation: ai.nation ?? undefined,
|
||||||
actionPipeline.onCalcDomestic(
|
...(ai.worldRef
|
||||||
{
|
? {
|
||||||
general: ai.general,
|
worldView: {
|
||||||
nation: ai.nation ?? undefined,
|
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||||
...(ai.worldRef
|
listGeneralsByCity: (cityId: number) =>
|
||||||
? {
|
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||||
worldView: {
|
listNations: () => ai.worldRef!.listNations(),
|
||||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
},
|
||||||
listGeneralsByCity: (cityId: number) =>
|
}
|
||||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
: {}),
|
||||||
listNations: () => ai.worldRef!.listNations(),
|
time: { year: ai.world.currentYear, month: ai.world.currentMonth, startYear: ai.startYear },
|
||||||
},
|
};
|
||||||
}
|
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||||
: {}),
|
const goldCost = crewType
|
||||||
time: {
|
? recruitment.getCost(recruitContext, crewType.id, crewAmount, crewType).gold *
|
||||||
year: ai.world.currentYear,
|
(ai.generalPolicy.can('모병') ? 2 : 1)
|
||||||
month: ai.world.currentMonth,
|
: 0;
|
||||||
startYear: ai.startYear,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'징병',
|
|
||||||
'cost',
|
|
||||||
rawGoldCost,
|
|
||||||
{ armType: crewType?.armType ?? 0 }
|
|
||||||
) * (ai.generalPolicy.can('모병') ? 2 : 1)
|
|
||||||
);
|
|
||||||
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
|
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
|
||||||
trace('recruit-cost', {
|
trace('recruit-cost', {
|
||||||
crewTypeId: crewType?.id ?? null,
|
crewTypeId: crewType?.id ?? null,
|
||||||
@@ -145,7 +137,9 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
|||||||
ai.aiConst.maxResourceActionAmount
|
ai.aiConst.maxResourceActionAmount
|
||||||
);
|
);
|
||||||
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
|
if (amount >= ai.nationPolicy.minimumResourceActionAmount) {
|
||||||
return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
|
const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매');
|
||||||
|
trace('sell', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,7 @@ const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
|
|||||||
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
|
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCrewGoldCost = (
|
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => {
|
||||||
ai: GeneralAI,
|
|
||||||
general: TurnGeneral,
|
|
||||||
baseMultiplier: number,
|
|
||||||
finalMultiplier = 1
|
|
||||||
): number => {
|
|
||||||
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
|
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
|
||||||
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
|
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
|
||||||
// Ref evaluates costWithTech() first, including its `/ 100`, and then
|
// Ref evaluates costWithTech() first, including its `/ 100`, and then
|
||||||
@@ -64,16 +59,15 @@ const getCrewGoldCost = (
|
|||||||
// Keeping that operation order is observable at exact resource boundaries
|
// Keeping that operation order is observable at exact resource boundaries
|
||||||
// (for example 3036 versus 3036.0000000000005).
|
// (for example 3036 versus 3036.0000000000005).
|
||||||
return (
|
return (
|
||||||
((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 *
|
(((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) *
|
||||||
baseMultiplier) *
|
100 *
|
||||||
|
baseMultiplier *
|
||||||
finalMultiplier
|
finalMultiplier
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
|
const sortByResource = (generals: TurnGeneral[], resource: ResourceName, descending = false) =>
|
||||||
Object.values(generals).sort((lhs, rhs) =>
|
generals.sort((lhs, rhs) => (descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]));
|
||||||
descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canUseGeneral = (general: TurnGeneral): boolean =>
|
const canUseGeneral = (general: TurnGeneral): boolean =>
|
||||||
readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5;
|
readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5;
|
||||||
@@ -88,9 +82,10 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
|
|||||||
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
|
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
|
||||||
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
|
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
|
||||||
];
|
];
|
||||||
|
const userWarGenerals = Object.values(ai.userWarGenerals);
|
||||||
|
|
||||||
for (const [resKey, minimum] of resourceMap) {
|
for (const [resKey, minimum] of resourceMap) {
|
||||||
const generals = sortedByResource(ai.userWarGenerals, resKey);
|
const generals = sortByResource(userWarGenerals, resKey);
|
||||||
for (const [index, general] of generals.entries()) {
|
for (const [index, general] of generals.entries()) {
|
||||||
if (general[resKey] >= minimum) {
|
if (general[resKey] >= minimum) {
|
||||||
break;
|
break;
|
||||||
@@ -112,7 +107,10 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
|
candidates.push([
|
||||||
|
{ destGeneralId: general.id, amount, isGold: resKey === 'gold' },
|
||||||
|
generals.length - index,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +137,13 @@ export const do유저장포상 = (ai: GeneralAI) => {
|
|||||||
ai.nationPolicy.reqHumanDevelRice,
|
ai.nationPolicy.reqHumanDevelRice,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
const userGenerals = Object.values(ai.userGenerals);
|
||||||
|
|
||||||
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
||||||
if (nation[resKey] < nationMinimum) {
|
if (nation[resKey] < nationMinimum) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const generals = sortedByResource(ai.userGenerals, resKey);
|
const generals = sortByResource(userGenerals, resKey);
|
||||||
for (const [index, general] of generals.entries()) {
|
for (const [index, general] of generals.entries()) {
|
||||||
if (general[resKey] >= warMinimum) {
|
if (general[resKey] >= warMinimum) {
|
||||||
break;
|
break;
|
||||||
@@ -171,7 +170,10 @@ export const do유저장포상 = (ai: GeneralAI) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
|
candidates.push([
|
||||||
|
{ destGeneralId: general.id, amount, isGold: resKey === 'gold' },
|
||||||
|
generals.length - index,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,12 +190,13 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
|
|||||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
|
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
|
||||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
|
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
|
||||||
];
|
];
|
||||||
|
const npcWarGenerals = Object.values(ai.npcWarGenerals);
|
||||||
|
|
||||||
for (const [resKey, nationMinimum, minimum] of resourceMap) {
|
for (const [resKey, nationMinimum, minimum] of resourceMap) {
|
||||||
if (nation[resKey] < nationMinimum) {
|
if (nation[resKey] < nationMinimum) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const generals = sortedByResource(ai.npcWarGenerals, resKey);
|
const generals = sortByResource(npcWarGenerals, resKey);
|
||||||
for (const [index, general] of generals.entries()) {
|
for (const [index, general] of generals.entries()) {
|
||||||
if (general[resKey] >= minimum) {
|
if (general[resKey] >= minimum) {
|
||||||
break;
|
break;
|
||||||
@@ -215,7 +218,10 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
|
||||||
candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]);
|
candidates.push([
|
||||||
|
{ destGeneralId: general.id, amount, isGold: resKey === 'gold' },
|
||||||
|
generals.length - index,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +238,15 @@ export const doNPC포상 = (ai: GeneralAI) => {
|
|||||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||||
];
|
];
|
||||||
|
const npcWarGenerals = Object.values(ai.npcWarGenerals);
|
||||||
|
const npcCivilGenerals = Object.values(ai.npcCivilGenerals);
|
||||||
|
|
||||||
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
||||||
if (nation[resKey] < nationMinimum) {
|
if (nation[resKey] < nationMinimum) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const warGenerals = sortedByResource(ai.npcWarGenerals, resKey);
|
const warGenerals = sortByResource(npcWarGenerals, resKey);
|
||||||
const civilGenerals = sortedByResource(ai.npcCivilGenerals, resKey);
|
const civilGenerals = sortByResource(npcCivilGenerals, resKey);
|
||||||
const weightBase = Math.max(warGenerals.length, civilGenerals.length);
|
const weightBase = Math.max(warGenerals.length, civilGenerals.length);
|
||||||
for (const [index, general] of warGenerals.entries()) {
|
for (const [index, general] of warGenerals.entries()) {
|
||||||
if (general[resKey] >= warMinimum) {
|
if (general[resKey] >= warMinimum) {
|
||||||
@@ -308,9 +316,11 @@ export const doNPC몰수 = (ai: GeneralAI) => {
|
|||||||
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
|
||||||
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
|
||||||
];
|
];
|
||||||
|
const npcWarGenerals = Object.values(ai.npcWarGenerals);
|
||||||
|
const npcCivilGenerals = Object.values(ai.npcCivilGenerals);
|
||||||
|
|
||||||
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
|
||||||
for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) {
|
for (const general of sortByResource(npcCivilGenerals, resKey, true)) {
|
||||||
if (general[resKey] <= civilMinimum * 1.5) {
|
if (general[resKey] <= civilMinimum * 1.5) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -326,7 +336,7 @@ export const doNPC몰수 = (ai: GeneralAI) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const takeSmallAmount = nation[resKey] >= nationMinimum;
|
const takeSmallAmount = nation[resKey] >= nationMinimum;
|
||||||
for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) {
|
for (const general of sortByResource(npcWarGenerals, resKey, true)) {
|
||||||
if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
|
if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly dirtyTroopIds = new Set<number>();
|
private readonly dirtyTroopIds = new Set<number>();
|
||||||
private readonly dirtyDiplomacyKeys = new Set<string>();
|
private readonly dirtyDiplomacyKeys = new Set<string>();
|
||||||
private readonly createdGeneralIds = new Set<number>();
|
private readonly createdGeneralIds = new Set<number>();
|
||||||
|
private nextLegacyGeneralScanOrder = 0;
|
||||||
private readonly createdNationIds = new Set<number>();
|
private readonly createdNationIds = new Set<number>();
|
||||||
private readonly createdTroopIds = new Set<number>();
|
private readonly createdTroopIds = new Set<number>();
|
||||||
private readonly createdDiplomacyKeys = new Set<string>();
|
private readonly createdDiplomacyKeys = new Set<string>();
|
||||||
@@ -476,7 +477,16 @@ export class InMemoryTurnWorld {
|
|||||||
const normalized = this.normalizeGeneralClock(
|
const normalized = this.normalizeGeneralClock(
|
||||||
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
|
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
|
||||||
);
|
);
|
||||||
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
|
const existingOrder = normalized.meta.legacyScanOrder;
|
||||||
|
const scanOrder =
|
||||||
|
typeof existingOrder === 'number' && Number.isFinite(existingOrder)
|
||||||
|
? existingOrder
|
||||||
|
: this.nextLegacyGeneralScanOrder;
|
||||||
|
this.nextLegacyGeneralScanOrder = Math.max(this.nextLegacyGeneralScanOrder, scanOrder + 1);
|
||||||
|
const ensured = ensureGeneralKillturn(
|
||||||
|
{ ...normalized, meta: { ...normalized.meta, legacyScanOrder: scanOrder } },
|
||||||
|
worldKillturn
|
||||||
|
);
|
||||||
this.generals.set(general.id, ensured);
|
this.generals.set(general.id, ensured);
|
||||||
}
|
}
|
||||||
for (const city of snapshot.cities) {
|
for (const city of snapshot.cities) {
|
||||||
@@ -876,7 +886,13 @@ export class InMemoryTurnWorld {
|
|||||||
const normalized = this.normalizeGeneralClock(
|
const normalized = this.normalizeGeneralClock(
|
||||||
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
|
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
|
||||||
);
|
);
|
||||||
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
|
const scanOrder = this.nextLegacyGeneralScanOrder++;
|
||||||
|
const ensured = normalizeGeneralDatabaseIntegers(
|
||||||
|
ensureGeneralKillturn(
|
||||||
|
{ ...normalized, meta: { ...normalized.meta, legacyScanOrder: scanOrder } },
|
||||||
|
worldKillturn
|
||||||
|
)
|
||||||
|
);
|
||||||
this.generals.set(general.id, ensured);
|
this.generals.set(general.id, ensured);
|
||||||
this.dirtyGeneralIds.add(general.id);
|
this.dirtyGeneralIds.add(general.id);
|
||||||
this.createdGeneralIds.add(general.id);
|
this.createdGeneralIds.add(general.id);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getOutcome,
|
getOutcome,
|
||||||
getRiceIncome,
|
getRiceIncome,
|
||||||
getWallIncome,
|
getWallIncome,
|
||||||
|
readLegacyCityTrust,
|
||||||
type CityIncomeSource,
|
type CityIncomeSource,
|
||||||
type Nation,
|
type Nation,
|
||||||
type NationIncomeContext,
|
type NationIncomeContext,
|
||||||
@@ -44,9 +45,13 @@ const resolveOfficerCity = (meta: Record<string, unknown>): number => {
|
|||||||
return asNumber(meta.officer_city, 0);
|
return asNumber(meta.officer_city, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveLegacyIncomeCityTrust = (trust: number): number => readLegacyCityTrust(trust);
|
||||||
|
|
||||||
const resolveCityTrust = (meta: Record<string, unknown>): number => {
|
const resolveCityTrust = (meta: Record<string, unknown>): number => {
|
||||||
const trust = asNumber(meta.trust, 50);
|
const trust = asNumber(meta.trust, 50);
|
||||||
return trust;
|
// Income is calculated in PHP after PDO exposes MariaDB FLOAT using a
|
||||||
|
// six-significant-digit decimal representation.
|
||||||
|
return resolveLegacyIncomeCityTrust(trust);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toIncomeCity = (city: ReturnType<InMemoryTurnWorld['listCities']>[number]): CityIncomeSource => ({
|
const toIncomeCity = (city: ReturnType<InMemoryTurnWorld['listCities']>[number]): CityIncomeSource => ({
|
||||||
|
|||||||
@@ -157,19 +157,11 @@ const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
|||||||
return fail('INTERNAL_SERVER_ERROR', '장수 생성 비밀 seed가 설정되지 않았습니다.');
|
return fail('INTERNAL_SERVER_ERROR', '장수 생성 비밀 seed가 설정되지 않았습니다.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLegacySeedTime = (value: Date): string => {
|
|
||||||
const pad = (part: number): string => String(part).padStart(2, '0');
|
|
||||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
|
||||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
|
||||||
koreaTime.getUTCDate()
|
|
||||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildJoinCreateGeneralSeed = (
|
export const buildJoinCreateGeneralSeed = (
|
||||||
hiddenSeed: string | number,
|
hiddenSeed: string | number,
|
||||||
ownerIdentity: string | number,
|
ownerIdentity: string | number,
|
||||||
acceptedAt: Date
|
acceptedTick: number
|
||||||
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, formatLegacySeedTime(acceptedAt));
|
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick);
|
||||||
|
|
||||||
const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise<void> => {
|
const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`);
|
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`);
|
||||||
@@ -611,7 +603,9 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
|
|
||||||
const hiddenSeed = readHiddenSeed(worldState);
|
const hiddenSeed = readHiddenSeed(worldState);
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, acceptedAt))
|
new LiteHashDRBG(
|
||||||
|
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const worldMeta = asRecord(worldState.meta);
|
const worldMeta = asRecord(worldState.meta);
|
||||||
const currentGenius = Math.max(
|
const currentGenius = Math.max(
|
||||||
|
|||||||
@@ -55,12 +55,8 @@ const readRuntimeNumber = (world: InMemoryTurnWorld, key: string, fallback: numb
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildSpecialityAge = (
|
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||||
retirementYear: number,
|
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||||
age: number,
|
|
||||||
relativeYear: number,
|
|
||||||
divisor: number
|
|
||||||
): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
|
||||||
|
|
||||||
const resolveSpecialityAge = (
|
const resolveSpecialityAge = (
|
||||||
general: TurnGeneral,
|
general: TurnGeneral,
|
||||||
@@ -124,9 +120,7 @@ const resolveTrait = (modules: readonly TraitModule[], key: string, label: strin
|
|||||||
export const createAssignGeneralSpecialityHandler = (options: {
|
export const createAssignGeneralSpecialityHandler = (options: {
|
||||||
getWorld: () => InMemoryTurnWorld | null;
|
getWorld: () => InMemoryTurnWorld | null;
|
||||||
}): MonthlyEventActionHandler => {
|
}): MonthlyEventActionHandler => {
|
||||||
let modulePromise:
|
let modulePromise: Promise<{ domesticModules: TraitModule[]; warModules: TraitModule[] }> | undefined;
|
||||||
| Promise<{ domesticModules: TraitModule[]; warModules: TraitModule[] }>
|
|
||||||
| undefined;
|
|
||||||
const loadModules = () => {
|
const loadModules = () => {
|
||||||
modulePromise ??= Promise.all([
|
modulePromise ??= Promise.all([
|
||||||
loadDomesticTraitModules([...LEGACY_DOMESTIC_SELECTION_KEYS]),
|
loadDomesticTraitModules([...LEGACY_DOMESTIC_SELECTION_KEYS]),
|
||||||
@@ -143,7 +137,12 @@ export const createAssignGeneralSpecialityHandler = (options: {
|
|||||||
const { domesticModules, warModules } = await loadModules();
|
const { domesticModules, warModules } = await loadModules();
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
simpleSerialize(resolveHiddenSeed(world), 'assignGeneralSpeciality', environment.year, environment.month)
|
simpleSerialize(
|
||||||
|
resolveHiddenSeed(world),
|
||||||
|
'assignGeneralSpeciality',
|
||||||
|
environment.year,
|
||||||
|
environment.month
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const defaultDomestic = normalizeCode(world.getScenarioConfig().const.defaultSpecialDomestic);
|
const defaultDomestic = normalizeCode(world.getScenarioConfig().const.defaultSpecialDomestic);
|
||||||
@@ -152,7 +151,11 @@ export const createAssignGeneralSpecialityHandler = (options: {
|
|||||||
const scenarioStat = world.getScenarioConfig().stat;
|
const scenarioStat = world.getScenarioConfig().stat;
|
||||||
// ref SQL에 ORDER BY가 없으므로 loader가 보존한 DB scan 순서를 두
|
// ref SQL에 ORDER BY가 없으므로 loader가 보존한 DB scan 순서를 두
|
||||||
// domestic/war pass에서 그대로 재사용한다.
|
// domestic/war pass에서 그대로 재사용한다.
|
||||||
const generals = world.listGenerals();
|
const generals = world.listGenerals().sort((left, right) => {
|
||||||
|
const leftOrder = readFiniteNumber(left.meta, ['legacyScanOrder']) ?? left.id;
|
||||||
|
const rightOrder = readFiniteNumber(right.meta, ['legacyScanOrder']) ?? right.id;
|
||||||
|
return leftOrder - rightOrder;
|
||||||
|
});
|
||||||
|
|
||||||
for (const general of generals) {
|
for (const general of generals) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
|||||||
'che_전투태세',
|
'che_전투태세',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const applyLegacyGeneralProgression = (
|
export const applyLegacyGeneralProgression = (
|
||||||
general: TurnGeneral,
|
general: TurnGeneral,
|
||||||
previousGeneral: TurnGeneral,
|
previousGeneral: TurnGeneral,
|
||||||
actionKey: string,
|
actionKey: string,
|
||||||
@@ -140,7 +140,13 @@ const applyLegacyGeneralProgression = (
|
|||||||
// 등급을 강제 재계산한다. 반대로 은퇴의 rebirth()와 선양의
|
// 등급을 강제 재계산한다. 반대로 은퇴의 rebirth()와 선양의
|
||||||
// multiplyVar('experience')는 수치를 줄이면서도 기존 등급을 그대로 둔다.
|
// multiplyVar('experience')는 수치를 줄이면서도 기존 등급을 그대로 둔다.
|
||||||
const forceRefreshLevel = actionKey === 'che_하야';
|
const forceRefreshLevel = actionKey === 'che_하야';
|
||||||
const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양';
|
// Battle units update levels before finishBattle() rounds the legacy INT
|
||||||
|
// columns. che_출병 must retain that pre-round result just like Ref.
|
||||||
|
const preserveLevel =
|
||||||
|
actionKey === 'che_은퇴' ||
|
||||||
|
actionKey === 'che_선양' ||
|
||||||
|
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);
|
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
|
||||||
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
|
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { TurnCommandEnv } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { resolveConstraintEnv } from '../src/turn/ai/generalAi/constraint.js';
|
||||||
|
|
||||||
|
describe('general AI constraint environment', () => {
|
||||||
|
it('passes the current development cost into candidate validation', () => {
|
||||||
|
const env = resolveConstraintEnv(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
currentYear: 182,
|
||||||
|
currentMonth: 5,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-08-02T05:47:00.000Z'),
|
||||||
|
meta: { develcost: 24 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'test',
|
||||||
|
startYear: 180,
|
||||||
|
life: null,
|
||||||
|
fiction: 1,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
},
|
||||||
|
{ develCost: 24, openingPartYear: 3, minAvailableRecruitPop: 30_000 } as TurnCommandEnv
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(env).toMatchObject({ currentYear: 182, currentMonth: 5, develCost: 24 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -521,13 +521,11 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
rice: 10_000,
|
rice: 10_000,
|
||||||
meta: { killturn: 100, fullLeadership: 70, rank_killcrew: 0, rank_deathcrew: 1 },
|
meta: { killturn: 100, fullLeadership: 70, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||||
},
|
},
|
||||||
generalActionModules: singleActionModuleStack(
|
generalActionModules: singleActionModuleStack({
|
||||||
{
|
eventHandlers: {},
|
||||||
eventHandlers: {},
|
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||||
onCalcDomestic: (_context, turnType, varType, value) =>
|
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
|
||||||
turnType === '징병' && varType === 'cost' ? value * 1.2 : value,
|
}),
|
||||||
}
|
|
||||||
),
|
|
||||||
rng: makeRng([], [0, 0]),
|
rng: makeRng([], [0, 0]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -698,6 +696,17 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
expect(do금쌀구매(ai)).toBeNull();
|
expect(do금쌀구매(ai)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses only the additional same-type crew when estimating the recruit gold reserve', () => {
|
||||||
|
const ai = makeAi({
|
||||||
|
general: { gold: 500, rice: 3000, crew: 6900, crewTypeId: 1 },
|
||||||
|
disabledPolicyActions: ['상인무시'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// A full 7,000-person estimate would make this branch sell rice. Ref's
|
||||||
|
// recruitment calculator prices only the remaining 100 people.
|
||||||
|
expect(do금쌀구매(ai)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('randomly chooses between supply and search when national resources are sufficient', () => {
|
it('randomly chooses between supply and search when national resources are sufficient', () => {
|
||||||
const ai = makeAi({ rng: makeRng([], [1]) });
|
const ai = makeAi({ rng: makeRng([], [1]) });
|
||||||
expect(do중립(ai)?.action).toBe('che_인재탐색');
|
expect(do중립(ai)?.action).toBe('che_인재탐색');
|
||||||
@@ -936,13 +945,11 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
dipState: 4,
|
dipState: 4,
|
||||||
rng,
|
rng,
|
||||||
generals: [baseGeneral(), specialist],
|
generals: [baseGeneral(), specialist],
|
||||||
generalActionModules: singleActionModuleStack(
|
generalActionModules: singleActionModuleStack({
|
||||||
{
|
eventHandlers: {},
|
||||||
eventHandlers: {},
|
onCalcDomestic: (context, turnType, varType, value) =>
|
||||||
onCalcDomestic: (context, turnType, varType, value) =>
|
context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value,
|
||||||
context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value,
|
}),
|
||||||
}
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } };
|
ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } };
|
||||||
ai.supplyCities = {
|
ai.supplyCities = {
|
||||||
@@ -994,4 +1001,32 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
ai.npcWarGenerals = { 2: warGeneral };
|
ai.npcWarGenerals = { 2: warGeneral };
|
||||||
expect(doNPC몰수(ai)?.action).toBe('che_몰수');
|
expect(doNPC몰수(ai)?.action).toBe('che_몰수');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries the Ref gold sort order into equal-rice NPC seizure candidates', () => {
|
||||||
|
const rng = makeRng();
|
||||||
|
const ai = makeAi({ nation: { gold: 1_000, rice: 1_000 }, rng });
|
||||||
|
ai.nationPolicy.reqNationGold = 10_000;
|
||||||
|
ai.nationPolicy.reqNationRice = 10_000;
|
||||||
|
ai.nationPolicy.reqNpcWarGold = 1_000;
|
||||||
|
ai.nationPolicy.reqNpcWarRice = 1_000;
|
||||||
|
const candidate = (id: number, gold: number) => ({
|
||||||
|
...baseGeneral(),
|
||||||
|
id,
|
||||||
|
gold,
|
||||||
|
rice: 5_000,
|
||||||
|
meta: { killturn: 100, fullLeadership: 70 },
|
||||||
|
});
|
||||||
|
ai.npcCivilGenerals = {};
|
||||||
|
ai.npcWarGenerals = {
|
||||||
|
77: candidate(77, 4_000),
|
||||||
|
534: candidate(534, 5_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(doNPC몰수(ai)?.action).toBe('che_몰수');
|
||||||
|
const riceCandidates = (rng.weightedPairs[0] ?? [])
|
||||||
|
.map(([args]) => args as { isGold: boolean; destGeneralId: number })
|
||||||
|
.filter((args) => !args.isGold)
|
||||||
|
.map((args) => args.destGeneralId);
|
||||||
|
expect(riceCandidates).toEqual([534, 77]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
|
import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||||
|
import { applyLegacyGeneralProgression } from '../src/turn/reservedTurnHandler.js';
|
||||||
|
|
||||||
const start = new Date('0200-01-01T00:00:00.000Z');
|
const start = new Date('0200-01-01T00:00:00.000Z');
|
||||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
@@ -152,6 +153,29 @@ const makeState = (): TurnWorldState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('legacy general-turn execution contract', () => {
|
describe('legacy general-turn execution contract', () => {
|
||||||
|
it('preserves the battle-computed level across legacy INT rounding', () => {
|
||||||
|
const previous = makeGeneral({
|
||||||
|
experience: 6_700,
|
||||||
|
dedication: 5_800,
|
||||||
|
meta: { killturn: 24, explevel: 25, dedlevel: 8 },
|
||||||
|
});
|
||||||
|
const roundedAfterBattle = makeGeneral({
|
||||||
|
experience: 6_760,
|
||||||
|
dedication: 5_871,
|
||||||
|
meta: { killturn: 24, explevel: 25, dedlevel: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = applyLegacyGeneralProgression(
|
||||||
|
roundedAfterBattle,
|
||||||
|
previous,
|
||||||
|
'che_출병',
|
||||||
|
{ maxStatLevel: 255, maxDedicationLevel: 30 } as never,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.meta).toMatchObject({ explevel: 25, dedlevel: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
|
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
|
||||||
const harness = await createTurnTestHarness({
|
const harness = await createTurnTestHarness({
|
||||||
snapshot: makeSnapshot(makeGeneral()),
|
snapshot: makeSnapshot(makeGeneral()),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
|
import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
|
||||||
|
|
||||||
describe('generic join legacy time contracts', () => {
|
describe('generic join legacy time contracts', () => {
|
||||||
it('builds the Ref MakeGeneral seed from the Seoul whole-second timestamp', () => {
|
it('builds the Ref MakeGeneral seed from the logical game tick', () => {
|
||||||
expect(buildJoinCreateGeneralSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe(
|
expect(buildJoinCreateGeneralSeed('seed', 42, 72_000_000)).toBe(
|
||||||
'str(4,seed)|str(11,MakeGeneral)|int(42)|str(19,2026-07-31 08:59:58)'
|
'str(4,seed)|str(11,MakeGeneral)|int(42)|int(72000000)'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { createIncomeHandler } from '../src/turn/incomeHandler.js';
|
import { createIncomeHandler, resolveLegacyIncomeCityTrust } from '../src/turn/incomeHandler.js';
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js';
|
import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js';
|
||||||
import {
|
import {
|
||||||
@@ -146,6 +146,10 @@ const buildWorld = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('core monthly event actions at the real month boundary', () => {
|
describe('core monthly event actions at the real month boundary', () => {
|
||||||
|
it('reads income trust through the PHP six-significant-digit FLOAT representation', () => {
|
||||||
|
expect(resolveLegacyIncomeCityTrust(98.12674)).toBe(98.1267);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves notice format, NewYear month log, age/belong, and officer lock reset', async () => {
|
it('preserves notice format, NewYear month log, age/belong, and officer lock reset', async () => {
|
||||||
const world = buildWorld(
|
const world = buildWorld(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { City, Nation, NationTraitModule } from '@sammo-ts/logic';
|
import type { City, Nation, NationTraitModule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import {
|
import { createProcessSemiAnnualHandler, storeLegacySemiAnnualTrust } from '../src/turn/monthlySemiAnnualAction.js';
|
||||||
createProcessSemiAnnualHandler,
|
|
||||||
storeLegacySemiAnnualTrust,
|
|
||||||
} from '../src/turn/monthlySemiAnnualAction.js';
|
|
||||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
const buildCity = (id: number, patch: Partial<City> = {}): City => ({
|
const buildCity = (id: number, patch: Partial<City> = {}): City => ({
|
||||||
|
|||||||
@@ -83,41 +83,41 @@ const buildWorld = (hiddenSeed = 'monthly-speciality-fixture') => {
|
|||||||
environment: { mapName: 'test', unitSet: 'default' },
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
};
|
};
|
||||||
const domesticGeneral = buildGeneral({
|
const domesticGeneral = buildGeneral({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: '내정대상',
|
name: '내정대상',
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
stats: [40, 45, 80],
|
stats: [40, 45, 80],
|
||||||
specialDomestic: null,
|
specialDomestic: null,
|
||||||
specialWar: 'che_신산',
|
specialWar: 'che_신산',
|
||||||
meta: { specage: 30, specage2: 99, prev_types_special: ['che_경작'] },
|
meta: { specage: 30, specage2: 99, prev_types_special: ['che_경작'] },
|
||||||
});
|
});
|
||||||
const warGeneral = buildGeneral({
|
const warGeneral = buildGeneral({
|
||||||
id: 2,
|
id: 2,
|
||||||
name: '전투대상',
|
name: '전투대상',
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
stats: [80, 75, 40],
|
stats: [80, 75, 40],
|
||||||
specialDomestic: 'che_인덕',
|
specialDomestic: 'che_인덕',
|
||||||
specialWar: null,
|
specialWar: null,
|
||||||
meta: {
|
meta: {
|
||||||
specage: 99,
|
specage: 99,
|
||||||
specage2: 30,
|
specage2: 30,
|
||||||
prev_types_special2: ['che_돌격'],
|
prev_types_special2: ['che_돌격'],
|
||||||
dex1: 200,
|
dex1: 200,
|
||||||
dex2: 10,
|
dex2: 10,
|
||||||
dex3: 10,
|
dex3: 10,
|
||||||
dex4: 10,
|
dex4: 10,
|
||||||
dex5: 10,
|
dex5: 10,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const inheritedGeneral = buildGeneral({
|
const inheritedGeneral = buildGeneral({
|
||||||
id: 3,
|
id: 3,
|
||||||
name: '계승대상',
|
name: '계승대상',
|
||||||
nationId: 2,
|
nationId: 2,
|
||||||
stats: [50, 50, 50],
|
stats: [50, 50, 50],
|
||||||
specialDomestic: 'che_경작',
|
specialDomestic: 'che_경작',
|
||||||
specialWar: null,
|
specialWar: null,
|
||||||
meta: { specage: 99, specage2: 30, inheritSpecificSpecialWar: 'che_의술', marker: 3 },
|
meta: { specage: 99, specage2: 30, inheritSpecificSpecialWar: 'che_의술', marker: 3 },
|
||||||
});
|
});
|
||||||
// The isolated Aria fixture scans eligible war rows as 3, 2 because the
|
// The isolated Aria fixture scans eligible war rows as 3, 2 because the
|
||||||
// legacy query has no ORDER BY. Preserve that input order in this trace.
|
// legacy query has no ORDER BY. Preserve that input order in this trace.
|
||||||
const generals = [domesticGeneral, inheritedGeneral, warGeneral];
|
const generals = [domesticGeneral, inheritedGeneral, warGeneral];
|
||||||
@@ -179,11 +179,7 @@ describe('monthly speciality and betrayal actions', () => {
|
|||||||
|
|
||||||
it('does nothing before the three-year opening period ends', async () => {
|
it('does nothing before the three-year opening period ends', async () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
await createAssignGeneralSpecialityHandler({ getWorld: () => world })(
|
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event);
|
||||||
[],
|
|
||||||
{ ...environment, year: 192 },
|
|
||||||
event
|
|
||||||
);
|
|
||||||
expect(world.peekDirtyState().generals).toEqual([]);
|
expect(world.peekDirtyState().generals).toEqual([]);
|
||||||
expect(world.peekDirtyState().logs).toEqual([]);
|
expect(world.peekDirtyState().logs).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -203,6 +199,59 @@ describe('monthly speciality and betrayal actions', () => {
|
|||||||
expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull();
|
expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists creation scan order for speciality RNG across a reload', async () => {
|
||||||
|
const world = buildWorld();
|
||||||
|
const laterId = buildGeneral({
|
||||||
|
id: 5,
|
||||||
|
name: '먼저생성',
|
||||||
|
nationId: 0,
|
||||||
|
stats: [55, 55, 55],
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: 'che_신산',
|
||||||
|
meta: { specage: 30, specage2: 99 },
|
||||||
|
});
|
||||||
|
const earlierId = buildGeneral({
|
||||||
|
id: 4,
|
||||||
|
name: '나중생성',
|
||||||
|
nationId: 0,
|
||||||
|
stats: [55, 55, 55],
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: 'che_신산',
|
||||||
|
meta: { specage: 30, specage2: 99 },
|
||||||
|
});
|
||||||
|
expect(world.addGeneral(laterId)).toBe(true);
|
||||||
|
expect(world.addGeneral(earlierId)).toBe(true);
|
||||||
|
|
||||||
|
const persisted = world.listGenerals().sort((left, right) => left.id - right.id);
|
||||||
|
expect(persisted.find((general) => general.id === 5)?.meta.legacyScanOrder).toBeLessThan(
|
||||||
|
persisted.find((general) => general.id === 4)?.meta.legacyScanOrder as number
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloaded = new InMemoryTurnWorld(
|
||||||
|
world.getState(),
|
||||||
|
{
|
||||||
|
scenarioConfig: world.getScenarioConfig(),
|
||||||
|
map: { id: 'test', name: 'test', cities: [] },
|
||||||
|
generals: persisted,
|
||||||
|
cities: [],
|
||||||
|
nations: [],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [event],
|
||||||
|
initialEvents: [],
|
||||||
|
},
|
||||||
|
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||||
|
);
|
||||||
|
await createAssignGeneralSpecialityHandler({ getWorld: () => reloaded })([], environment, event);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reloaded
|
||||||
|
.peekDirtyState()
|
||||||
|
.logs.filter((log) => log.category === LogCategory.HISTORY)
|
||||||
|
.map((log) => log.generalId)
|
||||||
|
).toEqual([1, 5, 4, 3, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies the two default scenario betrayal steps only to values within each threshold', async () => {
|
it('applies the two default scenario betrayal steps only to values within each threshold', async () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
world.updateGeneral(1, { meta: { ...world.getGeneralById(1)!.meta, betray: 0 } });
|
world.updateGeneral(1, { meta: { ...world.getGeneralById(1)!.meta, betray: 0 } });
|
||||||
|
|||||||
@@ -239,7 +239,10 @@ describeDb('scenario database seed', () => {
|
|||||||
expect(config.tournamentTrig).toBe(false);
|
expect(config.tournamentTrig).toBe(false);
|
||||||
|
|
||||||
const meta = (worldState.meta ?? {}) as Record<string, unknown>;
|
const meta = (worldState.meta ?? {}) as Record<string, unknown>;
|
||||||
expect(meta.develcost).toBe((worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2);
|
expect(meta.develcost).toBe(
|
||||||
|
(worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2
|
||||||
|
);
|
||||||
|
expect(meta.killturn).toBe(80);
|
||||||
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||||
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
||||||
expect(autorunOptions.develop).toBe(true);
|
expect(autorunOptions.develop).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { GameClock } from '@sammo-ts/common';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { calculateInitialTurnTick } from '../src/scenario/scenarioSeeder.js';
|
||||||
|
|
||||||
|
describe('scenario seeder general turn tick', () => {
|
||||||
|
test('preserves Ref-compatible sub-millisecond RNG precision', () => {
|
||||||
|
const now = new Date('2026-08-02T00:03:44.000Z');
|
||||||
|
const clock = new GameClock({
|
||||||
|
baseTime: new Date('2026-08-02T01:00:00.000Z'),
|
||||||
|
tick: 0,
|
||||||
|
mode: 'manual',
|
||||||
|
wallAnchor: now,
|
||||||
|
turnSeconds: 600,
|
||||||
|
});
|
||||||
|
const baseTick = clock.dateToTick(now);
|
||||||
|
|
||||||
|
expect(calculateInitialTurnTick(clock, baseTick, 235_265_319)).toBe(baseTick + 14_115_919);
|
||||||
|
expect(clock.dateToTick(new Date(now.getTime() + 235_265))).toBe(baseTick + 14_115_900);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,9 +19,9 @@ const requireSafeTick = (tick: number): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => {
|
const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => {
|
||||||
const wholeSeconds = Math.trunc(tick / ticksPerSecond);
|
const wholeSeconds = Math.floor(tick / ticksPerSecond);
|
||||||
const remainingTicks = tick - wholeSeconds * ticksPerSecond;
|
const remainingTicks = tick - wholeSeconds * ticksPerSecond;
|
||||||
const milliseconds = wholeSeconds * 1_000 + Math.round((remainingTicks * 1_000) / ticksPerSecond);
|
const milliseconds = wholeSeconds * 1_000 + Math.floor((remainingTicks * 1_000) / ticksPerSecond);
|
||||||
if (!Number.isSafeInteger(milliseconds)) {
|
if (!Number.isSafeInteger(milliseconds)) {
|
||||||
throw new Error(`Game tick offset is outside the safe millisecond range: ${milliseconds}`);
|
throw new Error(`Game tick offset is outside the safe millisecond range: ${milliseconds}`);
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ export class GameClock {
|
|||||||
const wholeSeconds = Math.trunc(milliseconds / 1_000);
|
const wholeSeconds = Math.trunc(milliseconds / 1_000);
|
||||||
const remainingMilliseconds = milliseconds - wholeSeconds * 1_000;
|
const remainingMilliseconds = milliseconds - wholeSeconds * 1_000;
|
||||||
return requireSafeTick(
|
return requireSafeTick(
|
||||||
wholeSeconds * this.ticksPerSecond + Math.round((remainingMilliseconds * this.ticksPerSecond) / 1_000)
|
wholeSeconds * this.ticksPerSecond + Math.trunc((remainingMilliseconds * this.ticksPerSecond) / 1_000)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,17 @@ describe('GameClock', () => {
|
|||||||
expect(Number.isNaN(projected.getTime())).toBe(false);
|
expect(Number.isNaN(projected.getTime())).toBe(false);
|
||||||
expect(clock.dateToTick(projected)).toBe(tick);
|
expect(clock.dateToTick(projected)).toBe(tick);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('truncates sub-millisecond projections like Ref GameClock', () => {
|
||||||
|
const clock = new GameClock({
|
||||||
|
baseTime,
|
||||||
|
tick: 0,
|
||||||
|
mode: 'manual',
|
||||||
|
wallAnchor: baseTime,
|
||||||
|
turnSeconds: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clock.tickToDate(14_115_919).toISOString()).toBe('2042-01-01T00:03:55.265Z');
|
||||||
|
expect(clock.tickToDate(-1).toISOString()).toBe('2041-12-31T23:59:59.999Z');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const ACTION_KEY = 'che_물자조달';
|
|||||||
|
|
||||||
export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
|
export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
|
||||||
|
|
||||||
|
export const resolveLegacyExperienceLevel = (experience: number): number =>
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(255, experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)))
|
||||||
|
);
|
||||||
|
export const resolveLegacyDedicationLevel = (dedication: number): number =>
|
||||||
|
Math.max(0, Math.min(30, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||||
|
|
||||||
export class ActionResolver<
|
export class ActionResolver<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionResolver<TriggerState, ProcureArgs> {
|
> implements GeneralActionResolver<TriggerState, ProcureArgs> {
|
||||||
@@ -107,8 +115,10 @@ export class ActionResolver<
|
|||||||
// the delta separately changes cancellation cases such as
|
// the delta separately changes cancellation cases such as
|
||||||
// 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the
|
// 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the
|
||||||
// accumulated binary value is exactly 4564.5 and persists as 4565.
|
// accumulated binary value is exactly 4564.5 and persists as 4565.
|
||||||
const nextExp = roundLegacyAccumulatedInteger(general.experience, exp);
|
const rawNextExp = general.experience + exp;
|
||||||
const nextDed = roundLegacyAccumulatedInteger(general.dedication, ded);
|
const rawNextDed = general.dedication + ded;
|
||||||
|
const nextExp = Math.round(rawNextExp);
|
||||||
|
const nextDed = Math.round(rawNextDed);
|
||||||
|
|
||||||
let appliedScore = score;
|
let appliedScore = score;
|
||||||
if (context.city && [1, 3].includes(context.city.frontState)) {
|
if (context.city && [1, 3].includes(context.city.frontState)) {
|
||||||
@@ -167,6 +177,8 @@ export class ActionResolver<
|
|||||||
dedication: nextDed,
|
dedication: nextDed,
|
||||||
meta: {
|
meta: {
|
||||||
...general.meta,
|
...general.meta,
|
||||||
|
explevel: resolveLegacyExperienceLevel(rawNextExp),
|
||||||
|
dedlevel: resolveLegacyDedicationLevel(rawNextDed),
|
||||||
[statKey]:
|
[statKey]:
|
||||||
(typeof general.meta[statKey] === 'number' ? (general.meta[statKey] as number) : 0) + 1,
|
(typeof general.meta[statKey] === 'number' ? (general.meta[statKey] as number) : 0) + 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface CommerceInvestmentResult {
|
|||||||
export interface CommerceInvestmentArgs {}
|
export interface CommerceInvestmentArgs {}
|
||||||
|
|
||||||
const DEFAULT_TRUST = 50;
|
const DEFAULT_TRUST = 50;
|
||||||
|
export const resolveLegacyDomesticTrust = (trust: number | null | undefined, fallback = DEFAULT_TRUST): number =>
|
||||||
|
Math.max(trust ?? fallback, DEFAULT_TRUST);
|
||||||
const DEFAULT_FRONT_STATES = [1, 3];
|
const DEFAULT_FRONT_STATES = [1, 3];
|
||||||
const DEFAULT_CONFIG: InvestmentConfig = {
|
const DEFAULT_CONFIG: InvestmentConfig = {
|
||||||
key: 'che_상업투자',
|
key: 'che_상업투자',
|
||||||
@@ -188,7 +190,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
}
|
}
|
||||||
|
|
||||||
calcBaseScore(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): number {
|
calcBaseScore(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): number {
|
||||||
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
const trust = resolveLegacyDomesticTrust(getMetaNumber(context.city.meta, 'trust'), this.env.defaultTrust);
|
||||||
|
|
||||||
const injuryMultiplier = (100 - context.general.injury) / 100;
|
const injuryMultiplier = (100 - context.general.injury) / 100;
|
||||||
const rawStats = {
|
const rawStats = {
|
||||||
@@ -220,8 +222,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
|||||||
|
|
||||||
resolve(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): CommerceInvestmentResult {
|
resolve(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): CommerceInvestmentResult {
|
||||||
const { gold: costGold, rice: costRice } = this.getCost(context);
|
const { gold: costGold, rice: costRice } = this.getCost(context);
|
||||||
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
const trust = resolveLegacyDomesticTrust(getMetaNumber(context.city.meta, 'trust'), this.env.defaultTrust);
|
||||||
let score = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER);
|
const calculatedBaseScore = this.calcBaseScore(context, rng);
|
||||||
|
let score = clamp(calculatedBaseScore, 1, Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
const ratio =
|
const ratio =
|
||||||
this.env.getCriticalRatio?.(context, this.config.statKey) ??
|
this.env.getCriticalRatio?.(context, this.config.statKey) ??
|
||||||
|
|||||||
@@ -105,11 +105,8 @@ export const normalizeLegacyGeneratedDex = (
|
|||||||
): [number, number, number, number, number] =>
|
): [number, number, number, number, number] =>
|
||||||
values.map((value) => Math.trunc(value)) as [number, number, number, number, number];
|
values.map((value) => Math.trunc(value)) as [number, number, number, number, number];
|
||||||
|
|
||||||
export const resolveLegacySpecialityAge = (
|
export const resolveLegacySpecialityAge = (retirementYear: number, age: number, divisor: number): number =>
|
||||||
retirementYear: number,
|
Math.round((retirementYear - age) / divisor) + age;
|
||||||
age: number,
|
|
||||||
divisor: number
|
|
||||||
): number => Math.round((retirementYear - age) / divisor) + age;
|
|
||||||
|
|
||||||
const addMetaValue = (
|
const addMetaValue = (
|
||||||
meta: Record<string, TriggerValue>,
|
meta: Record<string, TriggerValue>,
|
||||||
@@ -450,20 +447,14 @@ export class ActionResolver<
|
|||||||
const meta: GeneralMeta = {
|
const meta: GeneralMeta = {
|
||||||
killturn,
|
killturn,
|
||||||
npcType: NPC_TYPE,
|
npcType: NPC_TYPE,
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
crewTypeId: this.env.defaultCrewTypeId,
|
crewTypeId: this.env.defaultCrewTypeId,
|
||||||
affinity,
|
affinity,
|
||||||
birthYear,
|
birthYear,
|
||||||
deathYear,
|
deathYear,
|
||||||
specage: resolveLegacySpecialityAge(
|
specage: resolveLegacySpecialityAge(context.retirementYear, age, 12),
|
||||||
context.retirementYear,
|
specage2: resolveLegacySpecialityAge(context.retirementYear, age, 6),
|
||||||
age,
|
|
||||||
12
|
|
||||||
),
|
|
||||||
specage2: resolveLegacySpecialityAge(
|
|
||||||
context.retirementYear,
|
|
||||||
age,
|
|
||||||
6
|
|
||||||
),
|
|
||||||
dex1: dex[0],
|
dex1: dex[0],
|
||||||
dex2: dex[1],
|
dex2: dex[1],
|
||||||
dex3: dex[2],
|
dex3: dex[2],
|
||||||
|
|||||||
@@ -168,3 +168,5 @@ export const loadGeneralTurnCommandSpecs = async (
|
|||||||
}
|
}
|
||||||
return specs;
|
return specs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export const round = (value: number): number => {
|
|||||||
return Math.round(value);
|
return Math.round(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value));
|
// PHP 8.3's round() uses a wider half-boundary fuzz than one JavaScript
|
||||||
|
// ulp at four-digit battle totals. Accumulating several fractional battle
|
||||||
|
// rewards can land about three ulps below .5 (for example
|
||||||
|
// 8719.4999999999945), which PHP still rounds upward.
|
||||||
|
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
|
||||||
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
|
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -519,6 +519,8 @@ const buildGeneralSeeds = (
|
|||||||
source: contextLabel,
|
source: contextLabel,
|
||||||
deathMonth,
|
deathMonth,
|
||||||
initialTurnOffsetMicros,
|
initialTurnOffsetMicros,
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
// Ref's GeneralBuilder derives speciality ages from the scenario
|
// Ref's GeneralBuilder derives speciality ages from the scenario
|
||||||
// opening year even when installation stores a pre-opening age.
|
// opening year even when installation stores a pre-opening age.
|
||||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||||
@@ -588,6 +590,8 @@ const buildGeneralSeeds = (
|
|||||||
),
|
),
|
||||||
deathMonth,
|
deathMonth,
|
||||||
npcType,
|
npcType,
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
crewTypeId: defaultCrewTypeId,
|
crewTypeId: defaultCrewTypeId,
|
||||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||||
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { WorldSnapshot } from '../../src/world/types.js';
|
|||||||
import {
|
import {
|
||||||
commandSpec as procureSpec,
|
commandSpec as procureSpec,
|
||||||
roundLegacyAccumulatedInteger,
|
roundLegacyAccumulatedInteger,
|
||||||
|
resolveLegacyDedicationLevel,
|
||||||
|
resolveLegacyExperienceLevel,
|
||||||
} from '../../src/actions/turn/general/che_물자조달.js';
|
} from '../../src/actions/turn/general/che_물자조달.js';
|
||||||
import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js';
|
import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js';
|
||||||
import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js';
|
import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js';
|
||||||
@@ -37,11 +39,9 @@ import {
|
|||||||
readLegacyStoredTech,
|
readLegacyStoredTech,
|
||||||
toLegacyStoredTech,
|
toLegacyStoredTech,
|
||||||
} from '../../src/actions/turn/general/che_기술연구.js';
|
} from '../../src/actions/turn/general/che_기술연구.js';
|
||||||
import {
|
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
|
||||||
readLegacyCityTrust,
|
|
||||||
storeLegacyCityTrust,
|
|
||||||
} from '../../src/actions/turn/general/legacyCityTrust.js';
|
|
||||||
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
||||||
|
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
|
||||||
|
|
||||||
describe('General Commands New Scenario', () => {
|
describe('General Commands New Scenario', () => {
|
||||||
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
||||||
@@ -55,6 +55,14 @@ describe('General Commands New Scenario', () => {
|
|||||||
expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565);
|
expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calculates procurement levels before MariaDB rounds the INT columns', () => {
|
||||||
|
expect(Math.round(5_759.5)).toBe(5_760);
|
||||||
|
expect(resolveLegacyExperienceLevel(5_759.5)).toBe(23);
|
||||||
|
expect(Math.round(6_249.5)).toBe(6_250);
|
||||||
|
expect(resolveLegacyExperienceLevel(6_249.5)).toBe(24);
|
||||||
|
expect(resolveLegacyDedicationLevel(6_400.4)).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists generated NPC speciality ages from the legacy creation date', () => {
|
it('persists generated NPC speciality ages from the legacy creation date', () => {
|
||||||
expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27);
|
expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27);
|
||||||
expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32);
|
expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32);
|
||||||
@@ -86,6 +94,12 @@ describe('General Commands New Scenario', () => {
|
|||||||
expect(roundLegacyRecruitCost(cavalryCost)).toBe(886);
|
expect(roundLegacyRecruitCost(cavalryCost)).toBe(886);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the legacy minimum trust for domestic calculations', () => {
|
||||||
|
expect(resolveLegacyDomesticTrust(44.5321)).toBe(50);
|
||||||
|
expect(resolveLegacyDomesticTrust(80)).toBe(80);
|
||||||
|
expect(resolveLegacyDomesticTrust(null)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Setup Environment
|
// 1. Setup Environment
|
||||||
const systemEnv: TurnCommandEnv = {
|
const systemEnv: TurnCommandEnv = {
|
||||||
develCost: 100,
|
develCost: 100,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ describe('legacy war rounding', () => {
|
|||||||
it('matches PHP round() at drifted positive and negative half boundaries', () => {
|
it('matches PHP round() at drifted positive and negative half boundaries', () => {
|
||||||
expect(round(4159.499999999999)).toBe(4160);
|
expect(round(4159.499999999999)).toBe(4160);
|
||||||
expect(round(-4159.499999999999)).toBe(-4160);
|
expect(round(-4159.499999999999)).toBe(-4160);
|
||||||
|
expect(round(8719.4999999999945)).toBe(8720);
|
||||||
|
expect(round(-8719.4999999999945)).toBe(-8720);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps values meaningfully below a half boundary on the lower integer', () => {
|
it('keeps values meaningfully below a half boundary on the lower integer', () => {
|
||||||
|
|||||||
@@ -175,9 +175,16 @@ describe('scenario bootstrap', () => {
|
|||||||
specialDomestic: 'che_event_돌격',
|
specialDomestic: 'che_event_돌격',
|
||||||
specialWar: null,
|
specialWar: null,
|
||||||
});
|
});
|
||||||
expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
expect(result.snapshot.generals[0]?.meta).toMatchObject({
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
|
specage: 25,
|
||||||
|
specage2: 30,
|
||||||
|
});
|
||||||
expect(result.seed.generals[0]?.meta).toMatchObject({
|
expect(result.seed.generals[0]?.meta).toMatchObject({
|
||||||
deathMonth: expect.any(Number),
|
deathMonth: expect.any(Number),
|
||||||
|
explevel: 0,
|
||||||
|
dedlevel: 1,
|
||||||
specage: 25,
|
specage: 25,
|
||||||
specage2: 30,
|
specage2: 30,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { DatabaseTurnDaemonTransport } from '../../app/game-api/dist/index.js';
|
||||||
|
import {
|
||||||
|
createTurnDaemonRuntime,
|
||||||
|
resolveDatabaseUrl,
|
||||||
|
} from '../../app/game-engine/dist/index.js';
|
||||||
|
import { createGamePostgresConnector } from '../../packages/infra/dist/index.js';
|
||||||
|
|
||||||
|
const userCount = Number.parseInt(process.env.SEED_PARITY_USER_COUNT ?? '2', 10);
|
||||||
|
if (!Number.isInteger(userCount) || userCount < 1 || userCount > 10) {
|
||||||
|
throw new Error('SEED_PARITY_USER_COUNT must be an integer between 1 and 10');
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseUrl = await resolveDatabaseUrl();
|
||||||
|
const profile = process.env.PROFILE ?? 'hwe';
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||||
|
await connector.connect();
|
||||||
|
|
||||||
|
const runtime = await createTurnDaemonRuntime({
|
||||||
|
profile,
|
||||||
|
databaseUrl,
|
||||||
|
enableDatabaseFlush: true,
|
||||||
|
enableLeaseHeartbeat: false,
|
||||||
|
leaseOwnerId: `${profile}-seed-parity-user-setup`,
|
||||||
|
gameClockMode: 'manual',
|
||||||
|
});
|
||||||
|
const daemon = new DatabaseTurnDaemonTransport(connector.prisma, 30_000);
|
||||||
|
// Manual mode intentionally advances scheduled turns without wall-clock waits.
|
||||||
|
// Pause before the loop starts so this setup command cannot consume month 1.
|
||||||
|
runtime.lifecycle.pause('seed parity user setup');
|
||||||
|
const daemonLoop = runtime.lifecycle.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await daemon.requestStatus(30_000);
|
||||||
|
for (let number = 1; number <= userCount; number += 1) {
|
||||||
|
const userId = `gc2400-user-${String(number).padStart(2, '0')}`;
|
||||||
|
const ownerDisplayName = `올스타${String(number).padStart(2, '0')}`;
|
||||||
|
const generalName = `비교유저${String(number).padStart(2, '0')}`;
|
||||||
|
const result = await daemon.requestCommand(
|
||||||
|
{
|
||||||
|
type: 'joinCreateGeneral',
|
||||||
|
userId,
|
||||||
|
ownerDisplayName,
|
||||||
|
seedOwnerIdentity: number + 1,
|
||||||
|
name: generalName,
|
||||||
|
leadership: 100,
|
||||||
|
strength: 105,
|
||||||
|
intel: 105,
|
||||||
|
pic: false,
|
||||||
|
character: 'che_안전',
|
||||||
|
profileId: profile,
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
);
|
||||||
|
if (result?.type !== 'joinCreateGeneral' || !result.ok) {
|
||||||
|
throw new Error(`Failed to create ${userId}: ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the one explicit legacy appointment reservation. The remaining
|
||||||
|
// slots stay as Rest so the scenario's autorun-user policy owns them.
|
||||||
|
await connector.prisma.$transaction([
|
||||||
|
connector.prisma.generalTurn.update({
|
||||||
|
where: { generalId_turnIdx: { generalId: result.generalId, turnIdx: 0 } },
|
||||||
|
data: { actionCode: 'che_랜덤임관', arg: {} },
|
||||||
|
}),
|
||||||
|
connector.prisma.generalTurnRevision.update({
|
||||||
|
where: { generalId: result.generalId },
|
||||||
|
data: { revision: { increment: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
console.log(`${userId} created ${generalName} as general ${result.generalId}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runtime.lifecycle.stop('seed parity user setup complete');
|
||||||
|
await daemonLoop;
|
||||||
|
await runtime.close();
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user