Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions repo/js/GenshinCultivationPlanner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 角色一键养成

选好角色、天赋和武器的培养目标后,脚本会读取背包,扣除已有材料和可通过 3:1 合成得到的数量,再安排从今天开始的一周刷取计划。当天能刷的培养秘境、世界 Boss、圣遗物秘境和已订阅路线,可以直接交给 BetterGI 执行。

运行结束后,日志和邮件会列出本次确认获得的材料、当前缺口,以及培养秘境或 Boss 大约还要领取多少次奖励。收益通常以运行前后的背包差值为准;结束背包漏识别时,会使用 BetterGI 已识别到的任务奖励兜底。脚本不会替你点击升级;这里的“一键”是把材料计算、每周规划、任务调度和结果统计放进一次运行。

## 与现有养成脚本的区别

这个脚本接收的是“培养目标”,而不是一份需要用户事先整理好的材料清单。日常使用时,只要选择角色、目标等级、三项天赋,以及是否培养武器;脚本会自动推导材料总量、扣除背包库存,并匹配对应的世界 Boss、培养秘境和已订阅路线。

因此,用户不必先到养成计算器抄写各级材料数量,也不必自己查某项材料由哪个 Boss 掉落、在哪个秘境获取或星期几开放。队伍与路线完成一次基础配置后,后续更换培养目标即可重新计算和调度。

## 它会帮你做什么

- 按角色、三项天赋和武器的目标等级计算材料需求,不用逐级查表。
- 扣除背包库存,并沿合成链统一计算天赋书、武器突破材料和怪物材料;展示时仍按高、中、低阶分别列出。
- 按秘境开放日生成七天循环计划,周日会自动选择对应材料系列。
- 自动匹配所需世界 Boss,不要求手写 Boss 名称。机制特殊的 Boss 可以从下拉框选择专属队伍、策略或单独禁用。
- 调用 BetterGI 的秘境、自动首领和 AutoPathing 能力执行当天任务,并用运行前后的背包差值统计收益。

## 使用前准备

1. 使用 BetterGI `0.63.0` 或更高版本,并在 BetterGI 设置中选择 OCR V6。
2. 如果要刷地方特产或怪物材料,先在 BetterGI 中订阅对应的 AutoPathing 路线。
3. 准备好任务队伍。配置中的队伍名称必须与游戏内完全一致。

角色、武器或背包数量文字识别异常时,先确认 OCR V6 已启用,再重新运行。背包中的物品名称由 BetterGI ItemV2 图标模型匹配,并不使用 OCR;日志会把“图标未匹配”和“数量 OCR 失败”分开提示。脚本无法读取或切换 BetterGI 当前使用的 OCR 版本。

## 配置与运行

1. 在 BetterGI 脚本仓库中订阅并添加“角色一键养成”。
2. 选择角色、目标等级和三项天赋;需要培养武器时,再选择武器和目标等级。
3. 填写培养秘境队伍。世界 Boss、地方特产、怪物路线和圣遗物填充默认关闭,需要时再开启并填写对应队伍。
4. 检查配置,勾选设置页顶部的“我已确认配置并允许实际执行”,然后运行脚本。未勾选时脚本会立即结束,不读背包、不写记录、不发送通知,也不操作游戏。

如果把脚本加入 BetterGI 一条龙,请不要在它后面再启用内置“自动秘境”或“自动首领讨伐”。本脚本已经负责选择并执行当天的树脂任务,重复配置可能继续消耗树脂,也会让后续报错难以判断来源。

世界 Boss 会根据缺少的突破材料自动匹配。通用队伍无法处理某个 Boss 时,可以使用三个“专属配置”槽:Boss 名称从按地区分组的列表中选择,队伍和策略留空则继承通用配置。“禁用”会跳过该 Boss,继续寻找当天可执行的培养秘境。

多个目标或下拉框未覆盖的特殊区间可以启用“自定义培养目标”:

```text
申鹤:70>90,6/8/8>9/9/9;和璞鸢:70>90
```

中文和英文的分号、逗号、冒号都可以使用,也可以换行分隔。启用自定义目标后,上方的角色、武器、等级和天赋选项不再生效。

## 调度规则

树脂任务使用固定顺序:世界 Boss → 当天开放的限时培养秘境 → 普通培养秘境 → 圣遗物秘境填充。一次运行只执行第一个已启用且可以执行的树脂任务,不会在多个秘境之间拆分树脂。树脂任务结束后,再执行已开启的地方特产和怪物路线。

圣遗物填充默认关闭。启用后,只有当天没有可执行的 Boss 或培养材料秘境时才会加入队列。它支持单次和正式模式、独立队伍与独立战斗策略;不会自动分解圣遗物,也不会把圣遗物计入培养材料收益或预计完成天数。

