give every recipe one shape: a list of output groups
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced together, or outputs of which exactly one happened -- and every rule over them was written twice, selected by `building == ReprocessingPlant`: sizing a buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an item. RecipeDef now holds output groups, each a weight and a list of items, and a cycle yields exactly one group. One group is the ordinary recipe, so the old two cases are the same shape with one and with several, and all four rules collapse to one expression apiece with no building-type test left. rollReprocessingOutput becomes rollOutputGroup, where a single group returns without drawing or testing eligibility. That early-out is load-bearing twice over. Drawing there would consume entropy for every ordinary recipe and shift every later random outcome; and eligibility must not apply either, since implicit unlocking is demand-derived, so an ordinary recipe's output can be producible while nothing yet calls for it -- testing it would stop the building producing rather than gate a drop. Past the early-out a group is eligible only when all of its items are unlocked, being produced whole. Threat follows the recipe's shape rather than the building, and the per-unit value now divides by the group's amount as well as its odds. That moves no number today: every item resolved through this path has amount 1, which is why the threat expectations are untouched. Config keeps `outputs = [...]` as the single-group form, so only the two reprocessing recipes change shape. The recipe summary gains "/" between groups and keeps "+" within one, which also fixes the plant reading as though a cycle produced all of its items at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
@@ -99,25 +99,21 @@ building = "reprocessing_plant"
|
|||||||
inputs = [{item = "scrap", amount = 4}]
|
inputs = [{item = "scrap", amount = 4}]
|
||||||
duration_seconds = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "iron_ingot"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.3
|
probability = 0.3
|
||||||
|
items = [{item = "iron_ingot", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "copper_ingot"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.3
|
probability = 0.3
|
||||||
|
items = [{item = "copper_ingot", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "silicon"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.2
|
probability = 0.2
|
||||||
|
items = [{item = "silicon", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "voidsteel"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.2
|
probability = 0.2
|
||||||
|
items = [{item = "voidsteel", amount = 1}]
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Tier 2 — early intermediates (clean ratios, ~2:3)
|
# Tier 2 — early intermediates (clean ratios, ~2:3)
|
||||||
|
|||||||
@@ -75,20 +75,17 @@ building = "reprocessing_plant"
|
|||||||
inputs = [{item = "scrap", amount = 5}]
|
inputs = [{item = "scrap", amount = 5}]
|
||||||
duration_seconds = 3.0
|
duration_seconds = 3.0
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "iron_ingot"
|
|
||||||
amount = 2
|
|
||||||
probability = 0.6
|
probability = 0.6
|
||||||
|
items = [{item = "iron_ingot", amount = 2}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "circuit_board"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.3
|
probability = 0.3
|
||||||
|
items = [{item = "circuit_board", amount = 1}]
|
||||||
|
|
||||||
[[recipe.outputs]]
|
[[recipe.output_group]]
|
||||||
item = "advanced_alloy"
|
|
||||||
amount = 1
|
|
||||||
probability = 0.1
|
probability = 0.1
|
||||||
|
items = [{item = "advanced_alloy", amount = 1}]
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
|
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ supporting different fleet doctrines feel structurally different to build.
|
|||||||
**smelting** (same basic materials as ore — the safe, boring option) and
|
**smelting** (same basic materials as ore — the safe, boring option) and
|
||||||
**reprocessing** (probabilistic higher intermediates, including the
|
**reprocessing** (probabilistic higher intermediates, including the
|
||||||
late-game input — the gamble that eventually becomes mandatory).
|
late-game input — the gamble that eventually becomes mandatory).
|
||||||
- The reprocessing output pool renormalizes over implicitly unlocked items
|
- The reprocessing output pool renormalizes over the output groups whose items
|
||||||
(REQ-LOCK-OUTPUT-POOL), so its output quality improves
|
are implicitly unlocked (REQ-LOCK-OUTPUT-POOL), so its output quality improves
|
||||||
automatically as the run progresses. **Rule:** weights are authored for
|
automatically as the run progresses. **Rule:** weights are authored for
|
||||||
the *fully unlocked* pool state; early-game behavior falls out of
|
the *fully unlocked* pool state; early-game behavior falls out of
|
||||||
renormalization for free and needs no separate staging.
|
renormalization for free and needs no separate staging.
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
|
- **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
|
||||||
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||||
- **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
- **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||||
- **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool. (Reprocessing output amounts are 1 in practice, so per-unit division is already implicit in the formula.)
|
- **Item from a recipe that picks between output groups** (an item type produced by no single-group recipe, and so obtainable only where a cycle picks one outcome of several — REQ-MAT-OUTPUT-GROUP): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability / output_amount`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the scrap consumed per cycle, `duration_seconds` is the cycle time, `probability` is the group's normalized weight, and `output_amount` is how many units of the item that group yields. The cycle's cost is divided by the odds of getting the group at all, and then by how many units it yields, so the value is per unit as everywhere else in this requirement.
|
||||||
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
|
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
|
||||||
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
|
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
|
||||||
|
|
||||||
|
|||||||
@@ -32,15 +32,46 @@ std::vector<RecipeOutput> parseRecipeOutputs(const toml::array& arr,
|
|||||||
RecipeOutput out;
|
RecipeOutput out;
|
||||||
out.item = utility::requireString(mt["item"], file, elemPath + ".item");
|
out.item = utility::requireString(mt["item"], file, elemPath + ".item");
|
||||||
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
|
out.amount = static_cast<int>(utility::requireInt(mt["amount"], file, elemPath + ".amount"));
|
||||||
|
result.push_back(std::move(out));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The several-group form: one [[recipe.output_group]] entry per possible result, each
|
||||||
|
// carrying its weight and the items it yields together (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
std::vector<RecipeOutputGroup> parseOutputGroups(const toml::array& arr,
|
||||||
|
const std::string& file,
|
||||||
|
const std::string& path)
|
||||||
|
{
|
||||||
|
std::vector<RecipeOutputGroup> result;
|
||||||
|
result.reserve(arr.size());
|
||||||
|
for (std::size_t i = 0; i < arr.size(); ++i)
|
||||||
|
{
|
||||||
|
const std::string elemPath = path + "[" + std::to_string(i) + "]";
|
||||||
|
const toml::table* t = arr[i].as_table();
|
||||||
|
if (t == nullptr)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath, "not a table");
|
||||||
|
}
|
||||||
|
toml::table& mt = const_cast<toml::table&>(*t);
|
||||||
|
|
||||||
|
RecipeOutputGroup group;
|
||||||
|
const toml::array& items =
|
||||||
|
utility::requireArray(mt["items"], file, elemPath + ".items");
|
||||||
|
group.items = parseRecipeOutputs(items, file, elemPath + ".items");
|
||||||
|
if (group.items.empty())
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath + ".items", "produces nothing");
|
||||||
|
}
|
||||||
if (const std::optional<double> p = mt["probability"].value<double>())
|
if (const std::optional<double> p = mt["probability"].value<double>())
|
||||||
{
|
{
|
||||||
out.probability = *p;
|
group.probability = *p;
|
||||||
}
|
}
|
||||||
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
|
else if (const std::optional<int64_t> p = mt["probability"].value<int64_t>())
|
||||||
{
|
{
|
||||||
out.probability = static_cast<double>(*p);
|
group.probability = static_cast<double>(*p);
|
||||||
}
|
}
|
||||||
result.push_back(std::move(out));
|
result.push_back(std::move(group));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -91,8 +122,34 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
|||||||
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
|
def.inputs = utility::parseIngredients(inputs, file, elemPath + ".inputs");
|
||||||
}
|
}
|
||||||
|
|
||||||
const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
// Either form, never both (REQ-MAT-OUTPUT-GROUP): `outputs` is the single-group
|
||||||
def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
|
// shorthand that all but the reprocessing recipes use, `output_group` the several-
|
||||||
|
// group form. The shorthand carries no weight -- with one group nothing is picked.
|
||||||
|
const bool hasOutputs = mt.contains("outputs");
|
||||||
|
const bool hasGroups = mt.contains("output_group");
|
||||||
|
if (hasOutputs && hasGroups)
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath,
|
||||||
|
"has both 'outputs' and 'output_group'; use one or the other");
|
||||||
|
}
|
||||||
|
if (hasGroups)
|
||||||
|
{
|
||||||
|
const toml::array& groups =
|
||||||
|
utility::requireArray(mt["output_group"], file, elemPath + ".output_group");
|
||||||
|
def.outputGroups = parseOutputGroups(groups, file, elemPath + ".output_group");
|
||||||
|
if (def.outputGroups.empty())
|
||||||
|
{
|
||||||
|
throw utility::makeError(file, elemPath + ".output_group", "is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const toml::array& outputs =
|
||||||
|
utility::requireArray(mt["outputs"], file, elemPath + ".outputs");
|
||||||
|
RecipeOutputGroup group;
|
||||||
|
group.items = parseRecipeOutputs(outputs, file, elemPath + ".outputs");
|
||||||
|
def.outputGroups.push_back(std::move(group));
|
||||||
|
}
|
||||||
|
|
||||||
cfg.recipes.push_back(std::move(def));
|
cfg.recipes.push_back(std::move(def));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -14,14 +15,22 @@ struct RecipeIngredient
|
|||||||
int amount;
|
int amount;
|
||||||
};
|
};
|
||||||
|
|
||||||
// One entry in [[recipe]].outputs. For reprocessing_plant recipes, probability
|
// One item produced by an output group -- amount units of a named item
|
||||||
// is populated and outputs are rolled with weighted pick at cycle start
|
// (REQ-MAT-OUTPUT-GROUP).
|
||||||
// (REQ-BLD-REPROCESSING, REQ-MAT-CYCLE). For other buildings, probability is
|
|
||||||
// std::nullopt and all outputs are produced on every cycle.
|
|
||||||
struct RecipeOutput
|
struct RecipeOutput
|
||||||
{
|
{
|
||||||
std::string item;
|
std::string item;
|
||||||
int amount;
|
int amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One possible result of a production cycle: the items it yields, produced together, and
|
||||||
|
// the weight this group is picked with among the recipe's groups (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
// A recipe with a single group always produces it, so the weight is meaningful only where
|
||||||
|
// there are several -- which is the only difference between what used to be called a
|
||||||
|
// deterministic and a probabilistic recipe.
|
||||||
|
struct RecipeOutputGroup
|
||||||
|
{
|
||||||
|
std::vector<RecipeOutput> items;
|
||||||
std::optional<double> probability;
|
std::optional<double> probability;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,7 +39,8 @@ struct RecipeDef
|
|||||||
std::string id; // Unique recipe id; used by UI for selection.
|
std::string id; // Unique recipe id; used by UI for selection.
|
||||||
BuildingType building; // Which BuildingType can run this recipe.
|
BuildingType building; // Which BuildingType can run this recipe.
|
||||||
std::vector<RecipeIngredient> inputs;
|
std::vector<RecipeIngredient> inputs;
|
||||||
std::vector<RecipeOutput> outputs;
|
// Never empty: one group is the ordinary recipe (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
std::vector<RecipeOutputGroup> outputGroups;
|
||||||
double durationSeconds;
|
double durationSeconds;
|
||||||
// Assembler only. When true, this recipe is available from game start
|
// Assembler only. When true, this recipe is available from game start
|
||||||
// regardless of the implicit item graph — used for base recipes that no
|
// regardless of the implicit item graph — used for base recipes that no
|
||||||
@@ -40,6 +50,38 @@ struct RecipeDef
|
|||||||
bool unlockedAtStart = false;
|
bool unlockedAtStart = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every distinct item any group of this recipe can produce, in config order. Most callers
|
||||||
|
// only want to know what a recipe can make at all -- which items it has buffers for, which
|
||||||
|
// recipes produce an item -- and not which group yields what.
|
||||||
|
inline std::vector<std::string> getProducibleItems(const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
std::vector<std::string> items;
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
if (std::find(items.begin(), items.end(), out.item) == items.end())
|
||||||
|
{
|
||||||
|
items.push_back(out.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when some group of this recipe yields the given item.
|
||||||
|
inline bool producesItem(const RecipeDef& recipe, const std::string& itemId)
|
||||||
|
{
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
if (out.item == itemId) { return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
struct RecipesConfig
|
struct RecipesConfig
|
||||||
{
|
{
|
||||||
std::vector<RecipeDef> recipes;
|
std::vector<RecipeDef> recipes;
|
||||||
|
|||||||
@@ -12,27 +12,27 @@
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
// Folds the output capacities one recipe implies into `caps`: twice each produced
|
// Folds the output capacities one recipe implies into `caps`: twice each produced item's
|
||||||
// item's per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A Reprocessing Plant rolls exactly
|
// per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A cycle yields exactly one output group
|
||||||
// one of its outputs per cycle (REQ-BLD-REPROCESSING), so its per-cycle amount for an
|
// (REQ-MAT-OUTPUT-GROUP), so an item's per-cycle amount is the largest total any single
|
||||||
// item is that one outcome's amount rather than a sum over the entries.
|
// group produces of it -- summed within a group, whose items come together, and taken at
|
||||||
|
// its maximum across groups, of which only one ever happens.
|
||||||
//
|
//
|
||||||
// Where a cap is already present the larger wins, which is how an auto-recipe building
|
// Where a cap is already present the larger wins, which is how a cap unions across the
|
||||||
// unions the recipes of its type -- the same rule its input caps follow.
|
// recipes it could be sized over -- the same rule the input caps follow.
|
||||||
void addOutputCaps(std::map<ItemType, int>& caps, BuildingType type,
|
void addOutputCaps(std::map<ItemType, int>& caps, const RecipeDef& recipe)
|
||||||
const RecipeDef& recipe)
|
|
||||||
{
|
{
|
||||||
std::map<ItemType, int> perCycle;
|
std::map<ItemType, int> perCycle;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
const ItemType item{out.item};
|
std::map<ItemType, int> inGroup;
|
||||||
if (type == BuildingType::ReprocessingPlant)
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
perCycle[item] = std::max(perCycle[item], out.amount);
|
inGroup[ItemType{out.item}] += out.amount;
|
||||||
}
|
}
|
||||||
else
|
for (const std::pair<const ItemType, int>& entry : inGroup)
|
||||||
{
|
{
|
||||||
perCycle[item] += out.amount;
|
perCycle[entry.first] = std::max(perCycle[entry.first], entry.second);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ void initBuffers(Building& b, const RecipeDef& recipe)
|
|||||||
|
|
||||||
b.outputBuffer.items.clear();
|
b.outputBuffer.items.clear();
|
||||||
b.outputBuffer.caps.clear();
|
b.outputBuffer.caps.clear();
|
||||||
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
addOutputCaps(b.outputBuffer.caps, recipe);
|
||||||
}
|
}
|
||||||
|
|
||||||
void initShipyardBuffers(const GameConfig& config, Building& b)
|
void initShipyardBuffers(const GameConfig& config, Building& b)
|
||||||
|
|||||||
@@ -48,29 +48,62 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
|
namespace
|
||||||
{
|
{
|
||||||
std::vector<const RecipeOutput*> eligible;
|
// The items of one group, produced together (REQ-MAT-OUTPUT-GROUP).
|
||||||
std::vector<double> weights;
|
std::vector<Item> itemsOf(const RecipeOutputGroup& group)
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
{
|
||||||
|
std::vector<Item> result;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
if (!m_isItemUnlocked(out.item)) { continue; }
|
Item item;
|
||||||
eligible.push_back(&out);
|
item.type.id = out.item;
|
||||||
weights.push_back(out.probability.value_or(1.0));
|
for (int i = 0; i < out.amount; ++i)
|
||||||
|
{
|
||||||
|
result.push_back(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<Item> BuildingSystem::rollOutputGroup(const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
// One group: nothing to choose, so no weight is read, no draw is made, and no
|
||||||
|
// eligibility is tested (REQ-MAT-OUTPUT-GROUP, REQ-LOCK-OUTPUT-POOL).
|
||||||
|
//
|
||||||
|
// Not drawing matters beyond speed. A draw here would consume entropy for every
|
||||||
|
// ordinary recipe, shifting every later random outcome and invalidating recorded
|
||||||
|
// replays. And eligibility must not apply either: implicit unlocking is derived from
|
||||||
|
// demand, so an ordinary recipe's output can be perfectly producible while nothing
|
||||||
|
// yet calls for it -- testing it here would stop the building producing at all.
|
||||||
|
if (recipe.outputGroups.size() == 1)
|
||||||
|
{
|
||||||
|
return itemsOf(recipe.outputGroups.front());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several groups: only those whose items are all unlocked can be picked, and a group
|
||||||
|
// holding any locked item is dropped whole, since its items come together
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL). Weights are renormalized over what is left by
|
||||||
|
// discrete_distribution.
|
||||||
|
std::vector<const RecipeOutputGroup*> eligible;
|
||||||
|
std::vector<double> weights;
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
bool allUnlocked = true;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
if (!m_isItemUnlocked(out.item)) { allUnlocked = false; break; }
|
||||||
|
}
|
||||||
|
if (!allUnlocked) { continue; }
|
||||||
|
eligible.push_back(&group);
|
||||||
|
weights.push_back(group.probability.value_or(1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligible.empty()) { return {}; }
|
if (eligible.empty()) { return {}; }
|
||||||
|
|
||||||
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
||||||
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
|
return itemsOf(*eligible[static_cast<std::size_t>(dist(m_rng))]);
|
||||||
std::vector<Item> result;
|
|
||||||
Item item;
|
|
||||||
item.type.id = chosen.item;
|
|
||||||
for (int i = 0; i < chosen.amount; ++i)
|
|
||||||
{
|
|
||||||
result.push_back(item);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -572,25 +605,11 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Determine chosen outputs (roll for reprocessing).
|
// 3. Settle what this cycle produces: its one output group, picked by weight only
|
||||||
std::vector<Item> chosen;
|
// where the recipe has several (REQ-MAT-OUTPUT-GROUP). Empty means every group
|
||||||
if (building.type == BuildingType::ReprocessingPlant)
|
// was ineligible, so there is nothing to run.
|
||||||
{
|
std::vector<Item> chosen = rollOutputGroup(*recipe);
|
||||||
chosen = rollReprocessingOutput(*recipe);
|
if (chosen.empty()) { continue; }
|
||||||
if (chosen.empty()) { continue; }
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (const RecipeOutput& out : recipe->outputs)
|
|
||||||
{
|
|
||||||
Item item;
|
|
||||||
item.type.id = out.item;
|
|
||||||
for (int i = 0; i < out.amount; ++i)
|
|
||||||
{
|
|
||||||
chosen.push_back(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Consume inputs and start cycle.
|
// 4. Consume inputs and start cycle.
|
||||||
for (const RecipeIngredient& ing : recipe->inputs)
|
for (const RecipeIngredient& ing : recipe->inputs)
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ private:
|
|||||||
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
||||||
// the status light (REQ-UI-STATUS-LIGHT).
|
// the status light (REQ-UI-STATUS-LIGHT).
|
||||||
|
|
||||||
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
|
// What one cycle of this recipe produces: the items of its one output group
|
||||||
// caps span the union of every recipe of the building's type; no player
|
// (REQ-MAT-OUTPUT-GROUP). Where the recipe has several, one is picked by weight from
|
||||||
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
// those currently eligible (REQ-LOCK-OUTPUT-POOL) and the result is empty if none is;
|
||||||
// Core input-edge scan shared by operational buildings and construction sites.
|
// where it has one, that group is returned with no draw and no eligibility test.
|
||||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
std::vector<Item> rollOutputGroup(const RecipeDef& recipe);
|
||||||
|
|
||||||
const GameConfig& m_config;
|
const GameConfig& m_config;
|
||||||
|
|
||||||
|
|||||||
@@ -154,33 +154,22 @@ bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
|
|||||||
|
|
||||||
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
||||||
{
|
{
|
||||||
if (b.type == BuildingType::ReprocessingPlant)
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
// One roll yields one of these, so each is measured on its own -- but all of them
|
// A group's items come together, so an item listed twice in one is produced in the
|
||||||
// have to fit, since which one it will be is not known yet.
|
// sum of those amounts and judged once, as a sum.
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
std::map<ItemType, int> perCycle;
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
{
|
{
|
||||||
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
|
perCycle[ItemType{out.item}] += out.amount;
|
||||||
|
}
|
||||||
|
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||||
|
{
|
||||||
|
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A deterministic cycle deposits all of its outputs together. An item listed more
|
|
||||||
// than once is produced in the sum of those amounts, so it is judged once, as a sum.
|
|
||||||
std::map<ItemType, int> perCycle;
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
|
||||||
{
|
|
||||||
perCycle[ItemType{out.item}] += out.amount;
|
|
||||||
}
|
|
||||||
for (const std::pair<const ItemType, int>& entry : perCycle)
|
|
||||||
{
|
|
||||||
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,11 @@ bool hasInputsToStart(const GameConfig& config, const Building& b);
|
|||||||
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
|
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
|
||||||
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
|
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
|
||||||
|
|
||||||
// True when every output a cycle of this recipe could produce would fit -- the gate a
|
// True when every one of the recipe's output groups would fit -- the gate a cycle has to
|
||||||
// cycle has to pass before it may start (REQ-MAT-CYCLE). For a deterministic recipe that
|
// pass before it may start (REQ-MAT-CYCLE, REQ-MAT-OUTPUT-GROUP). With a single group that
|
||||||
// is its own outputs. A Reprocessing Plant rolls one of its outputs per cycle
|
// is simply that group. With several the pick is committed the moment the cycle starts, so
|
||||||
// (REQ-BLD-REPROCESSING), so each possibility is judged on its own and all must fit: the
|
// every outcome must fit: testing all of them rather than the picked one is what keeps a
|
||||||
// roll is committed the moment the cycle starts, and testing every outcome rather than
|
// stalled output belt from biasing the distribution.
|
||||||
// the rolled one is what keeps a stalled output belt from biasing the distribution.
|
|
||||||
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
|
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
|
||||||
|
|
||||||
// True when a production cycle could actually start right now: some candidate recipe
|
// True when a production cycle could actually start right now: some candidate recipe
|
||||||
|
|||||||
@@ -97,35 +97,42 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
|||||||
// values are raw from config; we normalize them per-recipe below).
|
// values are raw from config; we normalize them per-recipe below).
|
||||||
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
|
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
|
||||||
|
|
||||||
|
// What decides which model an item's threat follows is the recipe's shape, not the
|
||||||
|
// building running it (REQ-MAT-OUTPUT-GROUP): a recipe with several groups yields one
|
||||||
|
// of them by chance, so its items cost the cycle divided by their odds; a recipe with
|
||||||
|
// one group yields it every cycle, so its items cost the cycle outright.
|
||||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||||
{
|
{
|
||||||
if (recipe.building == BuildingType::ReprocessingPlant)
|
if (recipe.outputGroups.size() > 1)
|
||||||
{
|
{
|
||||||
// Compute the total weight across all outputs of this reprocessing recipe
|
// Total weight across the groups, so each group's probability normalizes.
|
||||||
// so we can normalize each output's probability.
|
|
||||||
double totalWeight = 0.0;
|
double totalWeight = 0.0;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
totalWeight += out.probability.value_or(1.0);
|
totalWeight += group.probability.value_or(1.0);
|
||||||
}
|
}
|
||||||
if (totalWeight <= 0.0)
|
if (totalWeight <= 0.0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
{
|
{
|
||||||
RecipeRef ref;
|
const double probability = group.probability.value_or(1.0) / totalWeight;
|
||||||
ref.recipe = &recipe;
|
for (const RecipeOutput& out : group.items)
|
||||||
ref.outputItem = out.item;
|
{
|
||||||
ref.outputAmount = out.amount;
|
RecipeRef ref;
|
||||||
ref.probability = out.probability.value_or(1.0) / totalWeight;
|
ref.recipe = &recipe;
|
||||||
reprocessingRecipes[out.item].push_back(ref);
|
ref.outputItem = out.item;
|
||||||
|
ref.outputAmount = out.amount;
|
||||||
|
ref.probability = probability;
|
||||||
|
reprocessingRecipes[out.item].push_back(ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check whether this non-reprocessing recipe consumes scrap.
|
// Check whether this single-group recipe consumes scrap.
|
||||||
bool consumesScrap = false;
|
bool consumesScrap = false;
|
||||||
for (const RecipeIngredient& input : recipe.inputs)
|
for (const RecipeIngredient& input : recipe.inputs)
|
||||||
{
|
{
|
||||||
@@ -136,7 +143,7 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const RecipeOutput& out : recipe.outputGroups.front().items)
|
||||||
{
|
{
|
||||||
if (!consumesScrap)
|
if (!consumesScrap)
|
||||||
{
|
{
|
||||||
@@ -288,8 +295,13 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
|||||||
scrapPerCycle += input.amount;
|
scrapPerCycle += input.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per unit: the cycle's cost, divided by the odds of getting this group
|
||||||
|
// at all and then by how many units that group yields (REQ-THREAT-ITEM).
|
||||||
|
const double perUnitDivisor =
|
||||||
|
ref.probability * static_cast<double>(ref.outputAmount);
|
||||||
|
if (perUnitDivisor <= 0.0) { continue; }
|
||||||
double threat = (table.scrapThreat * scrapPerCycle
|
double threat = (table.scrapThreat * scrapPerCycle
|
||||||
+ ref.recipe->durationSeconds) / ref.probability;
|
+ ref.recipe->durationSeconds) / perUnitDivisor;
|
||||||
|
|
||||||
std::map<std::string, double>::iterator existing = resolved.find(item);
|
std::map<std::string, double>::iterator existing = resolved.find(item);
|
||||||
if (existing == resolved.end() || threat > existing->second)
|
if (existing == resolved.end() || threat > existing->second)
|
||||||
|
|||||||
@@ -244,9 +244,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
|
|||||||
if (def.building == BuildingType::Assembler
|
if (def.building == BuildingType::Assembler
|
||||||
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
|
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
|
||||||
{
|
{
|
||||||
for (const RecipeOutput& out : def.outputs)
|
for (const std::string& item : getProducibleItems(def))
|
||||||
{
|
{
|
||||||
result.itemIds.insert(out.item);
|
result.itemIds.insert(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,9 +272,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bool producesUnlocked = false;
|
bool producesUnlocked = false;
|
||||||
for (const RecipeOutput& out : recipe.outputs)
|
for (const std::string& item : getProducibleItems(recipe))
|
||||||
{
|
{
|
||||||
if (result.itemIds.count(out.item) > 0)
|
if (result.itemIds.count(item) > 0)
|
||||||
{
|
{
|
||||||
producesUnlocked = true;
|
producesUnlocked = true;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -104,14 +104,20 @@ struct PlacementFixture
|
|||||||
BuildingSystem bs;
|
BuildingSystem bs;
|
||||||
|
|
||||||
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
|
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
|
||||||
// needs items to arrive immediately.
|
// needs items to arrive immediately. Everything counts as unlocked unless the test
|
||||||
explicit PlacementFixture(std::optional<double> beltSpeed_tps = std::nullopt)
|
// says otherwise, which is what the output-group eligibility rule turns on
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL).
|
||||||
|
explicit PlacementFixture(
|
||||||
|
std::optional<double> beltSpeed_tps = std::nullopt,
|
||||||
|
std::function<bool(const std::string&)> isItemUnlocked = nullptr)
|
||||||
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
|
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
|
||||||
, bs(cfg, belts,
|
, bs(cfg, belts,
|
||||||
[this]() { return nextBuildingId++; },
|
[this]() { return nextBuildingId++; },
|
||||||
[this](int n) { stock += n; },
|
[this](int n) { stock += n; },
|
||||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||||
[](const std::string&) -> bool { return true; },
|
isItemUnlocked ? std::move(isItemUnlocked)
|
||||||
|
: std::function<bool(const std::string&)>(
|
||||||
|
[](const std::string&) { return true; }),
|
||||||
rng)
|
rng)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -948,6 +954,135 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
|
|||||||
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
|
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("ConfigLoader: the outputs shorthand and one output_group load alike",
|
||||||
|
"[config]")
|
||||||
|
{
|
||||||
|
// `outputs = [...]` is exactly one group holding those items (REQ-MAT-OUTPUT-GROUP),
|
||||||
|
// so a recipe written either way behaves identically.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const RecipeDef* shorthand =
|
||||||
|
f.cfg.recipes.findRecipeDef("iron_ingot", BuildingType::Smelter);
|
||||||
|
REQUIRE(shorthand != nullptr);
|
||||||
|
REQUIRE(shorthand->outputGroups.size() == 1);
|
||||||
|
REQUIRE_FALSE(shorthand->outputGroups.front().probability.has_value());
|
||||||
|
|
||||||
|
// Sizing and the cycle gate read it as one group like any other.
|
||||||
|
Building smelter; smelter.type = BuildingType::Smelter;
|
||||||
|
smelter.recipeId = shorthand->id;
|
||||||
|
initBuffers(smelter, *shorthand);
|
||||||
|
REQUIRE(smelter.outputBuffer.caps.at(ItemType{"iron_ingot"})
|
||||||
|
== 2 * shorthand->outputGroups.front().items.front().amount);
|
||||||
|
REQUIRE(recipeOutputsFit(smelter, *shorthand));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a single-group recipe consumes no randomness", "[building]")
|
||||||
|
{
|
||||||
|
// Nothing is picked where there is one group, so no draw is made (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
// Drawing here would consume entropy for every ordinary recipe and shift every later
|
||||||
|
// random outcome, which is what the two fixtures below would expose: they differ only
|
||||||
|
// in how far their generators have been advanced.
|
||||||
|
PlacementFixture quiet;
|
||||||
|
PlacementFixture advanced;
|
||||||
|
for (int i = 0; i < 50; ++i) { (void)advanced.rng(); }
|
||||||
|
|
||||||
|
Tick tickA = 0;
|
||||||
|
Tick tickB = 0;
|
||||||
|
const BuildingId a =
|
||||||
|
quiet.bs.place(quiet.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
const BuildingId b =
|
||||||
|
advanced.bs.place(advanced.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
quiet.bs.setRecipe(quiet.state, a, "mine_iron_ore");
|
||||||
|
advanced.bs.setRecipe(advanced.state, b, "mine_iron_ore");
|
||||||
|
|
||||||
|
const int ticks = static_cast<int>(secondsToTicks(10.0)) + 40;
|
||||||
|
runTicks(quiet.bs, quiet.cfg, quiet.state, quiet.belts, quiet.stock, ticks, tickA);
|
||||||
|
runTicks(advanced.bs, advanced.cfg, advanced.state, advanced.belts, advanced.stock,
|
||||||
|
ticks, tickB);
|
||||||
|
|
||||||
|
const Building* minerA = findBuilding(quiet.state, a);
|
||||||
|
const Building* minerB = findBuilding(advanced.state, b);
|
||||||
|
REQUIRE(minerA != nullptr);
|
||||||
|
REQUIRE(minerB != nullptr);
|
||||||
|
REQUIRE(minerA->getOutputItemCount() > 0);
|
||||||
|
REQUIRE(minerA->getOutputItemCount() == minerB->getOutputItemCount());
|
||||||
|
REQUIRE(minerA->production.has_value() == minerB->production.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[building]")
|
||||||
|
{
|
||||||
|
// A group yields all of its items at once (REQ-MAT-OUTPUT-GROUP), so each is buffered
|
||||||
|
// at twice its own amount and the cycle needs room for all of them at once. No config
|
||||||
|
// recipe has a multi-item group yet, so one is built here.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
RecipeDef recipe;
|
||||||
|
recipe.id = "multi_item_group";
|
||||||
|
recipe.building = BuildingType::Assembler;
|
||||||
|
recipe.durationSeconds = 1.0;
|
||||||
|
recipe.inputs.push_back(RecipeIngredient{"iron_ore", 1});
|
||||||
|
RecipeOutputGroup group;
|
||||||
|
group.items.push_back(RecipeOutput{"iron_ingot", 2});
|
||||||
|
group.items.push_back(RecipeOutput{"silicon", 1});
|
||||||
|
recipe.outputGroups.push_back(group);
|
||||||
|
|
||||||
|
Building assembler; assembler.type = BuildingType::Assembler;
|
||||||
|
initBuffers(assembler, recipe);
|
||||||
|
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
|
||||||
|
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"silicon"}) == 2);
|
||||||
|
|
||||||
|
// Both fit while both have room.
|
||||||
|
REQUIRE(recipeOutputsFit(assembler, recipe));
|
||||||
|
|
||||||
|
// One item of the group short of room blocks the whole cycle, even though the other
|
||||||
|
// still has plenty: the group cannot be produced in halves.
|
||||||
|
assembler.outputBuffer.items.push_back(makeItem("silicon"));
|
||||||
|
assembler.outputBuffer.items.push_back(makeItem("silicon"));
|
||||||
|
REQUIRE(outputBufferHasRoom(assembler, ItemType{"iron_ingot"}, 2));
|
||||||
|
REQUIRE_FALSE(outputBufferHasRoom(assembler, ItemType{"silicon"}, 1));
|
||||||
|
REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
|
||||||
|
{
|
||||||
|
// A group's items come together, so a group holding any locked item is dropped whole
|
||||||
|
// (REQ-LOCK-OUTPUT-POOL). Here only circuit_board is unlocked, so every cycle must
|
||||||
|
// yield that group however the weights are stacked -- iron_ingot's group carries the
|
||||||
|
// largest weight of the three and would dominate were the filter not applied.
|
||||||
|
PlacementFixture f(std::nullopt,
|
||||||
|
[](const std::string& id) { return id == "circuit_board"; });
|
||||||
|
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
|
||||||
|
QPoint(0, 0), Rotation::East, 0).value();
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||||
|
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
|
||||||
|
|
||||||
|
// Run many cycles, refilling the scrap and draining the output each time so the plant
|
||||||
|
// never stalls. A broken filter would show iron_ingot within a few rounds.
|
||||||
|
int produced = 0;
|
||||||
|
for (int round = 0; round < 20; ++round)
|
||||||
|
{
|
||||||
|
f.bs.forEachBuilding(f.state, [](Building& building) {
|
||||||
|
if (building.type != BuildingType::ReprocessingPlant) { return; }
|
||||||
|
building.inputBuffer.counts[ItemType{"scrap"}] =
|
||||||
|
building.inputBuffer.caps.at(ItemType{"scrap"});
|
||||||
|
building.outputBuffer.items.clear();
|
||||||
|
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||||
|
});
|
||||||
|
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
|
||||||
|
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
|
||||||
|
|
||||||
|
for (const Item& item : outputSideItems(*findBuilding(f.state, id)))
|
||||||
|
{
|
||||||
|
CHECK(item.type.id == "circuit_board");
|
||||||
|
++produced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
REQUIRE(produced > 0);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
|
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
|
||||||
"[building]")
|
"[building]")
|
||||||
{
|
{
|
||||||
@@ -1855,9 +1990,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
|
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
|
||||||
initBuffers(assembler, *assemblerRecipe);
|
initBuffers(assembler, *assemblerRecipe);
|
||||||
|
|
||||||
const std::string outputItemId = assemblerRecipe->outputs.front().item;
|
const std::string outputItemId =
|
||||||
|
assemblerRecipe->outputGroups.front().items.front().item;
|
||||||
int cycleOutput = 0;
|
int cycleOutput = 0;
|
||||||
for (const RecipeOutput& out : assemblerRecipe->outputs)
|
for (const RecipeOutput& out : assemblerRecipe->outputGroups.front().items)
|
||||||
{
|
{
|
||||||
cycleOutput += out.amount;
|
cycleOutput += out.amount;
|
||||||
}
|
}
|
||||||
@@ -1896,17 +2032,21 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
{
|
{
|
||||||
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
|
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
|
||||||
int total = 0;
|
int total = 0;
|
||||||
for (const RecipeOutput& out : r.outputs) { total += out.amount; }
|
for (const RecipeOutput& out : r.outputGroups.front().items)
|
||||||
|
{
|
||||||
|
total += out.amount;
|
||||||
|
}
|
||||||
if (total >= 2) { multiOutputRecipe = &r; break; }
|
if (total >= 2) { multiOutputRecipe = &r; break; }
|
||||||
}
|
}
|
||||||
REQUIRE(multiOutputRecipe != nullptr);
|
REQUIRE(multiOutputRecipe != nullptr);
|
||||||
|
|
||||||
int cycleOutput = 0;
|
int cycleOutput = 0;
|
||||||
for (const RecipeOutput& out : multiOutputRecipe->outputs)
|
for (const RecipeOutput& out : multiOutputRecipe->outputGroups.front().items)
|
||||||
{
|
{
|
||||||
cycleOutput += out.amount;
|
cycleOutput += out.amount;
|
||||||
}
|
}
|
||||||
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
|
const std::string outputItemId =
|
||||||
|
multiOutputRecipe->outputGroups.front().items.front().item;
|
||||||
|
|
||||||
Building assembler; assembler.type = BuildingType::Assembler;
|
Building assembler; assembler.type = BuildingType::Assembler;
|
||||||
assembler.recipeId = multiOutputRecipe->id;
|
assembler.recipeId = multiOutputRecipe->id;
|
||||||
@@ -1945,7 +2085,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
REQUIRE(reprocessingRecipe != nullptr);
|
REQUIRE(reprocessingRecipe != nullptr);
|
||||||
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
|
REQUIRE(reprocessingRecipe->outputGroups.size() >= 2);
|
||||||
|
|
||||||
Building plant; plant.type = BuildingType::ReprocessingPlant;
|
Building plant; plant.type = BuildingType::ReprocessingPlant;
|
||||||
plant.recipeId = reprocessingRecipe->id;
|
plant.recipeId = reprocessingRecipe->id;
|
||||||
@@ -1960,8 +2100,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
|
|
||||||
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
|
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
|
||||||
// the other outcomes still have room.
|
// the other outcomes still have room.
|
||||||
const std::string firstItemId = reprocessingRecipe->outputs.front().item;
|
const std::string firstItemId =
|
||||||
const std::string lastItemId = reprocessingRecipe->outputs.back().item;
|
reprocessingRecipe->outputGroups.front().items.front().item;
|
||||||
|
const std::string lastItemId =
|
||||||
|
reprocessingRecipe->outputGroups.back().items.front().item;
|
||||||
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
|
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
|
||||||
{
|
{
|
||||||
plant.outputBuffer.items.push_back(makeItem(firstItemId));
|
plant.outputBuffer.items.push_back(makeItem(firstItemId));
|
||||||
|
|||||||
@@ -130,22 +130,26 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
|||||||
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
|
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
|
||||||
REQUIRE_FALSE(minerIt->tooltip.has_value());
|
REQUIRE_FALSE(minerIt->tooltip.has_value());
|
||||||
|
|
||||||
// recipes.toml — reprocessing cycle has three weighted outputs.
|
// recipes.toml -- the reprocessing cycle is written as three weighted output groups,
|
||||||
|
// each yielding one item (REQ-MAT-OUTPUT-GROUP).
|
||||||
const auto reproIt = std::find_if(
|
const auto reproIt = std::find_if(
|
||||||
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
|
[](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
|
||||||
REQUIRE(reproIt != cfg.recipes.recipes.end());
|
REQUIRE(reproIt != cfg.recipes.recipes.end());
|
||||||
REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
|
REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
|
||||||
REQUIRE(reproIt->outputs.size() == 3);
|
REQUIRE(reproIt->outputGroups.size() == 3);
|
||||||
REQUIRE(reproIt->outputs[0].probability.has_value());
|
REQUIRE(reproIt->outputGroups[0].probability.has_value());
|
||||||
|
REQUIRE(reproIt->outputGroups[0].items.size() == 1);
|
||||||
|
|
||||||
// Non-reprocessing recipes don't carry probability.
|
// The `outputs = [...]` shorthand loads as one group carrying no weight: with a single
|
||||||
|
// group nothing is picked, so there is nothing to weigh.
|
||||||
const auto ironIngotIt = std::find_if(
|
const auto ironIngotIt = std::find_if(
|
||||||
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "iron_ingot"; });
|
[](const RecipeDef& r) { return r.id == "iron_ingot"; });
|
||||||
REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
|
REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
|
||||||
REQUIRE(ironIngotIt->outputs.size() == 1);
|
REQUIRE(ironIngotIt->outputGroups.size() == 1);
|
||||||
REQUIRE_FALSE(ironIngotIt->outputs[0].probability.has_value());
|
REQUIRE(ironIngotIt->outputGroups[0].items.size() == 1);
|
||||||
|
REQUIRE_FALSE(ironIngotIt->outputGroups[0].probability.has_value());
|
||||||
|
|
||||||
// ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
|
// ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
|
||||||
const auto interceptorIt = std::find_if(
|
const auto interceptorIt = std::find_if(
|
||||||
|
|||||||
@@ -7,18 +7,6 @@
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
bool producesItem(const RecipeDef& recipe, const std::string& itemId)
|
|
||||||
{
|
|
||||||
for (const RecipeOutput& output : recipe.outputs)
|
|
||||||
{
|
|
||||||
if (output.item == itemId)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isAvailable(const RecipeDef& recipe, const Simulation& sim)
|
bool isAvailable(const RecipeDef& recipe, const Simulation& sim)
|
||||||
{
|
{
|
||||||
if (recipe.building == BuildingType::Miner
|
if (recipe.building == BuildingType::Miner
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ void clearRow(QHBoxLayout* layout)
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<std::vector<RecipeLineRow::Amount>> RecipeLineRow::toOutputGroups(
|
||||||
|
const RecipeDef& recipe)
|
||||||
|
{
|
||||||
|
std::vector<std::vector<Amount>> groups;
|
||||||
|
groups.reserve(recipe.outputGroups.size());
|
||||||
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||||
|
{
|
||||||
|
std::vector<Amount> amounts;
|
||||||
|
amounts.reserve(group.items.size());
|
||||||
|
for (const RecipeOutput& out : group.items)
|
||||||
|
{
|
||||||
|
amounts.push_back(Amount{ out.item, out.amount });
|
||||||
|
}
|
||||||
|
groups.push_back(std::move(amounts));
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
|
RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
|
||||||
QWidget* parent)
|
QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
@@ -143,12 +161,21 @@ void RecipeLineRow::rebuild(const Spec& spec)
|
|||||||
|
|
||||||
// Second line: what the cycle costs, makes and takes.
|
// Second line: what the cycle costs, makes and takes.
|
||||||
addAmounts(spec.inputs);
|
addAmounts(spec.inputs);
|
||||||
if (!spec.inputs.empty() && !spec.outputs.empty())
|
if (!spec.inputs.empty() && !spec.outputGroups.empty())
|
||||||
{
|
{
|
||||||
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
|
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
|
||||||
addAndShow(m_amountsLayout, new QLabel(QString(rightArrow), m_amountsRow));
|
addAndShow(m_amountsLayout, new QLabel(QString(rightArrow), m_amountsRow));
|
||||||
}
|
}
|
||||||
addAmounts(spec.outputs);
|
for (std::size_t i = 0; i < spec.outputGroups.size(); ++i)
|
||||||
|
{
|
||||||
|
// Between one group and the next, so alternatives read as a choice rather than as
|
||||||
|
// one combined yield -- which is what a run of `+` would say (REQ-UI-RECIPE-SUMMARY).
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
addAndShow(m_amountsLayout, new QLabel(QStringLiteral("/"), m_amountsRow));
|
||||||
|
}
|
||||||
|
addAmounts(spec.outputGroups[i]);
|
||||||
|
}
|
||||||
|
|
||||||
if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0)
|
if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
|
#include "RecipesConfig.h"
|
||||||
|
|
||||||
class BuildingIconCache;
|
class BuildingIconCache;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
@@ -59,9 +60,12 @@ public:
|
|||||||
// name is the caption of the widget around this one instead.
|
// name is the caption of the widget around this one instead.
|
||||||
QString name;
|
QString name;
|
||||||
std::vector<Amount> inputs;
|
std::vector<Amount> inputs;
|
||||||
// Empty for a line that produces no item of its own: a ship schematic, or a
|
// What the recipe produces, one entry per output group (REQ-MAT-OUTPUT-GROUP).
|
||||||
// module's price. No arrow is drawn then.
|
// The items of a group are drawn joined by `+` because they come together, and the
|
||||||
std::vector<Amount> outputs;
|
// groups joined by `/` because only one of them happens. Empty for a line that
|
||||||
|
// produces no item of its own: a ship schematic, or a module's price. No arrow is
|
||||||
|
// drawn then.
|
||||||
|
std::vector<std::vector<Amount>> outputGroups;
|
||||||
std::optional<double> durationSeconds;
|
std::optional<double> durationSeconds;
|
||||||
// True where the time is added to something else rather than being a cycle of
|
// True where the time is added to something else rather than being a cycle of
|
||||||
// its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG).
|
// its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG).
|
||||||
@@ -70,14 +74,19 @@ public:
|
|||||||
bool operator==(const Spec& other) const
|
bool operator==(const Spec& other) const
|
||||||
{
|
{
|
||||||
return building == other.building && name == other.name
|
return building == other.building && name == other.name
|
||||||
&& inputs == other.inputs && outputs == other.outputs
|
&& inputs == other.inputs && outputGroups == other.outputGroups
|
||||||
&& durationSeconds == other.durationSeconds
|
&& durationSeconds == other.durationSeconds
|
||||||
&& durationIsAddition == other.durationIsAddition;
|
&& durationIsAddition == other.durationIsAddition;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isEmpty() const { return inputs.empty() && outputs.empty(); }
|
bool isEmpty() const { return inputs.empty() && outputGroups.empty(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A recipe's output groups as this row states them (REQ-MAT-OUTPUT-GROUP). Shared, so
|
||||||
|
// that every place drawing a recipe -- the summary, the option buttons, the tooltip
|
||||||
|
// lines -- converts it the same way rather than each keeping its own copy.
|
||||||
|
static std::vector<std::vector<Amount>> toOutputGroups(const RecipeDef& recipe);
|
||||||
|
|
||||||
// Both caches may be null, which leaves the icons off: an item with no square and
|
// Both caches may be null, which leaves the icons off: an item with no square and
|
||||||
// no icon falls back to its id, and a building with no chip to its name alone.
|
// no icon falls back to its id, and a building with no chip to its name alone.
|
||||||
// Neither is owned.
|
// Neither is owned.
|
||||||
|
|||||||
@@ -33,16 +33,6 @@ std::vector<RecipeLineRow::Amount> toAmounts(
|
|||||||
return amounts;
|
return amounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
|
|
||||||
{
|
|
||||||
std::vector<RecipeLineRow::Amount> amounts;
|
|
||||||
amounts.reserve(outputs.size());
|
|
||||||
for (const RecipeOutput& output : outputs)
|
|
||||||
{
|
|
||||||
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
|
|
||||||
}
|
|
||||||
return amounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -90,7 +80,7 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
|||||||
|
|
||||||
RecipeLineRow::Spec line;
|
RecipeLineRow::Spec line;
|
||||||
line.inputs = toAmounts(recipe.inputs);
|
line.inputs = toAmounts(recipe.inputs);
|
||||||
line.outputs = toAmounts(recipe.outputs);
|
line.outputGroups = RecipeLineRow::toOutputGroups(recipe);
|
||||||
line.durationSeconds = recipe.durationSeconds;
|
line.durationSeconds = recipe.durationSeconds;
|
||||||
|
|
||||||
options.push_back({recipe.id,
|
options.push_back({recipe.id,
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ std::vector<RecipeLineRow::Amount> toAmounts(
|
|||||||
return amounts;
|
return amounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
|
|
||||||
{
|
|
||||||
std::vector<RecipeLineRow::Amount> amounts;
|
|
||||||
amounts.reserve(outputs.size());
|
|
||||||
for (const RecipeOutput& output : outputs)
|
|
||||||
{
|
|
||||||
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
|
|
||||||
}
|
|
||||||
return amounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString grantKindLabel(SchematicType type)
|
QString grantKindLabel(SchematicType type)
|
||||||
{
|
{
|
||||||
@@ -147,7 +137,7 @@ SchematicChoiceDialog::SchematicChoiceDialog(
|
|||||||
spec.building = def->building;
|
spec.building = def->building;
|
||||||
spec.name = QString::fromStdString(toDisplayName(def->id));
|
spec.name = QString::fromStdString(toDisplayName(def->id));
|
||||||
spec.inputs = toAmounts(def->inputs);
|
spec.inputs = toAmounts(def->inputs);
|
||||||
spec.outputs = toAmounts(def->outputs);
|
spec.outputGroups = RecipeLineRow::toOutputGroups(*def);
|
||||||
spec.durationSeconds = def->durationSeconds;
|
spec.durationSeconds = def->durationSeconds;
|
||||||
|
|
||||||
RecipeLineRow* line =
|
RecipeLineRow* line =
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ void BufferedBuildingContent::refreshConfiguration()
|
|||||||
const CycleInfo cycle = getCycleInfo(target);
|
const CycleInfo cycle = getCycleInfo(target);
|
||||||
RecipeLineRow::Spec summary;
|
RecipeLineRow::Spec summary;
|
||||||
summary.inputs = toAmounts(cycle.perCycleInputs);
|
summary.inputs = toAmounts(cycle.perCycleInputs);
|
||||||
summary.outputs = toAmounts(cycle.perCycleOutputs);
|
summary.outputGroups = cycle.perCycleOutputGroups;
|
||||||
summary.durationSeconds = cycle.durationSeconds;
|
summary.durationSeconds = cycle.durationSeconds;
|
||||||
m_recipeSummary->setLine(summary);
|
m_recipeSummary->setLine(summary);
|
||||||
|
|
||||||
@@ -192,9 +192,20 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A chip stands for a buffer, and a buffer exists for every item any group can
|
||||||
|
// produce (REQ-MAT-OUTPUT-BUFFER), so the groups are flattened here.
|
||||||
|
std::map<std::string, int> producible;
|
||||||
|
for (const std::vector<RecipeLineRow::Amount>& group : cycle.perCycleOutputGroups)
|
||||||
|
{
|
||||||
|
for (const RecipeLineRow::Amount& amount : group)
|
||||||
|
{
|
||||||
|
producible[amount.itemId] = std::max(producible[amount.itemId], amount.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<ItemChipRow::Entry> entries;
|
std::vector<ItemChipRow::Entry> entries;
|
||||||
for (const std::string& itemId :
|
for (const std::string& itemId :
|
||||||
collectItemIds(buffered, cycle.perCycleOutputs, cycle.handledOutputs))
|
collectItemIds(buffered, producible, cycle.handledOutputs))
|
||||||
{
|
{
|
||||||
if (!getContext().sim->isItemUnlocked(itemId)) { continue; }
|
if (!getContext().sim->isItemUnlocked(itemId)) { continue; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
#include "ItemChipRow.h"
|
#include "ItemChipRow.h"
|
||||||
|
#include "RecipeLineRow.h"
|
||||||
#include "SelectionContent.h"
|
#include "SelectionContent.h"
|
||||||
|
|
||||||
struct Building;
|
struct Building;
|
||||||
@@ -31,7 +32,11 @@ protected:
|
|||||||
struct CycleInfo
|
struct CycleInfo
|
||||||
{
|
{
|
||||||
std::map<std::string, int> perCycleInputs;
|
std::map<std::string, int> perCycleInputs;
|
||||||
std::map<std::string, int> perCycleOutputs;
|
// What one cycle produces, one entry per output group (REQ-MAT-OUTPUT-GROUP), so
|
||||||
|
// the summary can state alternatives as such. The output chips are listed from
|
||||||
|
// this too, flattened: a chip stands for a buffer, and a buffer exists for every
|
||||||
|
// item any group can produce.
|
||||||
|
std::vector<std::vector<RecipeLineRow::Amount>> perCycleOutputGroups;
|
||||||
|
|
||||||
// Items the card lists whether or not they are currently in the buffers, for a
|
// Items the card lists whether or not they are currently in the buffers, for a
|
||||||
// building whose recipe is implicit and so has nothing to name while it sits
|
// building whose recipe is implicit and so has nothing to name while it sits
|
||||||
|
|||||||
@@ -35,16 +35,6 @@ std::vector<RecipeLineRow::Amount> toAmounts(
|
|||||||
return amounts;
|
return amounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<RecipeLineRow::Amount> toAmounts(const std::vector<RecipeOutput>& outputs)
|
|
||||||
{
|
|
||||||
std::vector<RecipeLineRow::Amount> amounts;
|
|
||||||
amounts.reserve(outputs.size());
|
|
||||||
for (const RecipeOutput& output : outputs)
|
|
||||||
{
|
|
||||||
amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount });
|
|
||||||
}
|
|
||||||
return amounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The screen the cursor is on, falling back to the primary screen when the position is
|
// The screen the cursor is on, falling back to the primary screen when the position is
|
||||||
// on none of them (a cursor between two screens of different heights).
|
// on none of them (a cursor between two screens of different heights).
|
||||||
@@ -152,7 +142,7 @@ void ItemTooltip::rebuild()
|
|||||||
spec.building = recipe->building;
|
spec.building = recipe->building;
|
||||||
spec.name = QString::fromStdString(toDisplayName(recipe->id));
|
spec.name = QString::fromStdString(toDisplayName(recipe->id));
|
||||||
spec.inputs = toAmounts(recipe->inputs);
|
spec.inputs = toAmounts(recipe->inputs);
|
||||||
spec.outputs = toAmounts(recipe->outputs);
|
spec.outputGroups = RecipeLineRow::toOutputGroups(*recipe);
|
||||||
spec.durationSeconds = recipe->durationSeconds;
|
spec.durationSeconds = recipe->durationSeconds;
|
||||||
|
|
||||||
// Boxed, because an item with several producers stacks several of these and a run
|
// Boxed, because an item with several producers stacks several of these and a run
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ BufferedBuildingContent::CycleInfo RecipeProductionContent::getCycleInfo(
|
|||||||
{
|
{
|
||||||
info.perCycleInputs[ingredient.item] = ingredient.amount;
|
info.perCycleInputs[ingredient.item] = ingredient.amount;
|
||||||
}
|
}
|
||||||
for (const RecipeOutput& output : recipe->outputs)
|
info.perCycleOutputGroups = RecipeLineRow::toOutputGroups(*recipe);
|
||||||
{
|
|
||||||
info.perCycleOutputs[output.item] = output.amount;
|
|
||||||
}
|
|
||||||
info.runsProduction = true;
|
info.runsProduction = true;
|
||||||
info.durationSeconds = recipe->durationSeconds;
|
info.durationSeconds = recipe->durationSeconds;
|
||||||
return info;
|
return info;
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ std::vector<std::string> getAllItemIds(const RecipesConfig& recipes)
|
|||||||
{
|
{
|
||||||
seen.insert(ingredient.item);
|
seen.insert(ingredient.item);
|
||||||
}
|
}
|
||||||
for (const RecipeOutput& output : recipe.outputs)
|
for (const std::string& item : getProducibleItems(recipe))
|
||||||
{
|
{
|
||||||
seen.insert(output.item);
|
seen.insert(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return std::vector<std::string>(seen.begin(), seen.end());
|
return std::vector<std::string>(seen.begin(), seen.end());
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ def consumes_scrap(recipe):
|
|||||||
return any(inp["item"] == "scrap" for inp in recipe.get("inputs", []))
|
return any(inp["item"] == "scrap" for inp in recipe.get("inputs", []))
|
||||||
|
|
||||||
|
|
||||||
|
def output_groups(recipe):
|
||||||
|
"""The recipe's output groups, whichever form the config writes them in.
|
||||||
|
|
||||||
|
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
|
||||||
|
several-group form (REQ-MAT-OUTPUT-GROUP). A cycle yields exactly one group.
|
||||||
|
"""
|
||||||
|
if "output_group" in recipe:
|
||||||
|
return recipe["output_group"]
|
||||||
|
return [{"items": recipe.get("outputs", [])}]
|
||||||
|
|
||||||
|
|
||||||
|
def picks_one_of_several(recipe):
|
||||||
|
"""True when a cycle picks between groups, which is what makes a yield random."""
|
||||||
|
return len(output_groups(recipe)) > 1
|
||||||
|
|
||||||
|
|
||||||
def recipe_threat_per_unit(recipe, output, item_threat):
|
def recipe_threat_per_unit(recipe, output, item_threat):
|
||||||
threat = recipe["duration_seconds"]
|
threat = recipe["duration_seconds"]
|
||||||
for inp in recipe.get("inputs", []):
|
for inp in recipe.get("inputs", []):
|
||||||
@@ -69,15 +85,18 @@ def recipe_threat_per_unit(recipe, output, item_threat):
|
|||||||
|
|
||||||
def resolve_items(recipes, scrap_threat):
|
def resolve_items(recipes, scrap_threat):
|
||||||
"""Return {item: threat} resolved per REQ-THREAT-ITEM."""
|
"""Return {item: threat} resolved per REQ-THREAT-ITEM."""
|
||||||
non_repro = [r for r in recipes if r["building"] != "reprocessing_plant"]
|
# What decides the model is the recipe's shape, not the building running it: a recipe
|
||||||
repro = [r for r in recipes if r["building"] == "reprocessing_plant"]
|
# picking between groups costs its items by their odds, one yielding a single group
|
||||||
|
# every cycle costs them outright (REQ-MAT-OUTPUT-GROUP, REQ-THREAT-ITEM).
|
||||||
|
non_repro = [r for r in recipes if not picks_one_of_several(r)]
|
||||||
|
repro = [r for r in recipes if picks_one_of_several(r)]
|
||||||
|
|
||||||
# Items with at least one scrap-free producer: their scrap-consuming
|
# Items with at least one scrap-free producer: their scrap-consuming
|
||||||
# recipes never participate (fallback rule).
|
# recipes never participate (fallback rule).
|
||||||
scrap_free_items = set()
|
scrap_free_items = set()
|
||||||
for recipe in non_repro:
|
for recipe in non_repro:
|
||||||
if not consumes_scrap(recipe):
|
if not consumes_scrap(recipe):
|
||||||
for output in recipe.get("outputs", []):
|
for output in output_groups(recipe)[0]["items"]:
|
||||||
scrap_free_items.add(output["item"])
|
scrap_free_items.add(output["item"])
|
||||||
|
|
||||||
def eligible(recipe, output):
|
def eligible(recipe, output):
|
||||||
@@ -93,7 +112,7 @@ def resolve_items(recipes, scrap_threat):
|
|||||||
# pass earlier than the base path would win and underprice the item.
|
# pass earlier than the base path would win and underprice the item.
|
||||||
recipes_per_item = {}
|
recipes_per_item = {}
|
||||||
for recipe in non_repro:
|
for recipe in non_repro:
|
||||||
for output in recipe.get("outputs", []):
|
for output in output_groups(recipe)[0]["items"]:
|
||||||
if eligible(recipe, output):
|
if eligible(recipe, output):
|
||||||
recipes_per_item.setdefault(output["item"], []).append(
|
recipes_per_item.setdefault(output["item"], []).append(
|
||||||
(recipe, output))
|
(recipe, output))
|
||||||
@@ -121,22 +140,28 @@ def resolve_items(recipes, scrap_threat):
|
|||||||
for recipe in repro:
|
for recipe in repro:
|
||||||
scrap_per_cycle = sum(inp["amount"]
|
scrap_per_cycle = sum(inp["amount"]
|
||||||
for inp in recipe.get("inputs", []))
|
for inp in recipe.get("inputs", []))
|
||||||
total_weight = sum(out.get("probability", 1.0)
|
groups = output_groups(recipe)
|
||||||
for out in recipe.get("outputs", []))
|
total_weight = sum(g.get("probability", 1.0) for g in groups)
|
||||||
for output in recipe.get("outputs", []):
|
for group in groups:
|
||||||
# Reprocessing defines an item's threat only when nothing
|
probability = group.get("probability", 1.0) / total_weight
|
||||||
# else produces it (REQ-THREAT-ITEM).
|
|
||||||
if output["item"] in item_threat:
|
|
||||||
continue
|
|
||||||
if output["item"] in scrap_free_items:
|
|
||||||
continue
|
|
||||||
probability = output.get("probability", 1.0) / total_weight
|
|
||||||
if probability <= 0.0:
|
if probability <= 0.0:
|
||||||
continue
|
continue
|
||||||
item_threat[output["item"]] = (
|
for output in group["items"]:
|
||||||
(scrap_threat * scrap_per_cycle
|
# This model defines an item's threat only when nothing else
|
||||||
+ recipe["duration_seconds"]) / probability)
|
# produces it (REQ-THREAT-ITEM).
|
||||||
progress = True
|
if output["item"] in item_threat:
|
||||||
|
continue
|
||||||
|
if output["item"] in scrap_free_items:
|
||||||
|
continue
|
||||||
|
# Per unit: the cycle's cost over the odds of getting this group at
|
||||||
|
# all, then over how many units the group yields.
|
||||||
|
divisor = probability * output["amount"]
|
||||||
|
if divisor <= 0.0:
|
||||||
|
continue
|
||||||
|
item_threat[output["item"]] = (
|
||||||
|
(scrap_threat * scrap_per_cycle
|
||||||
|
+ recipe["duration_seconds"]) / divisor)
|
||||||
|
progress = True
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
# Iterate to a fixpoint: items downstream of reprocessing-only items
|
# Iterate to a fixpoint: items downstream of reprocessing-only items
|
||||||
@@ -225,9 +250,10 @@ def main():
|
|||||||
" building) ==")
|
" building) ==")
|
||||||
producers = {} # item -> [(recipe id, items/s per building)]
|
producers = {} # item -> [(recipe id, items/s per building)]
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
if recipe["building"] == "reprocessing_plant":
|
# A recipe that picks between groups has no steady per-item rate to quote.
|
||||||
|
if picks_one_of_several(recipe):
|
||||||
continue
|
continue
|
||||||
for output in recipe.get("outputs", []):
|
for output in output_groups(recipe)[0]["items"]:
|
||||||
rate = output["amount"] / recipe["duration_seconds"]
|
rate = output["amount"] / recipe["duration_seconds"]
|
||||||
producers.setdefault(output["item"], []).append((recipe["id"], rate))
|
producers.setdefault(output["item"], []).append((recipe["id"], rate))
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ def load_toml(path):
|
|||||||
return toml.load(path)
|
return toml.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_outputs(recipe):
|
||||||
|
"""Every item the recipe can produce, across all of its output groups.
|
||||||
|
|
||||||
|
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
|
||||||
|
several-group form (REQ-MAT-OUTPUT-GROUP).
|
||||||
|
"""
|
||||||
|
if "output_group" in recipe:
|
||||||
|
return [out for group in recipe["output_group"] for out in group["items"]]
|
||||||
|
return recipe.get("outputs", [])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
default_dir = os.path.normpath(os.path.join(
|
default_dir = os.path.normpath(os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
@@ -67,7 +78,7 @@ def main():
|
|||||||
consumed = {} # item id -> [consumer descriptions]
|
consumed = {} # item id -> [consumer descriptions]
|
||||||
|
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
for output in recipe.get("outputs", []):
|
for output in recipe_outputs(recipe):
|
||||||
produced.setdefault(output["item"], []).append(
|
produced.setdefault(output["item"], []).append(
|
||||||
"recipe '{}'".format(recipe["id"]))
|
"recipe '{}'".format(recipe["id"]))
|
||||||
for inp in recipe.get("inputs", []):
|
for inp in recipe.get("inputs", []):
|
||||||
|
|||||||
Reference in New Issue
Block a user