diff --git a/docs/changelog.txt b/docs/changelog.txt index 39916d65ec..62742fdc89 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -61,6 +61,8 @@ Template for new versions: ## Fixes ## Misc Improvements +- `tailor`: clothing needs are now computed from each unit's body plan instead of a fixed armor/pants/shoes list: dwarves get two shoes, units with any number of stance parts get appropriately many pairs of shoes, and helmets, gloves, and unusually-shaped citizens are covered too. Clothing types are chosen to maximize body coverage using their UBSTEP/LBSTEP/UPSTEP reach +- `tailor`: pending manager orders now count toward clothing needs, so repeating ``tailor now`` no longer creates additional orders in other materials for needs that are already covered by an outstanding order ## Documentation diff --git a/docs/plugins/tailor.rst b/docs/plugins/tailor.rst index 9e59957810..a162dd15de 100644 --- a/docs/plugins/tailor.rst +++ b/docs/plugins/tailor.rst @@ -15,6 +15,19 @@ manufacture some more. ``tailor`` will intelligently create orders using raw materials that you have on hand in the fort. For example, if you have lots of silk, but no cloth, then ``tailor`` will order only silk clothing to be made. +``tailor`` determines what each unit needs based on the unit's actual body +plan rather than a fixed list of clothing types. Every body part that wants +clothing -- upper and lower body parts, the head, grasp parts (hands), and +stance parts (feet, or whatever else the unit stands on) -- gets its own +requirement, so a dwarf needs *two* shoes, a one-legged creature needs only +one, and a creature that stands on a tail needs a shoe for its tail. For each +such part, ``tailor`` chooses from the clothing types your civilization knows +how to make, preferring the ones that cover the most of the unit's body based +on the item definition's UBSTEP/LBSTEP/UPSTEP reach. Since gloves and shoes +are manufactured in pairs, orders for them account for pairs: one order makes +two identical shoes that can cover any two stance parts, or one left and one +right glove for the matching grasp parts. + If you are wondering whether you should enable `autoclothing` instead, see the head-to-head comparison in the `autoclothing` docs. diff --git a/plugins/tailor.cpp b/plugins/tailor.cpp index 1018999ec4..7506846d54 100644 --- a/plugins/tailor.cpp +++ b/plugins/tailor.cpp @@ -3,20 +3,27 @@ */ #include +#include +#include +#include #include "Debug.h" #include "LuaTools.h" #include "PluginManager.h" #include "PluginLua.h" +#include "modules/Items.h" #include "modules/Materials.h" #include "modules/Persistence.h" #include "modules/Units.h" #include "modules/World.h" +#include "df/body_part_raw.h" #include "df/building.h" +#include "df/caste_body_info.h" #include "df/creature_raw.h" #include "df/descriptor_color.h" +#include "df/glove_handedness.h" #include "df/historical_entity.h" #include "df/item.h" #include "df/item_flags.h" @@ -139,17 +146,317 @@ static struct BadFlags { } } badFlags; +// chirality classes used to track left and right glove needs (and stock) +// separately; achiral items always use CHIRAL_ANY +enum TailorChiral { + CHIRAL_ANY = 0, + CHIRAL_RIGHT = 1, + CHIRAL_LEFT = 2, + CHIRAL_OTHER = 3, // gloves with no (or an invalid) handedness; nothing we + // can produce fills such a need +}; + +// one body part that wants a worn item, as determined by its flags using the +// same precedence DF uses to compute clothing needs +struct TailorSlot { + int16_t body_part_id; + df::item_type item_type; + int16_t subtype = -1; // chosen clothing itemdef, -1 if none is usable + int chiral = CHIRAL_ANY; +}; + +// the set of clothing slots for a body plan, plus the clothing item +// definitions chosen to fill them. this is expensive to compute, so it is +// cached per caste_body_info for the lifetime of the world +struct BodyClothingPlan { + std::vector slots; + std::map slot_by_part; +}; + +// the item type a body part wants worn on it. DF checks part flags in this +// order, so e.g. a part flagged both STANCE and GRASP (the "hands" of a +// creature that walks on its hands) wants shoes rather than gloves +static df::item_type slot_type_for_part(df::body_part_raw *part) +{ + auto &f = part->flags; + if (f.is_set(df::body_part_raw_flags::LOWERBODY)) return df::item_type::PANTS; + if (f.is_set(df::body_part_raw_flags::UPPERBODY)) return df::item_type::ARMOR; + if (f.is_set(df::body_part_raw_flags::STANCE)) return df::item_type::SHOES; + if (f.is_set(df::body_part_raw_flags::HEAD)) return df::item_type::HELM; + if (f.is_set(df::body_part_raw_flags::GRASP)) return df::item_type::GLOVES; + return df::item_type::NONE; +} + +// faithful reimplementation of DF's bodyst::get_body_step_distance, which +// measures how many "steps" of non-torso body parts lie between a target part +// and the part a garment is worn on, walking up the body part parent +// (con_part_id) chain. DF compares the result to a clothing item +// definition's UBSTEP/LBSTEP values to decide coverage: +// +// *main is set when the target is the worn part itself or a LOWERBODY part +// directly attached to it (body armor always covers the lower body). +// *ub_steps is set when the target does not lie on a stance limb chain +// (e.g. arms), *lb_steps when it does (legs, stance tails, etc). +// +// The distance counts every non-UPPERBODY/LOWERBODY part on the chain from +// the target upward, stopping the count at the first torso part; every part +// on the chain between that torso part and the worn part must itself be a +// torso part, and the worn part must be an ancestor of the target, otherwise +// no distance is reported. +static void body_step_distance(df::caste_body_info *body, int16_t worn, int16_t target, + bool *main, int16_t *ub_steps, int16_t *lb_steps) +{ + *main = false; + *ub_steps = -1; + *lb_steps = -1; + + auto &parts = body->body_parts; + int n = (int)parts.size(); + if (worn < 0 || worn >= n || target < 0 || target >= n) + return; + + auto is_torso = [&parts](int16_t i) { + return parts[i]->flags.is_set(df::body_part_raw_flags::UPPERBODY) || + parts[i]->flags.is_set(df::body_part_raw_flags::LOWERBODY); + }; + + if (worn == target || + (parts[target]->con_part_id == worn && + parts[target]->flags.is_set(df::body_part_raw_flags::LOWERBODY))) { + *main = true; + return; + } + + // a part is on a stance limb chain if it is itself a stance part or a LIMB + // ancestor of a stance part + bool on_stance_chain = false; + for (int i = 0; i < n && !on_stance_chain; i++) { + if (!parts[i]->flags.is_set(df::body_part_raw_flags::STANCE)) + continue; + if (i == target) { + on_stance_chain = true; + break; + } + for (int16_t s = parts[i]->con_part_id; 0 <= s && s < n; s = parts[s]->con_part_id) { + if (!parts[s]->flags.is_set(df::body_part_raw_flags::LIMB)) + break; + if (s == target) { + on_stance_chain = true; + break; + } + } + } + + int steps = 0; + bool crossed_torso = false; + int16_t cur = target; + while (0 <= cur && cur < n) { + if (crossed_torso) { + if (!is_torso(cur)) + return; + } else if (is_torso(cur)) { + crossed_torso = true; + } else { + steps++; + } + if (cur == worn) + break; + cur = parts[cur]->con_part_id; + } + if (steps > 0 && cur == worn) { + if (on_stance_chain) + *lb_steps = steps; + else + *ub_steps = steps; + } +} + +// replicates the coverage decision made by DF's unitst::insulation_quantity: +// does a garment of item type 'ty' worn on body part 'worn' cover body part +// 'target'? the step parameters are the item definition's UBSTEP/LBSTEP/UPSTEP +// values as appropriate for the type +static bool covers_part(df::caste_body_info *body, df::item_type ty, + int16_t ubstep, int16_t lbstep, int16_t upstep, int16_t worn, int16_t target) +{ + auto &parts = body->body_parts; + int n = (int)parts.size(); + if (worn < 0 || worn >= n || target < 0 || target >= n) + return false; + + switch (ty) { + case df::item_type::HELM: + // helms cover only the head they are worn on + return target == worn; + case df::item_type::GLOVES: + case df::item_type::SHOES: { + // gloves and shoes cover the part they are worn on plus up to UPSTEP + // LIMB ancestors of that part + if (target == worn) + return true; + int steps = 1; + for (int16_t s = parts[worn]->con_part_id; 0 <= s && s < n; s = parts[s]->con_part_id, steps++) { + if (!parts[s]->flags.is_set(df::body_part_raw_flags::LIMB)) + break; + if (s == target) + return steps <= upstep; + } + return false; + } + case df::item_type::ARMOR: + case df::item_type::PANTS: { + // body armor and pants never cover heads, grasp, or stance parts + auto &tf = parts[target]->flags; + if (tf.is_set(df::body_part_raw_flags::HEAD) || + tf.is_set(df::body_part_raw_flags::GRASP) || + tf.is_set(df::body_part_raw_flags::STANCE)) + return false; + bool main; + int16_t ub, lb; + body_step_distance(body, worn, target, &main, &ub, &lb); + if (main) + return true; + if (ty == df::item_type::ARMOR) + return (0 <= ub && ub <= ubstep) || (0 <= lb && lb <= lbstep); + // pants can only extend down stance limb chains, never along arms + return 0 <= lb && lb <= lbstep; + } + default: + return false; + } +} + +// the number of body parts an item of the given type/subtype would cover on +// the given body plan when worn on 'worn' +static int itemdef_coverage(df::caste_body_info *body, df::item_type ty, int16_t sub, int16_t worn) +{ + int16_t ubstep = 0, lbstep = 0, upstep = 0; + switch (ty) { + case df::item_type::ARMOR: { + auto def = world->raws.itemdefs.armor[sub]; + if (!def) + return 0; + ubstep = def->ubstep; + lbstep = def->lbstep; + break; + } + case df::item_type::PANTS: { + auto def = world->raws.itemdefs.pants[sub]; + if (!def) + return 0; + lbstep = def->lbstep; + break; + } + case df::item_type::GLOVES: { + auto def = world->raws.itemdefs.gloves[sub]; + if (!def) + return 0; + upstep = def->upstep; + break; + } + case df::item_type::SHOES: { + auto def = world->raws.itemdefs.shoes[sub]; + if (!def) + return 0; + upstep = def->upstep; + break; + } + case df::item_type::HELM: break; + default: return 0; + } + int count = 0; + for (int t = 0; t < (int)body->body_parts.size(); t++) + if (covers_part(body, ty, ubstep, lbstep, upstep, worn, t)) + count++; + return count; +} + +static const std::vector &entity_itemdefs(df::historical_entity *e, df::item_type ty) +{ + static const std::vector empty; + switch (ty) { + case df::item_type::ARMOR: return e->resources.armor_type; + case df::item_type::GLOVES: return e->resources.gloves_type; + case df::item_type::HELM: return e->resources.helm_type; + case df::item_type::PANTS: return e->resources.pants_type; + case df::item_type::SHOES: return e->resources.shoes_type; + default: return empty; + } +} + +// clothing is distinguished from real armor by armorlevel == 0 +static bool itemdef_is_clothing(df::item_type ty, int16_t sub) +{ + switch (ty) { + case df::item_type::ARMOR: + return world->raws.itemdefs.armor[sub] && + world->raws.itemdefs.armor[sub]->armorlevel == 0; + case df::item_type::GLOVES: + return world->raws.itemdefs.gloves[sub] && + world->raws.itemdefs.gloves[sub]->armorlevel == 0; + case df::item_type::HELM: + return world->raws.itemdefs.helms[sub] && + world->raws.itemdefs.helms[sub]->armorlevel == 0; + case df::item_type::PANTS: + return world->raws.itemdefs.pants[sub] && + world->raws.itemdefs.pants[sub]->armorlevel == 0; + case df::item_type::SHOES: + return world->raws.itemdefs.shoes[sub] && + world->raws.itemdefs.shoes[sub]->armorlevel == 0; + default: return false; + } +} + +// the chirality bucket a stock item belongs to +static int stock_chiral(df::item *i) +{ + if (i->getType() != df::item_type::GLOVES) + return CHIRAL_ANY; + uint32_t h = i->getGloveHandedness(); + if (h == (1u << df::glove_handedness::RIGHT)) + return CHIRAL_RIGHT; + if (h == (1u << df::glove_handedness::LEFT)) + return CHIRAL_LEFT; + return CHIRAL_OTHER; +} + +// does this item fill the given clothing slot when worn on it? the item type +// must match, and gloves must have the handedness the slot requires +static bool item_fills_slot(df::item *item, const TailorSlot &slot) +{ + if (item->getType() != slot.item_type) + return false; + if (slot.item_type == df::item_type::GLOVES) { + uint32_t h = item->getGloveHandedness(); + switch (slot.chiral) { + case CHIRAL_RIGHT: return (h & (1u << df::glove_handedness::RIGHT)) != 0; + case CHIRAL_LEFT: return (h & (1u << df::glove_handedness::LEFT)) != 0; + case CHIRAL_OTHER: return h == 0; + default: return false; + } + } + return true; +} + class Tailor { private: std::map sizes; // this maps body size to races - std::map, int> available; + // (item type, size, chirality) -> count of unworn clothing items in stock + std::map, int> available; - std::map, int> needed; + // (item type, itemdef subtype, size, chirality) -> count of items needed. + // for shoes and gloves this counts individual items; each order produces + // a pair, so order counts are derived from these numbers + std::map, int> needed; std::map, int> orders; + // cached clothing plans, keyed by the shared caste body plan pointer; see + // BodyClothingPlan. body plans and entity clothing lists are fixed for the + // lifetime of a loaded world + std::map plan_cache; + std::map supply; std::map reserves; @@ -193,6 +500,11 @@ class Tailor { return material_order; } + void clear_plan_cache() + { + plan_cache.clear(); + } + void reset() { available.clear(); @@ -202,6 +514,67 @@ class Tailor { orders.clear(); } + // choose the craftable clothing itemdef of the given type covering the + // most body parts on this body plan when worn on 'worn'; -1 if none + int best_subtype(df::item_type ty, const std::vector &candidates, + df::caste_body_info *body, int16_t worn) + { + int best = -1; + int best_coverage = -1; + for (auto sub : candidates) + { + if (!itemdef_is_clothing(ty, sub)) + continue; + int coverage = itemdef_coverage(body, ty, sub, worn); + if (coverage > best_coverage) + { + best_coverage = coverage; + best = sub; + } + } + return best; + } + + // compute (or fetch from the cache) the clothing plan for a body plan: + // the list of clothing slots and the best-covering itemdef for each + const BodyClothingPlan *get_plan(df::caste_body_info *body) + { + auto found = plan_cache.find(body); + if (found != plan_cache.end()) + return &found->second; + + BodyClothingPlan plan; + auto entity = world->entities.all[plotinfo->civ_id]; + int n = (int)body->body_parts.size(); + for (int i = 0; i < n; i++) + { + auto part = body->body_parts[i]; + df::item_type ty = slot_type_for_part(part); + if (ty == df::item_type::NONE) + continue; + + TailorSlot slot; + slot.body_part_id = (int16_t)i; + slot.item_type = ty; + if (ty == df::item_type::GLOVES) + { + // gloves are made in pairs of one left and one right glove, so + // only distinctly left/right grasp parts can wear them + bool r = part->flags.is_set(df::body_part_raw_flags::RIGHT); + bool l = part->flags.is_set(df::body_part_raw_flags::LEFT); + slot.chiral = r && !l ? CHIRAL_RIGHT : + l && !r ? CHIRAL_LEFT : CHIRAL_OTHER; + } + if (slot.chiral != CHIRAL_OTHER) + slot.subtype = best_subtype(ty, entity_itemdefs(entity, ty), body, slot.body_part_id); + + plan.slot_by_part[slot.body_part_id] = plan.slots.size(); + plan.slots.push_back(slot); + } + + return &plan_cache.emplace(body, std::move(plan)).first->second; + } + void scan_clothing() { for (auto i : world->items.other[df::items_other_id::ANY_GOES_IN_CABINET]) @@ -212,13 +585,15 @@ class Tailor { } if (i->getWear() >= 1) continue; + if (!i->isClothing()) + continue; if (i->getMakerRace() < 0) // sometimes we get borked items with no valid maker race continue; df::item_type t = i->getType(); int size = world->raws.creatures.all[i->getMakerRace()]->adultsize; - available[std::make_pair(t, size)] += 1; + available[std::make_tuple(t, size, stock_chiral(i))] += 1; } if (DBG_NAME(cycle).isEnabled(DebugCategory::LDEBUG)) @@ -226,8 +601,8 @@ class Tailor { for (auto& i : available) { df::item_type t; - int size; - std::tie(t, size) = i.first; + int size, chiral; + std::tie(t, size, chiral) = i.first; DEBUG(cycle).print("tailor: {} {} of size {} found\n", i.second, ENUM_KEY_STR(item_type, t), size); } @@ -284,6 +659,27 @@ class Tailor { supply[M_SILK], supply[M_YARN], supply[M_CLOTH], supply[M_LEATHER], supply[M_ADAMANTINE]); } + void confiscate_item(df::unit *u, df::item *w) + { + std::string description; + w->getItemDescription(&description, 0); + + if (w->flags.bits.owned) + { + bool confiscated = Items::setOwner(w, NULL); + + INFO(cycle).print( + "tailor: {} {} from {}.\n", + (confiscated ? "confiscated" : "could not confiscate"), + DF2CONSOLE(description), + DF2CONSOLE(Units::getReadableName(u)) + ); + } + + if (w->getWear() > 1) + w->flags.bits.dump = true; + } + void scan_replacements() { for (auto u : Units::citizensRange(world->units.active)) { @@ -291,140 +687,132 @@ class Tailor { !Units::casteFlagSet(u->race, u->caste, df::enums::caste_raw_flags::EQUIPS)) continue; // skip units we don't control or that can't wear clothes - std::set equipped; - std::set ordered; - std::deque damaged; + auto body = u->body.body_plan; + if (!body) + continue; + auto plan = get_plan(body); + if (!plan) + continue; + + int usize = world->raws.creatures.all[u->race]->adultsize; + sizes[usize] = u->race; + + // count, per clothing slot, the intact and tattered items worn on + // the slot's body part that actually fill the slot + std::vector good(plan->slots.size(), 0); + std::map> tattered; for (auto inv : u->inventory) { - if (inv->mode != df::inv_item_role_type::Worn) + if (inv->mode != df::inv_item_role_type::Worn && + inv->mode != df::inv_item_role_type::WrappedAround) continue; // skip non-clothing if (!inv->item->isClothing()) continue; + auto sit = plan->slot_by_part.find(inv->body_part_id); + if (sit == plan->slot_by_part.end()) + continue; + if (!item_fills_slot(inv->item, plan->slots[sit->second])) + continue; if (inv->item->getWear() > 0) - damaged.push_back(inv->item); + tattered[sit->second].push_back(inv->item); else - equipped.insert(inv->item->getType()); + good[sit->second] += 1; } - int usize = world->raws.creatures.all[u->race]->adultsize; - sizes[usize] = u->race; - - for (auto w : damaged) + auto &part_status = u->body.components.body_part_status; + for (size_t i = 0; i < plan->slots.size(); i++) { - // skip armor - if (w->getEffectiveArmorLevel() > 0) - continue; - - auto ty = w->getType(); - - auto makerRace = w->getMakerRace(); - if (makerRace < 0 || makerRace >= (int16_t) world->raws.creatures.all.size()) + const TailorSlot &slot = plan->slots[i]; + // missing parts need no clothing + if (slot.body_part_id < (int)part_status.size() && + part_status[slot.body_part_id].bits.missing) continue; - int isize = world->raws.creatures.all[makerRace]->adultsize; - std::string description; - w->getItemDescription(&description, 0); - - if (equipped.count(ty) == 0) + if (good[i] > 0) { - if (ordered.count(ty) == 0) - { - DEBUG(cycle).print ("tailor: {} (size {}) worn by {} (size {}) needs replacement\n", - DF2CONSOLE(description), isize, - DF2CONSOLE(Units::getReadableName(u)), usize); - needed[std::make_pair(ty, usize)] += 1; - ordered.insert(ty); - } + // the slot is adequately covered; any tattered clothing + // still worn on it is redundant and can be confiscated + if (confiscate) + for (auto w : tattered[i]) + confiscate_item(u, w); + continue; } - if (confiscate && equipped.count(ty) > 0) + if (slot.subtype < 0) { - if (w->flags.bits.owned) - { - bool confiscated = Items::setOwner(w, NULL); - - INFO(cycle).print( - "tailor: {} {} from {}.\n", - (confiscated ? "confiscated" : "could not confiscate"), - DF2CONSOLE(description), - DF2CONSOLE(Units::getReadableName(u)) - ); - } - - if (w->getWear() > 1) - w->flags.bits.dump = true; + TRACE(cycle).print("tailor: {} has a {} part tailor cannot supply\n", + DF2CONSOLE(Units::getReadableName(u)), + ENUM_KEY_STR(item_type, slot.item_type)); + continue; } - } - - for (auto ty : std::set{ df::item_type::ARMOR, df::item_type::PANTS, df::item_type::SHOES }) - { - if (equipped.count(ty) == 0 && ordered.count(ty) == 0) - { - TRACE(cycle).print("tailor: one {} of size {} needed to cover {}\n", - ENUM_KEY_STR(item_type, ty), - usize, - DF2CONSOLE(Units::getReadableName(u))); - needed[std::make_pair(ty, usize)] += 1; - } + TRACE(cycle).print("tailor: one {} of size {} needed to cover {} ({})\n", + ENUM_KEY_STR(item_type, slot.item_type), + usize, + DF2CONSOLE(Units::getReadableName(u)), + DF2CONSOLE(body->body_parts[slot.body_part_id]->token)); + needed[std::make_tuple(slot.item_type, slot.subtype, usize, slot.chiral)] += 1; } } } void create_orders() { - auto entity = world->entities.all[plotinfo->civ_id]; + // glove needs are tallied per chirality across all body plans sharing + // a (subtype, size) pair; each job makes one left and one right glove, + // so the number of jobs is the larger of the two needs + std::map, std::array> glove_need; for (auto& a : needed) { - df::item_type ty = a.first.first; - int size = a.first.second; - int count = a.second; + df::item_type ty; + int sub, size, chiral; + std::tie(ty, sub, size, chiral) = a.first; - // decrease "need" by "available" - count -= available[std::make_pair(ty, size)]; + // decrease "need" by "available"; stock of any subtype fills a + // need of its type, so consume the shared pool as we go + int &stock = available[std::make_tuple(ty, size, chiral)]; + int count = a.second - stock; + stock = std::max(0, stock - a.second); if (count <= 0) continue; - int sub = 0; - std::vector v; - - switch (ty) { - case df::item_type::ARMOR: v = entity->resources.armor_type; break; - case df::item_type::GLOVES: v = entity->resources.gloves_type; break; - case df::item_type::HELM: v = entity->resources.helm_type; break; - case df::item_type::PANTS: v = entity->resources.pants_type; break; - case df::item_type::SHOES: v = entity->resources.shoes_type; break; - default: break; + if (ty == df::item_type::SHOES) + { + // shoes are made in identical pairs, so two uncovered stance + // parts can be shod with a single order + int pairs = (count + 1) / 2; + orders[std::make_tuple(df::job_type::MakeShoes, sub, size)] += pairs; + DEBUG(cycle).print("tailor: {} shoes of size {} needed, ordering {} pairs\n", + count, size, pairs); } - - for (auto vv : v) { - bool isClothing = false; - switch (ty) { - case df::item_type::ARMOR: isClothing = world->raws.itemdefs.armor[vv]->armorlevel == 0; break; - case df::item_type::GLOVES: isClothing = world->raws.itemdefs.gloves[vv]->armorlevel == 0; break; - case df::item_type::HELM: isClothing = world->raws.itemdefs.helms[vv]->armorlevel == 0; break; - case df::item_type::PANTS: isClothing = world->raws.itemdefs.pants[vv]->armorlevel == 0; break; - case df::item_type::SHOES: isClothing = world->raws.itemdefs.shoes[vv]->armorlevel == 0; break; - default: break; - } - if (isClothing) + else if (ty == df::item_type::GLOVES) + { + glove_need[std::make_pair(sub, size)][chiral] += count; + } + else + { + auto jj = itemTypeMap.find(ty); + if (jj != itemTypeMap.end()) { - sub = vv; - break; + orders[std::make_tuple(jj->second, sub, size)] += count; + DEBUG(cycle).print("tailor: {} times {} of size {} ordered\n", + ENUM_KEY_STR(job_type, jj->second), count, size); } } + } - auto jj = itemTypeMap.find(ty); - if (jj != itemTypeMap.end()) - { - const df::job_type j = jj->second; - orders[std::make_tuple(j, sub, size)] += count; - DEBUG(cycle).print("tailor: {} times {} of size {} ordered\n", ENUM_KEY_STR(job_type, j), count, size); - } + for (auto& g : glove_need) + { + int pairs = std::max(g.second[CHIRAL_LEFT], g.second[CHIRAL_RIGHT]); + if (pairs <= 0) + continue; + orders[std::make_tuple(df::job_type::MakeGloves, g.first.first, g.first.second)] += pairs; + DEBUG(cycle).print("tailor: {} left and {} right gloves of size {} needed, ordering {} pairs\n", + g.second[CHIRAL_LEFT], g.second[CHIRAL_RIGHT], g.first.second, pairs); } } @@ -571,18 +959,19 @@ class Tailor { pp->getDescription(&descr); - auto [_, n] = get_or_create_order(c, df::job_type::CustomReaction, -1, -1, 0, r->code); - if (n > 0) + auto [order, added] = ensure_order(c, df::job_type::CustomReaction, -1, -1, 0, r->code); + if (added > 0) { - INFO(cycle).print("tailor: ordered {} {}\n", c, DF2CONSOLE(descr)); + INFO(cycle).print("tailor: ordered {} {}\n", added, DF2CONSOLE(descr)); } - return n; + // the order (existing or new) now covers the requested amount + return order ? std::min(c, order->amount_left) : 0; } oqt order_dye_cloth(int c = 1) { - auto [_, n] = get_or_create_order(c, df::job_type::DyeCloth, -1, -1, 0); - return n; + auto [order, added] = ensure_order(c, df::job_type::DyeCloth, -1, -1, 0); + return order ? std::min(c, order->amount_left) : 0; } void make_dyes(int count) @@ -620,7 +1009,7 @@ class Tailor { return sum; } - static std::pair get_or_create_order(oqt c, df::job_type ty, int16_t sub, int32_t hfid, df::job_material_category mcat, std::string custom_reaction = "") + static df::manager_order *find_order(df::job_type ty, int16_t sub, int32_t hfid, df::job_material_category mcat, const std::string &custom_reaction) { auto f = [&] (df::manager_order* order) { return order->job_type == ty && @@ -631,25 +1020,53 @@ class Tailor { order->specdata.race == hfid && order->material_category.whole == mcat.whole && order->frequency == df::workquota_frequency_type::OneTime && - order->reaction_name == custom_reaction; + order->reaction_name == custom_reaction && + order->amount_left > 0; }; auto orderIt = std::ranges::find_if(world->manager_orders.all, f); + return orderIt != world->manager_orders.all.end() ? *orderIt : nullptr; + } - if (orderIt != world->manager_orders.all.end()) + // how much of the demand for this (job, subtype, race) is already covered + // by pending manager orders; any material counts, since every such order + // eventually produces an item that fills the need + static oqt pending_coverage(oqt demand, df::job_type ty, int16_t sub, int32_t hfid) + { + oqt covered = 0; + for (auto order : world->manager_orders.all) { - auto o = *orderIt; - int chg = 0; - if (o->amount_left > 0) + if (order->job_type == ty && + order->item_type == df::item_type::NONE && + order->item_subtype == sub && + order->mat_type == -1 && + order->mat_index == -1 && + order->specdata.race == hfid && + order->frequency == df::workquota_frequency_type::OneTime && + order->reaction_name.empty()) { - int prev = o->amount_left; - o->amount_left = std::max(c, o->amount_left); - o->amount_total = std::max(c, o->amount_total); - chg = o->amount_left - prev; + covered += order->amount_left; + if (covered >= demand) + break; } - return {o, chg}; + } + return std::min(covered, demand); + } + + // increase the matching manager order by `add` items, creating it with + // `add` items if none exists; returns (order, items committed) + static std::pair add_to_order(oqt add, df::job_type ty, int16_t sub, int32_t hfid, df::job_material_category mcat, std::string custom_reaction = "") + { + if (auto order = find_order(ty, sub, hfid, mcat, custom_reaction)) + { + order->amount_left += add; + order->amount_total += add; + return {order, add}; } + if (add <= 0) + return {nullptr, 0}; + auto order = new df::manager_order; order->job_type = ty; order->item_type = df::item_type::NONE; @@ -659,8 +1076,8 @@ class Tailor { order->material_category = mcat; order->mat_type = -1; order->mat_index = -1; - order->amount_left = c; - order->amount_total = c; + order->amount_left = add; + order->amount_total = add; order->status.bits.validated = false; order->status.bits.active = false; order->frequency = df::workquota_frequency_type::OneTime; @@ -668,7 +1085,16 @@ class Tailor { world->manager_orders.all.push_back(order); - return {order, c}; + return {order, add}; + } + + // ensure the matching order has at least `demand` items pending; + // returns (order, additional items committed by this call) + static std::pair ensure_order(oqt demand, df::job_type ty, int16_t sub, int32_t hfid, df::job_material_category mcat, std::string custom_reaction = "") + { + auto order = find_order(ty, sub, hfid, mcat, custom_reaction); + oqt add = std::max(0, demand - (order ? order->amount_left : 0)); + return add_to_order(add, ty, sub, hfid, mcat, custom_reaction); } int place_orders() @@ -743,6 +1169,11 @@ class Tailor { continue; } + // pending orders for this item already count toward the need + count -= pending_coverage(count, ty, sub, sizes[size]); + if (count <= 0) + continue; + DEBUG(cycle).print("tailor: ordering {} {}\n", count, DF2CONSOLE(name_p)); for (auto& m : material_order) @@ -754,17 +1185,15 @@ class Tailor { int res = (r == reserves.end()) ? default_reserve : r->second; if (supply[m] > res && fl->is_set(m.armor_flag)) { - int c = count; - if (supply[m] < count + res) + int c = std::min(count, supply[m] - res); + if (c < count) { - c = supply[m] - res; TRACE(cycle).print("tailor: order reduced from {} to {} to protect reserves of {}\n", count, c, DF2CONSOLE(m.name)); - skipped += (count - c); } supply[m] -= c; - auto [order,n] = get_or_create_order(c, ty, sub, sizes[size], m.job_material); + auto [order,n] = add_to_order(c, ty, sub, sizes[size], m.job_material); if (n > 0) { @@ -781,11 +1210,12 @@ class Tailor { } else { - skipped += count; DEBUG(cycle).print("tailor: material {} skipped due to lack of reserves, {} available\n", DF2CONSOLE(m.name), supply[m]); } } + + skipped += count; } } @@ -887,6 +1317,8 @@ DFhackCExport command_result plugin_shutdown (color_ostream &out) { DFhackCExport command_result plugin_load_site_data (color_ostream &out) { cycle_timestamp = 0; + if (tailor_instance) + tailor_instance->clear_plan_cache(); config = World::GetPersistentSiteData(CONFIG_KEY); config2 = World::GetPersistentSiteData(CONFIG_KEY_2); diff --git a/test/plugins/tailor.lua b/test/plugins/tailor.lua new file mode 100644 index 0000000000..bbccf06eb2 --- /dev/null +++ b/test/plugins/tailor.lua @@ -0,0 +1,43 @@ +config.mode = 'fortress' +config.target = 'tailor' + +function test.status() + local output = dfhack.run_command_silent('tailor') + expect.str_find('tailor is', output) + expect.str_find('confiscating tattered clothing', output) +end + +function test.wrong_command_shows_usage() + local output, status = dfhack.run_command_silent('tailor', 'bogus') + expect.true_(status ~= 0) +end + +local CLOTHING_JOBS = { + [df.job_type.MakeArmor] = true, + [df.job_type.MakeHelm] = true, + [df.job_type.MakeGloves] = true, + [df.job_type.MakeShoes] = true, + [df.job_type.MakePants] = true, +} + +local function pending_clothing_items() + local total = 0 + for _, o in ipairs(df.global.world.manager_orders.all) do + if CLOTHING_JOBS[o.job_type] then + total = total + o.amount_left + end + end + return total +end + +function test.repeated_run_does_not_duplicate_orders() + dfhack.run_command_silent('tailor', 'now') + local after_first = pending_clothing_items() + dfhack.run_command_silent('tailor', 'now') + local after_second = pending_clothing_items() + -- pending orders must count toward needs, so an immediate second run + -- cannot commit more items than the first run did + expect.eq(after_first, after_second, + ('second tailor run added %d duplicate pending items'):format( + after_second - after_first)) +end