Files
Farmer/server/tools/calc-exp-yield.js
2026-02-18 13:52:06 +08:00

475 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 基于 tools/seed-shop-merged-export.json 计算经验收益率
*
* 规则:
* 1) 每次收获经验 = exp新版已去除铲地+1经验
* 2) 种植速度:
* - 不施肥2 秒种 18 块地 => 9 块/秒
* - 普通肥2 秒种 12 块地 => 6 块/秒
* 3) 普通肥:直接减少一个生长阶段(按 Plant.json 的 grow_phases 取首个非0阶段时长
*
* 用法:
* node tools/calc-exp-yield.js
* node tools/calc-exp-yield.js --lands 18 --level 27
* node tools/calc-exp-yield.js --input tools/seed-shop-merged-export.json
*
* 运行时调用:
* const { getPlantingRecommendation } = require('../tools/calc-exp-yield');
* const rec = getPlantingRecommendation(27, 18);
*/
const fs = require('fs');
const path = require('path');
const DEFAULT_INPUT = path.join(__dirname, 'seed-shop-merged-export.json');
const PLANT_CONFIG_PATH = path.join(__dirname, '..', 'gameConfig', 'Plant.json');
const DEFAULT_OUT_JSON = path.join(__dirname, 'exp-yield-result.json');
const DEFAULT_OUT_CSV = path.join(__dirname, 'exp-yield-result.csv');
const DEFAULT_OUT_TXT = path.join(__dirname, 'exp-yield-summary.txt');
const NO_FERT_PLANTS_PER_2_SEC = 18;
const NORMAL_FERT_PLANTS_PER_2_SEC = 12;
const NO_FERT_PLANT_SPEED_PER_SEC = NO_FERT_PLANTS_PER_2_SEC / 2; // 9 块/秒
const NORMAL_FERT_PLANT_SPEED_PER_SEC = NORMAL_FERT_PLANTS_PER_2_SEC / 2; // 6 块/秒
function toNum(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function parseArgs(argv) {
const opts = {
input: DEFAULT_INPUT,
outJson: DEFAULT_OUT_JSON,
outCsv: DEFAULT_OUT_CSV,
outTxt: DEFAULT_OUT_TXT,
lands: 18,
level: null,
top: 20,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--input' && argv[i + 1]) opts.input = argv[++i];
else if (a === '--out-json' && argv[i + 1]) opts.outJson = argv[++i];
else if (a === '--out-csv' && argv[i + 1]) opts.outCsv = argv[++i];
else if (a === '--out-txt' && argv[i + 1]) opts.outTxt = argv[++i];
else if (a === '--lands' && argv[i + 1]) opts.lands = Math.max(1, Math.floor(toNum(argv[++i], 18)));
else if (a === '--level' && argv[i + 1]) opts.level = Math.max(1, Math.floor(toNum(argv[++i], 1)));
else if (a === '--top' && argv[i + 1]) opts.top = Math.max(1, Math.floor(toNum(argv[++i], 20)));
else if (a === '--help' || a === '-h') {
printHelp();
process.exit(0);
}
}
return opts;
}
function printHelp() {
console.log('Usage: node tools/calc-exp-yield.js [options]');
console.log('');
console.log('Options:');
console.log(' --input <path> 输入 JSON 文件路径');
console.log(' --lands <n> 地块数(默认 18');
console.log(' --level <n> 指定账号等级,输出该等级可用最优作物');
console.log(' --top <n> 摘要 Top 数量(默认 20');
console.log(' --out-json <path> 输出 JSON 路径');
console.log(' --out-csv <path> 输出 CSV 路径');
console.log(' --out-txt <path> 输出 TXT 路径');
}
function readSeeds(inputPath) {
const text = fs.readFileSync(inputPath, 'utf8');
const data = JSON.parse(text);
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.rows)) return data.rows;
if (data && Array.isArray(data.seeds)) return data.seeds;
throw new Error('无法识别输入数据格式,需要数组或 rows/seeds 字段');
}
function parseGrowPhases(growPhases) {
if (!growPhases || typeof growPhases !== 'string') return [];
return growPhases
.split(';')
.map(x => x.trim())
.filter(Boolean)
.map(seg => {
const parts = seg.split(':');
return parts.length >= 2 ? toNum(parts[1], 0) : 0;
})
.filter(sec => sec > 0);
}
function loadSeedPhaseReduceMap() {
const text = fs.readFileSync(PLANT_CONFIG_PATH, 'utf8');
const rows = JSON.parse(text);
if (!Array.isArray(rows)) {
throw new Error(`Plant 配置格式异常: ${PLANT_CONFIG_PATH}`);
}
const map = new Map();
for (const p of rows) {
const seedId = toNum(p.seed_id, 0);
if (seedId <= 0 || map.has(seedId)) continue;
const phases = parseGrowPhases(p.grow_phases);
if (phases.length === 0) continue;
map.set(seedId, phases[0]); // 普通肥减少一个阶段:以首个阶段时长为准
}
return map;
}
function calcEffectiveGrowTime(growSec, seedId, seedPhaseReduceMap) {
const reduce = toNum(seedPhaseReduceMap.get(seedId), 0);
if (reduce <= 0) return growSec;
return Math.max(1, growSec - reduce);
}
function formatSec(sec) {
const s = Math.max(0, Math.round(sec));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const r = s % 60;
if (m < 60) return r > 0 ? `${m}m${r}s` : `${m}m`;
const h = Math.floor(m / 60);
const mm = m % 60;
return r > 0 ? `${h}h${mm}m${r}s` : `${h}h${mm}m`;
}
function csvCell(v) {
const s = v == null ? '' : String(v);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function buildRows(rawSeeds, lands, seedPhaseReduceMap) {
const plantSecondsNoFert = lands / NO_FERT_PLANT_SPEED_PER_SEC;
const plantSecondsNormalFert = lands / NORMAL_FERT_PLANT_SPEED_PER_SEC;
const rows = [];
let skipped = 0;
let missingPhaseReduceCount = 0;
for (const s of rawSeeds) {
const seedId = toNum(s.seedId || s.seed_id);
const name = s.name || `seed_${seedId}`;
const requiredLevel = toNum(s.requiredLevel || s.required_level || 1, 1);
const price = toNum(s.price, 0);
const expHarvest = toNum(s.exp, 0);
const growTimeSec = toNum(s.growTimeSec || s.growTime || s.grow_time || 0, 0);
if (seedId <= 0 || growTimeSec <= 0) {
skipped++;
continue;
}
const expPerCycle = expHarvest;
const reduceSec = toNum(seedPhaseReduceMap.get(seedId), 0);
if (reduceSec <= 0) missingPhaseReduceCount++;
const growTimeNormalFert = calcEffectiveGrowTime(growTimeSec, seedId, seedPhaseReduceMap);
// 整个农场一轮 = 生长时间 + 本轮全部地块种植耗时
const cycleSecNoFert = growTimeSec + plantSecondsNoFert;
const cycleSecNormalFert = growTimeNormalFert + plantSecondsNormalFert;
const farmExpPerHourNoFert = (lands * expPerCycle / cycleSecNoFert) * 3600;
const farmExpPerHourNormalFert = (lands * expPerCycle / cycleSecNormalFert) * 3600;
const gainPercent = farmExpPerHourNoFert > 0
? ((farmExpPerHourNormalFert - farmExpPerHourNoFert) / farmExpPerHourNoFert) * 100
: 0;
const expPerGoldSeed = price > 0 ? expPerCycle / price : 0;
rows.push({
seedId,
goodsId: toNum(s.goodsId || s.goods_id),
plantId: toNum(s.plantId || s.plant_id),
name,
requiredLevel,
unlocked: !!s.unlocked,
price,
expHarvest,
expPerCycle,
growTimeSec,
growTimeStr: s.growTimeStr || formatSec(growTimeSec),
normalFertReduceSec: reduceSec,
growTimeNormalFert,
growTimeNormalFertStr: formatSec(growTimeNormalFert),
cycleSecNoFert,
cycleSecNormalFert,
farmExpPerHourNoFert,
farmExpPerHourNormalFert,
farmExpPerDayNoFert: farmExpPerHourNoFert * 24,
farmExpPerDayNormalFert: farmExpPerHourNormalFert * 24,
gainPercent,
expPerGoldSeed,
fruitId: toNum(s?.fruit?.id || s.fruitId),
fruitCount: toNum(s?.fruit?.count || s.fruitCount),
});
}
return { rows, skipped, plantSecondsNoFert, plantSecondsNormalFert, missingPhaseReduceCount };
}
function pickTop(rows, key, topN) {
return [...rows]
.sort((a, b) => b[key] - a[key])
.slice(0, topN);
}
function buildBestByLevel(rows) {
const maxLevel = rows.reduce((m, r) => Math.max(m, r.requiredLevel), 1);
const result = [];
for (let lv = 1; lv <= maxLevel; lv++) {
// 按用户指定等级做理论可种分析,不受商店 unlocked 状态影响
const available = rows.filter(r => r.requiredLevel <= lv);
if (available.length === 0) continue;
const bestNo = pickTop(available, 'farmExpPerHourNoFert', 1)[0];
const bestFert = pickTop(available, 'farmExpPerHourNormalFert', 1)[0];
result.push({
level: lv,
bestNoFert: {
seedId: bestNo.seedId,
name: bestNo.name,
expPerHour: Number(bestNo.farmExpPerHourNoFert.toFixed(2)),
},
bestNormalFert: {
seedId: bestFert.seedId,
name: bestFert.name,
expPerHour: Number(bestFert.farmExpPerHourNormalFert.toFixed(2)),
},
});
}
return result;
}
function writeJson(outPath, payload) {
fs.writeFileSync(outPath, JSON.stringify(payload, null, 2), 'utf8');
}
function writeCsv(outPath, rows) {
const headers = [
'seedId',
'name',
'requiredLevel',
'price',
'expHarvest',
'expPerCycle',
'growTimeSec',
'growTimeNormalFert',
'cycleSecNoFert',
'cycleSecNormalFert',
'farmExpPerHourNoFert',
'farmExpPerHourNormalFert',
'farmExpPerDayNoFert',
'farmExpPerDayNormalFert',
'gainPercent',
'expPerGoldSeed',
];
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(headers.map(h => csvCell(r[h])).join(','));
}
fs.writeFileSync(outPath, `${lines.join('\n')}\n`, 'utf8');
}
function writeSummaryTxt(outPath, opts, meta, topNo, topFert, levelInfo) {
const lines = [];
lines.push('经验收益率分析结果');
lines.push('');
lines.push(`数据源: ${meta.input}`);
lines.push(`导出时间: ${new Date().toISOString()}`);
lines.push(`地块数: ${opts.lands}`);
lines.push(`种植速度(不施肥): ${NO_FERT_PLANTS_PER_2_SEC}块/${2}s (${NO_FERT_PLANT_SPEED_PER_SEC}块/s)`);
lines.push(`种植速度(普通肥): ${NORMAL_FERT_PLANTS_PER_2_SEC}块/${2}s (${NORMAL_FERT_PLANT_SPEED_PER_SEC}块/s)`);
lines.push(`整场种植耗时(不施肥): ${formatSec(meta.plantSecondsNoFert)}`);
lines.push(`整场种植耗时(普通肥): ${formatSec(meta.plantSecondsNormalFert)}`);
lines.push(`普通肥规则: 直接减少一个生长阶段(按 Plant.json 的首个阶段时长)`);
lines.push(`缺少阶段配置的种子数: ${meta.missingPhaseReduceCount}`);
lines.push('');
lines.push(`Top ${topNo.length}(不施肥,按每小时经验)`);
lines.push('排名 | 名称 | Lv需 | 生长 | 单轮经验 | 每小时经验');
topNo.forEach((r, i) => {
lines.push(
`${String(i + 1).padStart(2)} | ${r.name} | ${r.requiredLevel} | ${r.growTimeStr} | ${r.expPerCycle} | ${r.farmExpPerHourNoFert.toFixed(2)}`
);
});
lines.push('');
lines.push(`Top ${topFert.length}(普通肥,按每小时经验)`);
lines.push('排名 | 名称 | Lv需 | 肥后生长 | 单轮经验 | 每小时经验 | 提升');
topFert.forEach((r, i) => {
lines.push(
`${String(i + 1).padStart(2)} | ${r.name} | ${r.requiredLevel} | ${r.growTimeNormalFertStr} | ${r.expPerCycle} | ${r.farmExpPerHourNormalFert.toFixed(2)} | ${r.gainPercent.toFixed(2)}%`
);
});
lines.push('');
if (levelInfo) {
lines.push(`当前等级 Lv${levelInfo.level} 推荐`);
lines.push(`不施肥: ${levelInfo.bestNoFert.name}(seed=${levelInfo.bestNoFert.seedId}) -> ${levelInfo.bestNoFert.expPerHour.toFixed(2)} exp/h`);
lines.push(`普通肥: ${levelInfo.bestNormalFert.name}(seed=${levelInfo.bestNormalFert.seedId}) -> ${levelInfo.bestNormalFert.expPerHour.toFixed(2)} exp/h`);
lines.push('');
}
fs.writeFileSync(outPath, `${lines.join('\n')}\n`, 'utf8');
}
function analyzeExpYield(opts = {}) {
const lands = Math.max(1, Math.floor(toNum(opts.lands, 18)));
const level = opts.level == null ? null : Math.max(1, Math.floor(toNum(opts.level, 1)));
const top = Math.max(1, Math.floor(toNum(opts.top, 20)));
const input = opts.input || DEFAULT_INPUT;
const inputAbs = path.resolve(input);
const rawSeeds = readSeeds(inputAbs);
const seedPhaseReduceMap = loadSeedPhaseReduceMap();
const { rows, skipped, plantSecondsNoFert, plantSecondsNormalFert, missingPhaseReduceCount } = buildRows(rawSeeds, lands, seedPhaseReduceMap);
if (rows.length === 0) {
throw new Error('没有可计算的种子数据(请检查输入文件)');
}
const topNo = pickTop(rows, 'farmExpPerHourNoFert', top);
const topFert = pickTop(rows, 'farmExpPerHourNormalFert', top);
const bestByLevel = buildBestByLevel(rows);
let currentLevel = null;
if (level != null) {
currentLevel = bestByLevel.find(x => x.level === level) || null;
}
return {
generatedAt: new Date().toISOString(),
input: inputAbs,
config: {
lands,
plantSpeedPerSecNoFert: NO_FERT_PLANT_SPEED_PER_SEC,
plantSpeedPerSecNormalFert: NORMAL_FERT_PLANT_SPEED_PER_SEC,
plantSecondsNoFert,
plantSecondsNormalFert,
fertilizer: {
mode: 'minus_one_phase',
},
rule: {
expPerCycle: 'expHarvest',
},
},
stats: {
rawCount: rawSeeds.length,
calculatedCount: rows.length,
skippedCount: skipped,
missingPhaseReduceCount,
},
topNoFert: topNo.map(r => ({
seedId: r.seedId,
name: r.name,
requiredLevel: r.requiredLevel,
expPerHour: Number(r.farmExpPerHourNoFert.toFixed(4)),
})),
topNormalFert: topFert.map(r => ({
seedId: r.seedId,
name: r.name,
requiredLevel: r.requiredLevel,
expPerHour: Number(r.farmExpPerHourNormalFert.toFixed(4)),
gainPercent: Number(r.gainPercent.toFixed(4)),
})),
bestByLevel,
currentLevel,
rows,
};
}
function getPlantingRecommendation(level, lands, opts = {}) {
const safeLevel = Math.max(1, Math.floor(toNum(level, 1)));
const payload = analyzeExpYield({
input: opts.input || DEFAULT_INPUT,
lands: lands == null ? 18 : lands,
top: opts.top || 20,
level: safeLevel,
});
const availableRows = payload.rows.filter(r => r.requiredLevel <= safeLevel);
const bestNoFertRow = pickTop(availableRows, 'farmExpPerHourNoFert', 1)[0] || null;
const bestNormalFertRow = pickTop(availableRows, 'farmExpPerHourNormalFert', 1)[0] || null;
return {
level: safeLevel,
lands: payload.config.lands,
input: payload.input,
bestNoFert: bestNoFertRow ? {
seedId: bestNoFertRow.seedId,
name: bestNoFertRow.name,
requiredLevel: bestNoFertRow.requiredLevel,
expPerHour: Number(bestNoFertRow.farmExpPerHourNoFert.toFixed(4)),
} : null,
bestNormalFert: bestNormalFertRow ? {
seedId: bestNormalFertRow.seedId,
name: bestNormalFertRow.name,
requiredLevel: bestNormalFertRow.requiredLevel,
expPerHour: Number(bestNormalFertRow.farmExpPerHourNormalFert.toFixed(4)),
} : null,
candidatesNoFert: pickTop(availableRows, 'farmExpPerHourNoFert', opts.top || 20).map(r => ({
seedId: r.seedId,
name: r.name,
requiredLevel: r.requiredLevel,
expPerHour: Number(r.farmExpPerHourNoFert.toFixed(4)),
})),
candidatesNormalFert: pickTop(availableRows, 'farmExpPerHourNormalFert', opts.top || 20).map(r => ({
seedId: r.seedId,
name: r.name,
requiredLevel: r.requiredLevel,
expPerHour: Number(r.farmExpPerHourNormalFert.toFixed(4)),
gainPercent: Number(r.gainPercent.toFixed(4)),
})),
};
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const payload = analyzeExpYield(opts);
const rows = payload.rows;
const topNo = pickTop(rows, 'farmExpPerHourNoFert', opts.top);
const topFert = pickTop(rows, 'farmExpPerHourNormalFert', opts.top);
const currentLevel = payload.currentLevel;
writeJson(path.resolve(opts.outJson), payload);
writeCsv(path.resolve(opts.outCsv), rows);
writeSummaryTxt(
path.resolve(opts.outTxt),
opts,
{
input: payload.input,
plantSecondsNoFert: payload.config.plantSecondsNoFert,
plantSecondsNormalFert: payload.config.plantSecondsNormalFert,
missingPhaseReduceCount: payload.stats.missingPhaseReduceCount,
},
topNo,
topFert,
currentLevel
);
console.log(`[收益率] 计算完成,共 ${rows.length} 条(跳过 ${payload.stats.skippedCount} 条)`);
console.log(`[收益率] JSON: ${path.resolve(opts.outJson)}`);
console.log(`[收益率] CSV : ${path.resolve(opts.outCsv)}`);
console.log(`[收益率] TXT : ${path.resolve(opts.outTxt)}`);
if (currentLevel) {
console.log(`[收益率] Lv${opts.level} 最优(不施肥): ${currentLevel.bestNoFert.name} ${currentLevel.bestNoFert.expPerHour} exp/h`);
console.log(`[收益率] Lv${opts.level} 最优(普通肥): ${currentLevel.bestNormalFert.name} ${currentLevel.bestNormalFert.expPerHour} exp/h`);
}
}
module.exports = {
analyzeExpYield,
getPlantingRecommendation,
DEFAULT_INPUT,
};
if (require.main === module) {
try {
main();
} catch (e) {
console.error(`[收益率] 失败: ${e.message}`);
process.exit(1);
}
}