475 lines
18 KiB
JavaScript
475 lines
18 KiB
JavaScript
/**
|
||
* 基于 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);
|
||
}
|
||
}
|