merge: scenario 2400 long-run parity
This commit is contained in:
@@ -19,9 +19,9 @@ const requireSafeTick = (tick: 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 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)) {
|
||||
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 remainingMilliseconds = milliseconds - wholeSeconds * 1_000;
|
||||
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(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 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<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
> implements GeneralActionResolver<TriggerState, ProcureArgs> {
|
||||
@@ -107,8 +115,10 @@ export class ActionResolver<
|
||||
// the delta separately changes cancellation cases such as
|
||||
// 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the
|
||||
// accumulated binary value is exactly 4564.5 and persists as 4565.
|
||||
const nextExp = roundLegacyAccumulatedInteger(general.experience, exp);
|
||||
const nextDed = roundLegacyAccumulatedInteger(general.dedication, ded);
|
||||
const rawNextExp = general.experience + exp;
|
||||
const rawNextDed = general.dedication + ded;
|
||||
const nextExp = Math.round(rawNextExp);
|
||||
const nextDed = Math.round(rawNextDed);
|
||||
|
||||
let appliedScore = score;
|
||||
if (context.city && [1, 3].includes(context.city.frontState)) {
|
||||
@@ -167,6 +177,8 @@ export class ActionResolver<
|
||||
dedication: nextDed,
|
||||
meta: {
|
||||
...general.meta,
|
||||
explevel: resolveLegacyExperienceLevel(rawNextExp),
|
||||
dedlevel: resolveLegacyDedicationLevel(rawNextDed),
|
||||
[statKey]:
|
||||
(typeof general.meta[statKey] === 'number' ? (general.meta[statKey] as number) : 0) + 1,
|
||||
},
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface CommerceInvestmentResult {
|
||||
export interface CommerceInvestmentArgs {}
|
||||
|
||||
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_CONFIG: InvestmentConfig = {
|
||||
key: 'che_상업투자',
|
||||
@@ -188,7 +190,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
}
|
||||
|
||||
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 rawStats = {
|
||||
@@ -220,8 +222,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
|
||||
resolve(context: DomesticActionContext<TriggerState>, rng: RandomGenerator): CommerceInvestmentResult {
|
||||
const { gold: costGold, rice: costRice } = this.getCost(context);
|
||||
const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
|
||||
let score = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER);
|
||||
const trust = resolveLegacyDomesticTrust(getMetaNumber(context.city.meta, 'trust'), this.env.defaultTrust);
|
||||
const calculatedBaseScore = this.calcBaseScore(context, rng);
|
||||
let score = clamp(calculatedBaseScore, 1, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const ratio =
|
||||
this.env.getCriticalRatio?.(context, this.config.statKey) ??
|
||||
|
||||
@@ -105,11 +105,8 @@ export const normalizeLegacyGeneratedDex = (
|
||||
): [number, number, number, number, number] =>
|
||||
values.map((value) => Math.trunc(value)) as [number, number, number, number, number];
|
||||
|
||||
export const resolveLegacySpecialityAge = (
|
||||
retirementYear: number,
|
||||
age: number,
|
||||
divisor: number
|
||||
): number => Math.round((retirementYear - age) / divisor) + age;
|
||||
export const resolveLegacySpecialityAge = (retirementYear: number, age: number, divisor: number): number =>
|
||||
Math.round((retirementYear - age) / divisor) + age;
|
||||
|
||||
const addMetaValue = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
@@ -450,20 +447,14 @@ export class ActionResolver<
|
||||
const meta: GeneralMeta = {
|
||||
killturn,
|
||||
npcType: NPC_TYPE,
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
affinity,
|
||||
birthYear,
|
||||
deathYear,
|
||||
specage: resolveLegacySpecialityAge(
|
||||
context.retirementYear,
|
||||
age,
|
||||
12
|
||||
),
|
||||
specage2: resolveLegacySpecialityAge(
|
||||
context.retirementYear,
|
||||
age,
|
||||
6
|
||||
),
|
||||
specage: resolveLegacySpecialityAge(context.retirementYear, age, 12),
|
||||
specage2: resolveLegacySpecialityAge(context.retirementYear, age, 6),
|
||||
dex1: dex[0],
|
||||
dex2: dex[1],
|
||||
dex3: dex[2],
|
||||
|
||||
@@ -168,3 +168,5 @@ export const loadGeneralTurnCommandSpecs = async (
|
||||
}
|
||||
return specs;
|
||||
};
|
||||
|
||||
export { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
|
||||
|
||||
@@ -21,7 +21,11 @@ export const round = (value: number): number => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -519,6 +519,8 @@ const buildGeneralSeeds = (
|
||||
source: contextLabel,
|
||||
deathMonth,
|
||||
initialTurnOffsetMicros,
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
// Ref's GeneralBuilder derives speciality ages from the scenario
|
||||
// opening year even when installation stores a pre-opening age.
|
||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||
@@ -588,6 +590,8 @@ const buildGeneralSeeds = (
|
||||
),
|
||||
deathMonth,
|
||||
npcType,
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
crewTypeId: defaultCrewTypeId,
|
||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { WorldSnapshot } from '../../src/world/types.js';
|
||||
import {
|
||||
commandSpec as procureSpec,
|
||||
roundLegacyAccumulatedInteger,
|
||||
resolveLegacyDedicationLevel,
|
||||
resolveLegacyExperienceLevel,
|
||||
} 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';
|
||||
@@ -37,11 +39,9 @@ import {
|
||||
readLegacyStoredTech,
|
||||
toLegacyStoredTech,
|
||||
} from '../../src/actions/turn/general/che_기술연구.js';
|
||||
import {
|
||||
readLegacyCityTrust,
|
||||
storeLegacyCityTrust,
|
||||
} from '../../src/actions/turn/general/legacyCityTrust.js';
|
||||
import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js';
|
||||
import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js';
|
||||
import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js';
|
||||
|
||||
describe('General Commands New Scenario', () => {
|
||||
it('truncates generated NPC dex like GeneralBuilder integer arguments', () => {
|
||||
@@ -55,6 +55,14 @@ describe('General Commands New Scenario', () => {
|
||||
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', () => {
|
||||
expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27);
|
||||
expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32);
|
||||
@@ -86,6 +94,12 @@ describe('General Commands New Scenario', () => {
|
||||
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
|
||||
const systemEnv: TurnCommandEnv = {
|
||||
develCost: 100,
|
||||
|
||||
@@ -6,6 +6,8 @@ describe('legacy war rounding', () => {
|
||||
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(8719.4999999999945)).toBe(8720);
|
||||
expect(round(-8719.4999999999945)).toBe(-8720);
|
||||
});
|
||||
|
||||
it('keeps values meaningfully below a half boundary on the lower integer', () => {
|
||||
|
||||
@@ -175,9 +175,16 @@ describe('scenario bootstrap', () => {
|
||||
specialDomestic: 'che_event_돌격',
|
||||
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({
|
||||
deathMonth: expect.any(Number),
|
||||
explevel: 0,
|
||||
dedlevel: 1,
|
||||
specage: 25,
|
||||
specage2: 30,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user