import { Solar } from 'lunar-typescript'; import { chartFromCivilTime, normalizeCivilTime } from './calendar'; import { BRANCHES, STEMS } from './constants'; import { calculateSeasonContext } from './season-context'; import type { BaziInput, CalculateLuckCyclesOptions, CivilTime, FourPillarsInput, LuckCycleAgeOffset, LuckCycleDirection, LuckCycleDirectionRule, LuckCyclePeriod, LuckCycleResult, LuckCycleSexForRule, SolarTermDistance, YinYang, } from './types'; const SOURCE_SECONDS_PER_AGE_YEAR = 3 * 24 * 60 * 60; const SOURCE_SECONDS_PER_AGE_MONTH = SOURCE_SECONDS_PER_AGE_YEAR / 12; const SOURCE_SECONDS_PER_AGE_DAY = SOURCE_SECONDS_PER_AGE_YEAR / 360; const SOURCE_SECONDS_PER_AGE_HOUR = SOURCE_SECONDS_PER_AGE_DAY / 24; const DEFAULT_CYCLE_COUNT = 10; const CYCLE_LENGTH_YEARS = 10; const JIA_ZI: readonly string[] = Array.from( { length: 60 }, (_, index) => `${STEMS[index % STEMS.length]}${BRANCHES[index % BRANCHES.length]}`, ); function formatChinaStandardTime(solar: Solar): string { const pad = (value: number) => String(value).padStart(2, '0'); return `${String(solar.getYear()).padStart(4, '0')}-${pad(solar.getMonth())}-${pad(solar.getDay())}T${pad(solar.getHour())}:${pad(solar.getMinute())}:${pad(solar.getSecond())}+08:00`; } function solarFromCivilTime(civilTime: Required): Solar { return Solar.fromYmdHms( civilTime.year, civilTime.month, civilTime.day, civilTime.hour, civilTime.minute, civilTime.second, ); } function addMinutes(solar: Solar, minutes: number): Solar { if (minutes === 0) return solar; const date = new Date(0); date.setUTCFullYear(solar.getYear(), solar.getMonth() - 1, solar.getDay()); date.setUTCHours(solar.getHour(), solar.getMinute() + minutes, solar.getSecond(), 0); return Solar.fromYmdHms( date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), ); } function addAgeOffset( civilTime: Required, offset: LuckCycleAgeOffset, ): Solar { let solar = solarFromCivilTime(civilTime); solar = solar.nextYear(offset.years); solar = solar.nextMonth(offset.months); solar = solar.nextDay(offset.days); solar = solar.nextHour(offset.hours); return addMinutes(solar, offset.minutes); } export function convertThreeDaysToOneYear( distanceSeconds: number, ): LuckCycleAgeOffset { if (!Number.isInteger(distanceSeconds) || distanceSeconds < 0) { throw new RangeError('distanceSeconds 必须是非负整数'); } let remaining = distanceSeconds; const years = Math.floor(remaining / SOURCE_SECONDS_PER_AGE_YEAR); remaining -= years * SOURCE_SECONDS_PER_AGE_YEAR; const months = Math.floor(remaining / SOURCE_SECONDS_PER_AGE_MONTH); remaining -= months * SOURCE_SECONDS_PER_AGE_MONTH; const days = Math.floor(remaining / SOURCE_SECONDS_PER_AGE_DAY); remaining -= days * SOURCE_SECONDS_PER_AGE_DAY; const hours = Math.floor(remaining / SOURCE_SECONDS_PER_AGE_HOUR); remaining -= hours * SOURCE_SECONDS_PER_AGE_HOUR; return { years, months, days, hours, // 三天折一年且一年按 360 天展开时,原始 1 秒对应起运年龄 2 分钟。 minutes: remaining * 2, }; } function validateCycleCount(cycleCount: number | undefined): number { const value = cycleCount ?? DEFAULT_CYCLE_COUNT; if (!Number.isInteger(value) || value < 1 || value > 20) { throw new RangeError('cycleCount 必须是 1 到 20 之间的整数'); } return value; } function validateRequiredSexForRule(value: unknown): asserts value is LuckCycleSexForRule { if (value !== 'male' && value !== 'female') { throw new TypeError('未直接指定 direction 时,sexForRule 必须是 male 或 female'); } } function validateOptionalSexForRule(value: unknown): asserts value is LuckCycleSexForRule | undefined { if (value !== undefined && value !== 'male' && value !== 'female') { throw new TypeError('sexForRule 必须是 male 或 female'); } } function directionFromYearStemAndSex( yearYinYang: YinYang, sexForRule: LuckCycleSexForRule, ): LuckCycleDirection { const forward = (yearYinYang === 'yang' && sexForRule === 'male') || (yearYinYang === 'yin' && sexForRule === 'female'); return forward ? 'forward' : 'reverse'; } function resolveDirection( options: CalculateLuckCyclesOptions, yearStem: string, yearYinYang: YinYang, ): { direction: LuckCycleDirection; directionRule: LuckCycleDirectionRule; reason: string; } { if (options.direction !== undefined) { if (options.direction !== 'forward' && options.direction !== 'reverse') { throw new TypeError('direction 必须是 forward 或 reverse'); } return { direction: options.direction, directionRule: 'explicit', reason: `调用方显式指定大运${options.direction === 'forward' ? '顺排' : '逆排'}。`, }; } validateRequiredSexForRule(options.sexForRule); const direction = directionFromYearStemAndSex(yearYinYang, options.sexForRule); const yinYangLabel = yearYinYang === 'yang' ? '阳' : '阴'; const sexLabel = options.sexForRule === 'male' ? '男' : '女'; return { direction, directionRule: 'year-stem-and-sex', reason: `年干${yearStem}为${yinYangLabel}干,${sexLabel}命按所选传统规则${direction === 'forward' ? '顺排' : '逆排'}。`, }; } function shiftJiaZi(ganZhi: string, offset: number): string { const index = JIA_ZI.indexOf(ganZhi); if (index < 0) { throw new RangeError(`月柱必须属于六十甲子;收到:${ganZhi}`); } const shifted = ((index + offset) % JIA_ZI.length + JIA_ZI.length) % JIA_ZI.length; const result = JIA_ZI[shifted]; if (!result) throw new RangeError(`无法从${ganZhi}移动${offset}位`); return result; } function ageAtCycle( startAge: LuckCycleAgeOffset, addedYears: number, ): LuckCycleAgeOffset { return { ...startAge, years: startAge.years + addedYears }; } function createCycles( monthPillar: string, direction: LuckCycleDirection, firstStart: Solar, startAge: LuckCycleAgeOffset, count: number, ): LuckCyclePeriod[] { const step = direction === 'forward' ? 1 : -1; return Array.from({ length: count }, (_, zeroIndex) => { const index = zeroIndex + 1; const addedYears = zeroIndex * CYCLE_LENGTH_YEARS; const endAddedYears = index * CYCLE_LENGTH_YEARS; return { index, ganZhi: shiftJiaZi(monthPillar, step * index), startsAt: formatChinaStandardTime(firstStart.nextYear(addedYears)), endsAt: formatChinaStandardTime(firstStart.nextYear(endAddedYears)), startAge: ageAtCycle(startAge, addedYears), endAge: ageAtCycle(startAge, endAddedYears), }; }); } function pillarsInput(pillars: ReturnType['pillars']): FourPillarsInput { return { year: pillars.year, month: pillars.month, day: pillars.day, hour: pillars.hour, }; } export function calculateLuckCycles( input: BaziInput, options: CalculateLuckCyclesOptions, ): LuckCycleResult { if (!options || typeof options !== 'object') { throw new TypeError('calculateLuckCycles 需要 direction 或 sexForRule 以确定大运顺逆'); } if (options.dayBoundary !== undefined && options.dayBoundary !== 'midnight' && options.dayBoundary !== 'zi-hour') { throw new TypeError('dayBoundary 必须是 midnight 或 zi-hour'); } if (options.directionRule !== undefined && options.directionRule !== 'year-stem-and-sex') { throw new TypeError('luck-cycle-v1 只支持 year-stem-and-sex 顺逆规则'); } validateOptionalSexForRule(options.sexForRule); if (options.boundaryRule !== undefined && options.boundaryRule !== 'jie') { throw new TypeError('luck-cycle-v1 只支持以十二“节”为起运边界'); } if (options.startAgeRule !== undefined && options.startAgeRule !== 'three-days-one-year') { throw new TypeError('luck-cycle-v1 只支持 three-days-one-year 起运换算'); } const civilTime = normalizeCivilTime(input.civilTime); const dayBoundary = options.dayBoundary ?? 'midnight'; const calendar = chartFromCivilTime(civilTime, dayBoundary); const seasonContext = calculateSeasonContext(civilTime, calendar.chart); const resolvedDirection = resolveDirection( options, calendar.chart.pillars.year.stem.name, calendar.chart.pillars.year.stem.yinYang, ); const boundaryTerm: SolarTermDistance = resolvedDirection.direction === 'forward' ? seasonContext.solarTerms.nextMonthBoundary : seasonContext.solarTerms.previousMonthBoundary; const startAge = convertThreeDaysToOneYear(boundaryTerm.distanceSeconds); const firstStart = addAgeOffset(civilTime, startAge); const sourcePillars = pillarsInput(calendar.pillars); const cycleCount = validateCycleCount(options.cycleCount); const cycles = createCycles( sourcePillars.month, resolvedDirection.direction, firstStart, startAge, cycleCount, ); const warnings = [ 'luck-cycle-v1 只计算起运时间和十年干支序列,不判断任何一步大运的吉凶。', '起运年龄采用“三天一岁、一年按十二月和三百六十日展开”的确定性换算口径。', ]; if (options.direction !== undefined && options.sexForRule !== undefined) { warnings.push('已显式指定 direction,sexForRule 未参与本次顺逆判断。'); } return { model: 'luck-cycle-v1', input: { civilTime, ...(options.sexForRule === undefined ? {} : { sexForRule: options.sexForRule }), }, sourcePillars, direction: resolvedDirection.direction, directionReason: resolvedDirection.reason, boundaryTerm, startAge, startsAt: formatChinaStandardTime(firstStart), beforeStart: { label: '起运前', startsAt: formatChinaStandardTime(solarFromCivilTime(civilTime)), endsAt: formatChinaStandardTime(firstStart), }, cycles, appliedRules: { calendar: 'solar', timeStandard: 'china-standard-time', dayBoundary, directionRule: resolvedDirection.directionRule, boundaryRule: 'jie', startAgeRule: 'three-days-one-year', cycleLengthYears: 10, cycleInterval: 'start-inclusive-end-exclusive', solarTermProvider: 'lunar-typescript', solarTermPrecision: 'second', }, trace: [ ...calendar.trace, { ruleId: 'luck-cycle.direction', description: '根据调用方显式方向,或年干阴阳与 sexForRule 的传统规则确定大运顺逆。', input: { yearPillar: sourcePillars.year, yearStemYinYang: calendar.chart.pillars.year.stem.yinYang, direction: options.direction, sexForRule: options.sexForRule, }, output: { direction: resolvedDirection.direction, directionRule: resolvedDirection.directionRule, reason: resolvedDirection.reason, }, }, { ruleId: 'luck-cycle.boundary', description: '顺排取出生后的下一个节,逆排取出生前的上一个节。', input: { direction: resolvedDirection.direction, previousJie: seasonContext.solarTerms.previousMonthBoundary, nextJie: seasonContext.solarTerms.nextMonthBoundary, }, output: { boundaryTerm }, }, { ruleId: 'luck-cycle.start-age.three-days-one-year', description: '按三天一岁、一日四月、十二分钟一日的等价关系,将节令距离换算为起运年龄。', input: { distanceSeconds: boundaryTerm.distanceSeconds }, output: { startAge, startsAt: formatChinaStandardTime(firstStart) }, }, { ruleId: 'luck-cycle.sequence', description: '从月柱沿六十甲子按顺逆方向移动,每一步持续十个公历年。', input: { monthPillar: sourcePillars.month, direction: resolvedDirection.direction, cycleCount, }, output: { cycles: cycles.map((cycle) => ({ index: cycle.index, ganZhi: cycle.ganZhi, startsAt: cycle.startsAt, endsAt: cycle.endsAt, })), }, }, ], warnings, }; }