feat: initial commit for TheFarmer project

This commit is contained in:
Karriis
2026-02-18 13:52:06 +08:00
commit 8ceb5fa9db
420 changed files with 61918 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
/**
* 基于 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);
}
}