培养秘境和圣遗物秘境默认先用已有的浓缩树脂,再用原粹树脂;高级设置可以选择其他树脂组合。脚本不会制作浓缩树脂,世界 Boss 只使用原粹树脂。正式模式会尽量用完所选树脂,可能比当前缺口多刷少量材料。

秘境和世界 Boss 默认启用 BetterGI 的任务级奖励识别,便于在日志中查看每轮掉落。背包仍只在所有任务开始前和结束后各读取一次:背包差值可信时优先采用;结束背包漏识别时,任务奖励作为兜底,两者不会重复相加。最新计划和最近 100 次运行记录保存在脚本目录中;如果订阅了 BetterGI 的 `js.custom` 通知,还可以收到排版后的运行摘要。

材料折算只采用游戏中确定的基础配方,例如 3 个低阶材料合成 1 个高一阶材料。行秋、优菈等角色的合成返还或额外产出属于概率收益,脚本不会提前把它们算进库存,避免实际未触发时少算缺口;如果合成后产生了额外材料,下次背包读取会按实际数量重新计算。

周本材料会计入需求,但 0.9.0 不自动打周本。它们会单独列在“需手动获取”中,也不会挡住 Boss、秘境或路线任务。

## 路线匹配

脚本不附带 AutoPathing 路线,也不会联网下载。常见订阅目录如下:

```text
地方特产/国家/材料名/**/*.json
敌人与魔物/怪物名/**/*.json
```

脚本会按材料名和常见别名查找路线。没有匹配到时,可以在 `data/route-overrides.json` 中指定路径。刷蕈兽孢子时请准备不使用火、雷攻击的队伍,否则掉落会发生变化。

## 当前边界

- 不执行角色或武器升级,不刷摩拉、经验书和元素突破玉,也不执行材料合成。
- Boss、路线和圣遗物填充默认关闭。实际战斗和路线稳定性取决于 BetterGI 版本、队伍与订阅路线质量。
- 背包差值和任务奖励都没有确认到目标材料时,摘要会写“未确认”,不会把“任务已调用”当成“奖励已领取”。ItemV2 图标未匹配时优先检查 BetterGI 版本;数量文字异常时再检查 OCR V6。
- 第一次切换任务队伍前会先传送到七天神像;后续切换失败时只传送并重试一次,避免错误队伍名造成循环。
- 世界 Boss 刷取结束后会再传送一次七天神像,避免树脂不足退出时仍停在已刷新的 Boss 面前;不会在每轮领奖后反复传送。
- 自动读取角色当前档案、当前佩戴武器、复杂树脂调度和周本自动执行不在 0.9.0 中。

## 兼容性与源码

- 最低 BetterGI 版本:`0.63.0`
- 完整源码:[yunxi001/GenshinCultivationPlanner](https://github.com/yunxi001/GenshinCultivationPlanner)
- 许可:完整源码采用 GNU GPL v3;第三方软件和数据说明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)
- 脚本不申请 HTTP 权限,运行时不需要 npm,也不依赖网络服务。

## 反馈

问题、Bug 或功能需求可以发送到 `2739252596@qq.com`。报告 Bug 时,请附上 BetterGI 版本、脚本版本、复现步骤和完整日志;发送前请检查日志中是否有不想公开的信息。
29 changes: 29 additions & 0 deletions repo/js/GenshinCultivationPlanner/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 第三方软件与数据说明

本项目的开发期数据生成工具使用了 [genshin-db](https://github.com/theBowja/genshin-db)。生成后的静态规则数据随脚本发布,运行时不依赖该 npm 包。

## genshin-db

MIT License

Copyright (c) 2020 theBowja

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

《原神》及相关名称、图像与游戏数据的权利归其各自权利人所有。本项目为非官方社区脚本,与米哈游、HoYoverse 无隶属或背书关系。
62 changes: 62 additions & 0 deletions repo/js/GenshinCultivationPlanner/core/artifact-executor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { isBossTaskEnabled } from './boss-executor.js';

/**
* 当天没有培养树脂任务时,构造用户主动启用的圣遗物秘境填充任务。
* 不根据角色自动推断圣遗物套装,避免替用户做配装决策。
*/
export function appendArtifactFallbackTask(plan, settings) {
if (settings.artifactDomainEnabled !== true || !settings.artifactDomainName?.trim()) return plan;
if (plan.todayQueue.some((task) => isExecutableCultivationTask(task, settings))) return plan;
if (plan.todayQueue.some((task) => task.executionType === 'artifactDomain')) return plan;
plan.todayQueue.push({
materialId: `artifact:${settings.artifactDomainName.trim()}`,
materialName: settings.artifactDomainName.trim(),
executionType: 'artifactDomain',
domainName: settings.artifactDomainName.trim(),
materials: [],
shortage: 0,
priority: -1,
status: 'supported',
limited: false,
reason: '当天没有可执行培养树脂任务,使用用户启用的圣遗物秘境填充',
});
return plan;
}

function isExecutableCultivationTask(task, settings) {
if (task.status !== 'supported') return false;
if (task.executionType === 'domain') return true;
if (task.executionType === 'boss') return isBossTaskEnabled(task, settings);
return false;
}

/** 构造圣遗物秘境执行配置,沿用普通秘境树脂规则且默认不自动分解。 */
export function buildArtifactDomainExecutionConfig(task, settings, resinPolicy) {
if (task?.executionType !== 'artifactDomain' || !task.domainName) {
throw new Error('任务不是已配置的圣遗物秘境');
}
if (settings.artifactDomainEnabled !== true) throw new Error('未启用圣遗物秘境填充');
const partyName = settings.artifactTeamName?.trim();
if (!partyName) throw new Error('未配置圣遗物秘境队伍名称,已拒绝执行');
if (resinPolicy.priority.length === 0) throw new Error('未启用任何圣遗物秘境可用树脂类型,已拒绝执行');
const testSingleRun = settings.artifactTestSingleRun === true;
const effectiveResinPolicy = testSingleRun
? {
...resinPolicy,
priority: ['原粹树脂'],
originalResinUseCount: 1,
condensedResinUseCount: 0,
transientResinUseCount: 0,
fragileResinUseCount: 0,
}
: resinPolicy;
return {
domainName: task.domainName,
partyName,
strategyName: settings.artifactCombatStrategyName?.trim() || '',
resinPolicy: effectiveResinPolicy,
autoArtifactSalvage: false,
maxArtifactStar: '4',
testSingleRun,
};
}
26 changes: 26 additions & 0 deletions repo/js/GenshinCultivationPlanner/core/boss-executor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const UNLIMITED_RUN_COUNT = 9999;

/** 构造世界 Boss 执行配置:只耗原粹树脂,不主动使用须臾或脆弱树脂。 */
export function buildBossExecutionConfig(task, settings) {
if (task?.executionType !== 'boss' || !task.bossName) {
throw new Error('世界 Boss 任务缺少 BetterGI 已支持的首领名称');
}
const override = settings.bossOverrides?.[task.bossName] ?? {};
if (override.enabled === false) throw new Error(`世界 Boss“${task.bossName}”已被单独禁用`);
const partyName = override.partyName?.trim() || settings.bossTeamName?.trim();
if (!partyName) throw new Error(`世界 Boss“${task.bossName}”未配置 Boss 队伍`);
return {
bossName: task.bossName,
partyName,
// 留空时由 BetterGI 自动首领配置决定实际战斗策略。
strategyName: override.strategyName?.trim() || settings.bossCombatStrategyName?.trim() || '',
specifyRunCount: settings.bossTestSingleRun === true,
runCount: settings.bossTestSingleRun === true ? 1 : UNLIMITED_RUN_COUNT,
reviveRetryCount: 3,
trackedMaterials: task.materials ?? [{ materialId: task.materialId, materialName: task.materialName, shortage: task.shortage }],
};
}

export function isBossTaskEnabled(task, settings) {
return settings.bossExecutionEnabled === true && settings.bossOverrides?.[task.bossName]?.enabled !== false;
}
21 changes: 21 additions & 0 deletions repo/js/GenshinCultivationPlanner/core/boss-safety.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* 自动首领结束后离开战斗区域,避免树脂不足时停在已刷新的首领面前。
* 清理失败只记录警告,不覆盖原始任务结果或异常。
*/
export async function runBossTaskWithSafeExit({ runTask, teleportToStatue, logger }) {
try {
return await runTask();
} finally {
try {
logger.info('[Boss] 任务调用结束,传送七天神像恢复队伍并离开战斗区域');
await teleportToStatue();
logger.info('[Boss] 已安全离开首领区域');
} catch (error) {
logger.warn('[Boss] 自动首领结束后传送七天神像失败,后续一条龙任务可能受战斗状态影响:{error}', formatError(error));
}
}
}

function formatError(error) {
return error instanceof Error ? error.message : String(error);
}
92 changes: 92 additions & 0 deletions repo/js/GenshinCultivationPlanner/core/calculator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* 将已展开的材料需求合并为总需求。
* 角色、武器等级与天赋的具体展开逻辑将在规则库接入后实现。
*
* @param {Array<{id: string, requirements: Array<{materialId: string, count: number}>}>} targets
* @returns {Map<string, number>}
*/
export function mergeRequirements(targets) {
const totals = new Map();

for (const target of targets) {
for (const requirement of target.requirements ?? []) {
validateCount(requirement.count, `目标 ${target.id} 的材料数量`);
totals.set(
requirement.materialId,
(totals.get(requirement.materialId) ?? 0) + requirement.count,
);
}
}

return totals;
}

/**
* 根据总需求、库存与材料定义生成缺口表。
* 未确认库存不会被当作零库存,也不会被调度器视为已完成。
*
* @param {Map<string, number>} requirements
* @param {Record<string, number>} inventory
* @param {Record<string, object>} materials
* @returns {Array<object>}
*/
export function calculateShortages(requirements, inventory, materials) {
return [...requirements.entries()]
.map(([materialId, required]) => {
const material = materials[materialId];
if (!material) {
return {
materialId,
required,
owned: null,
shortage: null,
status: 'unknown',
reason: '材料规则库缺少定义',
};
}

if (material.status === 'excluded') {
return {
materialId,
required,
owned: inventory[materialId] ?? null,
shortage: null,
status: 'excluded',
reason: material.reason ?? '不在自动刷取范围内',
material,
};
}

const owned = inventory[materialId];
if (owned === undefined || owned === null) {
return {
materialId,
required,
owned: null,
shortage: null,
status: 'unknown',
reason: '尚未确认背包库存',
material,
};
}

validateCount(owned, `材料 ${materialId} 的库存数量`);
const shortage = Math.max(0, required - owned);
return {
materialId,
required,
owned,
shortage,
status: shortage === 0 ? 'complete' : material.status,
reason: shortage === 0 ? '库存已满足需求' : material.reason ?? null,
material,
};
})
.sort((left, right) => left.materialId.localeCompare(right.materialId, 'zh-Hans-CN'));
}

function validateCount(value, label) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${label}必须是非负整数`);
}
}
67 changes: 67 additions & 0 deletions repo/js/GenshinCultivationPlanner/core/crafting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* 收集目标材料及其所有可合成的低阶材料,供背包读取使用。
*/
export function collectCraftingMaterialIds(requirements, recipes) {
const ids = new Set();
const visit = (materialId) => {
const id = String(materialId);
if (ids.has(id)) return;
ids.add(id);
for (const input of recipes[id]?.inputs ?? []) visit(input.id);
};
for (const materialId of requirements.keys()) visit(materialId);
return [...ids];
}

/**
* 将高阶目标按基础配方逐级展开,仅采用确定的 3:1 等消耗。
* 角色天赋带来的概率返还或额外产出不提前计入,实际收益由下一次背包读取反映。
* 仅在整条合成链库存都已确认时使用合成结果;否则保留原始需求,避免凭空假定库存为零。
*/
export function buildCraftingPlan(requirements, inventory, recipes) {
const scanMaterialIds = collectCraftingMaterialIds(requirements, recipes);
const unknownMaterialIds = scanMaterialIds.filter((id) => inventory[id] === undefined || inventory[id] === null);
if (unknownMaterialIds.length > 0) {
return {
scanMaterialIds,
unknownMaterialIds,
craftPlan: [],
farmRequirements: new Map(requirements),
};
}

const needs = new Map(requirements);
const available = new Map(Object.entries(inventory).map(([id, count]) => [String(id), count]));
const craftPlan = [];
const orderedIds = [...scanMaterialIds].sort((left, right) => getRecipeDepth(right, recipes) - getRecipeDepth(left, recipes));

for (const materialId of orderedIds) {
const required = needs.get(materialId) ?? 0;
if (required <= 0) continue;
const owned = available.get(materialId) ?? 0;
const remaining = Math.max(0, required - owned);
needs.set(materialId, 0);
const recipe = recipes[materialId];
if (!recipe || remaining === 0) {
// 基础材料交由缺口计算器扣除库存,避免在此处与后续计算重复扣除。
needs.set(materialId, recipe ? 0 : required);
continue;
}

const craftCount = Math.ceil(remaining / recipe.resultCount);
craftPlan.push({ materialId, craftCount, produced: craftCount * recipe.resultCount, inputs: recipe.inputs });
for (const input of recipe.inputs) {
needs.set(String(input.id), (needs.get(String(input.id)) ?? 0) + input.count * craftCount);
}
}

return { scanMaterialIds, unknownMaterialIds: [], craftPlan, farmRequirements: needs };
}

function getRecipeDepth(materialId, recipes, seen = new Set()) {
if (seen.has(materialId)) return 0;
const recipe = recipes[materialId];
if (!recipe) return 0;
const nextSeen = new Set(seen).add(materialId);
return 1 + Math.max(0, ...recipe.inputs.map((input) => getRecipeDepth(String(input.id), recipes, nextSeen)));
}
Loading