From 7ef9b4565a4e96fba16b1fc42ee14874e9247496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Mon, 10 Aug 2026 18:45:15 +0200 Subject: [PATCH 1/2] A logbook is three items at once, and the search asks about one of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: Expedition Logbooks are parsed, priced and searched — `Strategy::Logbook`, an item class the app had no reading for at all. ADDED: A destination is structure rather than modifiers — its area, its faction and its implicits are one `LogbookArea`, recognised by shape rather than by a table of factions. - The two leading lines are matched on being bare names and the tail on reading as modifiers, so the four factions and the dozens of areas stay game data. The number of implicits is not fixed: the captures show two and three. ADDED: `SearchPlan::choices` — a set of filters the search sends **one** of, drawn as a radio at the head of the filter list. - A logbook lists up to three destinations and the player travels to exactly one, so a query naming all three prices the single copy in the league that leads to that trio, and one naming none prices every logbook in the league. The alternative's own row *is* its faction filter rather than a heading over one. ADDED: The faction and the area join to their `pseudo.*` stats through two lexicon terms, the shape `SanctumEffectPrefix` already had, so no faction name is compiled in. ADDED: The area level is searched as a floor and ticked; the item level, quantity, rarity and pack size are offered unticked. - Measured on the rare capture: category, type, `map_filters.area_level` at 80 and the faction pseudo together return 983 listings, so all four are indexed for this category. The other three are not measured yet, and a filter the site accepts and indexes nothing under empties the search. ADDED: "Dannig" is a usage needle, or a logbook's usage note comes back as a modifier. ADDED: Three captures — a normal, a rare and a magic logbook — and the bundle slice they need. CHANGED: A destination's implicit is seeded as a floor with no ceiling. - Trade indexes an item's implicits as one total per stat and every destination feeds it: the rare capture's 14% and 16% Explosives are indexed as 30%, so a ceiling from one destination asks the other two not to exist. CHANGED: `merge_same_stat` never folds across a choice, as it never folds across `hidden`. CHANGED: The strategy picker offers neither reading for a logbook, as it already does for a heist item. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- docs/data-layer.md | 14 +- docs/item-layer.md | 5 + docs/strategy-logbook.md | 114 +++++++++++ docs/testing.md | 9 + docs/trade-layer.md | 25 ++- scripts/slice-test-bundle.py | 34 +++- src/data/lexicon.cpp | 14 +- src/data/lexicon.hpp | 11 +- src/item/item.cpp | 2 + src/item/item.hpp | 21 ++ src/item/parse.cpp | 57 ++++++ src/item/plan.cpp | 188 +++++++++++++++++- src/item/plan.hpp | 39 ++++ src/screens/item_view.cpp | 28 +++ src/screens/pricecheck_screen.cpp | 82 +++++++- src/trade/query.cpp | 5 + tests/data/bundle/en-items-name.index.bin | Bin 344 -> 352 bytes tests/data/bundle/en-items-ref.index.bin | Bin 344 -> 352 bytes tests/data/bundle/en-items.ndjson | 1 + tests/data/bundle/en-stats-matcher.index.bin | Bin 440 -> 624 bytes tests/data/bundle/en-stats-ref.index.bin | Bin 352 -> 504 bytes tests/data/bundle/en-stats.ndjson | 19 ++ tests/data/bundle/item-classes.ndjson | 1 + tests/data/items/logbook-magic-buffered.txt | 26 +++ .../data/items/logbook-normal-three-areas.txt | 24 +++ .../data/items/logbook-rare-ancient-lands.txt | 34 ++++ tests/item_parse_test.cpp | 70 +++++++ tests/item_pricing_test.cpp | 149 ++++++++++++++ tests/trade_query_test.cpp | 41 ++++ 30 files changed, 991 insertions(+), 24 deletions(-) create mode 100644 docs/strategy-logbook.md create mode 100644 tests/data/items/logbook-magic-buffered.txt create mode 100644 tests/data/items/logbook-normal-three-areas.txt create mode 100644 tests/data/items/logbook-rare-ancient-lands.txt diff --git a/CLAUDE.md b/CLAUDE.md index 9092fd5..5b19752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ read whole; each is one layer. | [docs/data-layer.md](docs/data-layer.md) | `src/data/` — the runtime bundle, the updater, the lexicon, stat normalization and matching. | | [docs/updater.md](docs/updater.md) | `src/update/` and `packaging/` — how a copy arrives and how it replaces itself: the install flavours, the swap, `latest.json`, and the Windows installer. | | [docs/item-layer.md](docs/item-layer.md) | `src/item/` — parse, resolve, derive, range matching, and the plan rules every strategy shares. Where most pricing judgement lives. | -| [docs/strategy-unique.md](docs/strategy-unique.md), [strategy-map.md](docs/strategy-map.md), [strategy-gem.md](docs/strategy-gem.md) | One per search strategy that has more to say than the shared rules: uniques (including unidentified), maps (with charts and Valdo maps), gems. | +| [docs/strategy-unique.md](docs/strategy-unique.md), [strategy-map.md](docs/strategy-map.md), [strategy-gem.md](docs/strategy-gem.md), [strategy-logbook.md](docs/strategy-logbook.md) | One per search strategy that has more to say than the shared rules: uniques (including unidentified), maps (with charts and Valdo maps), gems, expedition logbooks (the one item that is up to three items at once). | | [docs/trade-layer.md](docs/trade-layer.md) | `src/trade/` — query building, the two-step client, the rate limiter, and how results and the filter list are drawn. | | [docs/ninja.md](docs/ninja.md) | `src/ninja/` — the poe.ninja reference price. | | [docs/exchange.md](docs/exchange.md) | `src/exchange/` — GGG's hourly in-game currency exchange digests. | diff --git a/docs/data-layer.md b/docs/data-layer.md index 5141ebd..f27d601 100644 --- a/docs/data-layer.md +++ b/docs/data-layer.md @@ -52,11 +52,15 @@ downloaded at runtime from **[JIRPOS/PathOfPriceCheck-Data](https://github.com/J lets the client's own words be sent as the id the site wants), and the property and item-class tables are keyed the other way round, printed label to key, so a translated one replaces the English outright. - **One entry here is not the client's wording at all**: `Term::SanctumEffectPrefix`, the `Has ` - a sanctum boon or affliction's *stat* is worded with, where the item prints the name alone - under a `Minor Boons:` label. It lives here because a translated bundle translates the stat - along with everything else and the lookup is by exact wording, so it is per-language in the - same way the rest of this table is. + **Three entries here are not the client's wording at all**: `Term::SanctumEffectPrefix`, the + `Has ` a sanctum boon or affliction's *stat* is worded with, where the item prints the name + alone under a `Minor Boons:` label; and `LogbookFactionPrefix` / `LogbookAreaPrefix`, the + `Has Logbook Faction: ` and `Has Logbook Area: ` an Expedition Logbook's destination is + searched under, where the item prints the two as bare names in a block of their own. They live + here because a translated bundle translates the stat along with everything else and the lookup + is by exact wording, so they are per-language in the same way the rest of this table is — and + because keeping the join in the lexicon is what keeps a compiled-in list of the four expedition + factions out of the parser. See [strategy-logbook.md](strategy-logbook.md). **An empty entry never matches**, deliberately: `starts_with("")` is true of every line, and `ModType::Explicit` has no generation word of its own. `parse_item` and `looks_like_item` **take a lexicon and have no default**. The language is diff --git a/docs/item-layer.md b/docs/item-layer.md index 5618b9a..a246fbf 100644 --- a/docs/item-layer.md +++ b/docs/item-layer.md @@ -471,4 +471,9 @@ bundle, and only the third and fourth encode pricing judgement. - **`item/plan`'s map strategy** (`plan_map`, `plan_chart`, `add_map_pseudo`) is [strategy-map.md](strategy-map.md) — maps, charts and Valdo maps, and the one strategy that searches on none of an item's affixes. +- **`item/plan`'s logbook strategy** (`plan_logbook`, `group_logbook_mods`) is + [strategy-logbook.md](strategy-logbook.md) — the one item that is up to three items at once, and + the only plan whose rows are **alternatives**: `SearchPlan::choices` and `StatFilter::choice`, + one group per destination, exactly one live. `select_choice` is the whole of the exclusion and + `trade::build_query` reads nothing but `enabled`, as it does for `hidden`. - **`item/plan`'s gem strategy** (`plan_gem`) is [strategy-gem.md](strategy-gem.md). diff --git a/docs/strategy-logbook.md b/docs/strategy-logbook.md new file mode 100644 index 0000000..658cbcd --- /dev/null +++ b/docs/strategy-logbook.md @@ -0,0 +1,114 @@ +# The logbook strategy + + + +What every strategy shares — bounds, options, merged stats, the hidden section — is in +[item-layer.md](item-layer.md). This is the one item in the game that is **up to three items at +once**, and everything below follows from that. + +An Expedition Logbook lists up to three **destinations**. Each names an area, the faction whose +land it is, and the two or three implicits that apply there. The player takes the book to Dannig +and travels to **exactly one** of them, and the faction is what decides what that is worth — so +one logbook has up to three prices, and a query asking for all three destinations at once prices +the single copy in the league that leads to that exact trio. + +## Parsing (`item/parse`) + +A destination is a section of its own and is recognised by **shape, not by a table of factions** +(`is_logbook_destination`). There are four factions today and the areas run to dozens; both are +game data a league can add to, and neither is a vocabulary this layer could keep honest. What is +matched instead is the two leading lines being **bare names** — no digit anywhere, no `Label:` +colon, no mod-type suffix, not an info line — which nothing else a logbook prints in a section of +its own is. + +Two checks and both are load-bearing. The **leading pair** is what keeps the block of affixes a +rare logbook prints *below* its destinations out, because an affix opens with its roll. And the +**tail has to read as modifiers** (`looks_like_mods`), which keeps a stray pair of prose lines out +and is the half that still holds with Advanced Mod Descriptions on, where each implicit gains an +info line above it. The number of implicits is deliberately not fixed: the captures show two and +three. + +The implicits stay in `Item::mods`, and `LogbookArea::mods` holds their indices. Which implicit +belongs to which destination is the whole of what a logbook is priced on and nothing about the +modifiers themselves says it — the rare capture grants "increased number of Explosives" from two +different destinations at two different rolls. The area and the faction go nowhere near the mod +list, where they used to arrive as six unmatchable lines. + +**"Take this item to Dannig" needed a usage needle.** A logbook is gear, so prose needs a positive +signal before it stops being read as a modifier, and that sentence opens with no click +instruction — the same shape a chart's "Take this item to Valerie" already had a needle for. + +## The join (`item/plan`, `logbook_stat`) + +The faction and the area are searched as **`pseudo.*` stats**, and nothing else in the game is +searched this way: the item prints the two as bare names and the site words them as +`Has Logbook Faction: Druids of the Broken Circle` and `Has Logbook Area: Scrublands`. So the join +is by exact stat wording, through two lexicon terms (`Term::LogbookFactionPrefix`, +`LogbookAreaPrefix`) — the same shape and the same argument as `SanctumEffectPrefix`, which is +also why the four faction names are nowhere in this codebase. A name the bundle has no stat for is +a note, as an unknown sanctum boon or beast species is: the area list grows with the league. + +## What is searched + +- **One destination at a time.** `SearchPlan::choices` is one group per destination and + `StatFilter::choice` files each row under one; `select_choice` keeps exactly one group live. + The **faction is the choice itself** — `choice_primary`, drawn as the alternative's own row and + never a second time as a tickable one, because it is the single filter that follows entirely + from which destination was picked. Everything else in the group is offered unticked: where it + goes, because a buyer picking a faction is rarely picking an area with it, and what it grants + there, because an implicit is one of two or three numbers that came with the area rather than + something anybody chose. +- **The faction is asked on presence, never on a count.** The pseudo stat does take a value — how + many destinations belong to that faction — and it is not what decides the price: a logbook with + two Druids destinations is still bought for a Druids run, and bounding it drops every + single-destination copy of the same thing. +- **The first destination is live by default**, and it is the *first* on purpose. Nothing here can + rank the four factions, the ranking changes with the league and with what the player is farming, + and a default dressed up as an answer would be read as one. The panel puts them in the game's own + order and the choice is one click. +- **A destination's implicit is a floor and never a ceiling** (`group_logbook_mods`). Trade indexes + an item's implicits as one total per stat and all three destinations feed the same total — the + rare capture's two Explosives rolls, 14% and 16%, are indexed as 30%. A floor still matches under + that, since the total can only exceed one destination's own roll; a ceiling seeded from one + destination's roll asks the other two not to exist. Which side is the floor is the stat's own + `better`, the same question `to_filter` asks of an open bound. +- **The area level**, `map_filters.area_level`, a floor and ticked. Unlike a map's tier, a chart's + area level or a sanctum's floor — all exact, because a different number there is a different + product — a higher logbook area level is strictly more of the same one, and a buyer at 80 takes + an 83. +- **The item level**, offered. It bounds what the affixes can be crafted to, which is a question + about crafting the book rather than about running it. +- **Quantity, rarity and pack size**, offered, all three unticked. They are the same three + properties a map is searched on and come off the same `map_filters` keys, but a map's are the + whole of what it is run for and a logbook's are a second-order bonus on top of the artifacts, + which the destination decides. **Not yet measured** for this category: a filter the site accepts + and indexes nothing under empties the search, exactly as `heist_max_escape_routes` does, and + unticked is the state that cannot do that. Measure one at a time before ticking any of them. +- **The type is sent only where the bundle resolved the base.** The category is the whole search on + its own — one base type is filed under `logbook` — so the type says nothing it does not, and a + magic logbook's printed line is "Buffered Expedition Logbook", which as a type matches nothing + and reads as nobody selling one. + +## What is not + +**The affixes the book prints below its destinations** are the map argument and get the map's +answer, which is what the maintainer asked for: they apply wherever it goes, a logbook is +`craftable` and a currency redoes them, and a query naming them finds the one copy in the league +that rolled that set. So they are `hidden` — offered under the section at the foot of the list +rather than dropped on the floor — and, as a map's are, not notes either. + +**Split is not parsed, not filtered and not mentioned.** It is a flag line like Corrupted and the +site has a filter for it; neither is wanted here. (The flag itself is read by `parse_flags` for +every item in the game and has been since long before this, which is a different thing from the +strategy having an opinion about it.) + +## Measured + +The rare capture, searched as the plan builds it — category `logbook`, type `Expedition Logbook`, +`map_filters.area_level` at min 80, `pseudo.pseudo_logbook_faction_druids` on presence, plus the +three ordinary booleans — returned **983 listings** in Allflame. So the category, the type beside +it, the area level and the faction pseudo are all indexed for this category and none of them is a +filter the site accepts and answers with nothing. + +What has **not** been measured, one variable at a time and against this same capture, is +`map_iiq`, `map_packsize` and `map_iir` — see above. diff --git a/docs/testing.md b/docs/testing.md index 0bda0db..1c358b2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -73,6 +73,15 @@ base line says, and `ITEM::SeafloorRidges` is what trade files the chart under internal id, with the `chart` discriminator and no display name on it at all, which is the whole reason `chart_area_key` exists. +The **logbook** entries are the only `pseudo.*` records in the slice that are looked up by +wording: two faction stats and five area stats, plus the seven destination implicits and the five +affixes a rare logbook prints below them. Two of those five affixes are there and still do not +resolve, which is a data-side gap rather than a slicing one — `+#% Monster Chaos Resistance` and +`+#% Monster Elemental Resistances` are published with the sign *inside* the matcher, and +`placeholder_form` replaces the sign along with the digits, so nothing the clipboard prints can +reach them. 34 of the bundle's 15,148 matchers are shaped that way. The pricing case asserts three +hidden affixes for that reason and becomes five the day it is fixed. + The slice's four `GEM::` records need a bundle from `data-20260807.23` or later, which is the release that keys gems on the name the game prints. The transfigured one (`Raise Zombie of Falling`) is the whole point of that field and is the record to check after diff --git a/docs/trade-layer.md b/docs/trade-layer.md index fa5191e..017dcdc 100644 --- a/docs/trade-layer.md +++ b/docs/trade-layer.md @@ -356,10 +356,11 @@ with ImGui about which presses can do anything. **What a strategy leaves out is a collapsed section at the foot of the list**, not nothing. `StatFilter::hidden` and `NumericFilter::hidden` are the flag and `draw_hidden_header` the row that -opens it. Three strategies set it on a modifier they match and then decide the item is not bought +opens it. Four strategies set it on a modifier they match and then decide the item is not bought for: **a map's affixes**, re-rollable with one Chaos Orb and answered by the single copy in the -league that rolled that set; **a beast's monster modifiers**, which are not affixes; and **an -ultimatum's hazards** other than the two that scale the stake. **Sockets and links below five** are +league that rolled that set; **a beast's monster modifiers**, which are not affixes; **an +ultimatum's hazards** other than the two that scale the stake; and **a logbook's own affixes**, +on the map argument exactly. **Sockets and links below five** are the numeric case and the same argument. Every one of those is occasionally the whole question, and before this there was no way to ask it short of the trade site itself. Numerics come first behind the disclosure as they do in front of it, so a row does not change position depending on which of @@ -374,6 +375,24 @@ something it deliberately did not attempt. And `merge_same_stat` **never folds a or a modifier the strategy left out would end up inside the total of one it did not, with the shown row's tick sending both. +**A set of rows the search sends one of is a different thing entirely**, and the list draws it +differently: `SearchPlan::choices` and `StatFilter::choice`, drawn by `draw_choice_row` as a +**radio button** at the head of the list, ahead of the numerics as well as the modifiers. An +Expedition Logbook is the case — up to three destinations, exactly one of which the player +travels to — so those rows are not three questions to answer independently but one question with +three answers, and three checkboxes would invite ticking two and searching for a logbook that +goes to both. **The alternative's own row is its primary filter** rather than a heading over one: a logbook +destination's faction is exactly what picking that destination asks for, so a tickable row +repeating it underneath said the same thing twice and offered to untick what the radio button had +just decided. It is the one line in the list drawn **bold**, being what a reader scans a logbook +for. The **chosen alternative shows the rest of its group** indented under it — the area and the +implicits, offered unticked; the others show their label and where they lead and nothing else, +since expanding all three would bury the choice under nine rows nobody has picked. Clicking +an unchosen one is `SearchPlan::select_choice`, which is the only thing that ticks or unticks a +grouped row. `build_query` again knows nothing about any of it: the other groups are simply +unticked. `merge_same_stat` grows a third divide for the same reason it has the `hidden` one — +two destinations can share a faction or grant one stat, and their total belongs to neither. + **Collapsed for every price check**, held on `App` rather than in ImGui's storage, which is keyed by id and would carry an open section from one item to the next. Six map affixes open by default would bury the two rows that actually price the map, which is the same argument that hid them. diff --git a/scripts/slice-test-bundle.py b/scripts/slice-test-bundle.py index 7fb14d3..36f2725 100755 --- a/scripts/slice-test-bundle.py +++ b/scripts/slice-test-bundle.py @@ -78,6 +78,34 @@ "Map's Item Quantity Modifiers also affect Blight Chest count at #% value\n" "Can be Anointed up to # times", "Natural inhabitants of this area have been removed", + # An Expedition Logbook's destinations. The faction and the area are `pseudo.*` stats and + # nothing else in the game is searched this way — the item prints the two as bare names and + # the join back to a trade id is by the stat's own wording, so the slice needs both halves + # of every destination the captures name. Two factions and five areas covers all three. + "Has Logbook Faction: Druids of the Broken Circle", + "Has Logbook Faction: Order of the Chalice", + "Has Logbook Area: Scrublands", + "Has Logbook Area: Volcanic Island", + "Has Logbook Area: Sarn Slums", + "Has Logbook Area: Battleground Graves", + "Has Logbook Area: Bluffs", + # A destination's implicits. Explosives is the one that matters most: the rare capture + # grants it from two different destinations, which is the case for both the merge refusing + # to fold two alternatives together and the bound being a floor only. + "#% increased number of Explosives", + "#% increased quantity of Artifacts dropped by Monsters", + "#% increased Explosive Radius", + "#% increased Explosive Placement Range", + "Area contains #% increased number of Monster Markers", + "Area contains #% increased number of Runic Monster Markers", + "Remnants have #% chance to have an additional Suffix Modifier", + # And the affixes a rare logbook prints below its destinations, which the plan must leave + # out as a map's are — without them the case would pass by resolving nothing at all. + "Players have #% to all maximum Resistances", + "Monsters' skills Chain # additional times", + "+#% Monster Chaos Resistance", + "+#% Monster Elemental Resistances", + "Monsters gain #% of Maximum Life as Extra Maximum Energy Shield", # The one modifier a Valdo map is searched on, and the only one anything is searched on in # **both** directions: absent, it becomes a `not` group rather than being left open. "Players who Die in area are sent to the Void", @@ -203,6 +231,10 @@ # An itemised sanctum. One floor of the four is enough: they differ only by name, and the # base is the whole of what a sanctum's identity is looked up for. "ITEM::Sanctum Vaults Research", + # An Expedition Logbook. One base for the whole category, which is why the type term says + # nothing a logbook's category does not — and why a magic one arriving as "Buffered + # Expedition Logbook" has to resolve back to this record before anything is sent. + "ITEM::Expedition Logbook", ] UNIQUE_MODS = [ @@ -214,7 +246,7 @@ ITEM_CLASSES = ["Rings", "Boots", "Gloves", "Body Armours", "Stackable Currency", "Divination Cards", "Jewels", "Utility Flasks", "Maps", "Skill Gems", "Support Gems", "Chart", "Misc Map Items", "Contracts", "Blueprints", - "Sanctum Research"] + "Sanctum Research", "Expedition Logbooks"] LANG = "en" diff --git a/src/data/lexicon.cpp b/src/data/lexicon.cpp index aae0325..7bfb012 100644 --- a/src/data/lexicon.cpp +++ b/src/data/lexicon.cpp @@ -19,7 +19,8 @@ constexpr std::array(Term::Count)> kTermKe "prefix_word", "suffix_word", "unique_word", "increased_word", "reduced_word", "req_level", "req_str", "req_dex", "req_int", "cosmetic_prefix", "cosmetic_suffix", "heist_job_prefix", - "heist_job_level", "sanctum_effect_prefix", "augmented", "adds_prefix", + "heist_job_level", "sanctum_effect_prefix", "logbook_faction_prefix", + "logbook_area_prefix", "augmented", "adds_prefix", "fire_damage", "cold_damage", "lightning_damage"}; constexpr std::array(TermList::Count)> kListKeys{ @@ -89,6 +90,7 @@ constexpr ClassKindName kClassKindNames[]{ {"heist_contract", ClassKind::HeistContract}, {"heist_blueprint", ClassKind::HeistBlueprint}, {"sanctum_research", ClassKind::SanctumResearch}, + {"expedition_logbook", ClassKind::ExpeditionLogbook}, }; /// The mod-type suffix the game prints in a parenthetical, indexed by `ModType`. In English @@ -145,6 +147,8 @@ void Lexicon::assign_english() { set(terms_, Term::HeistJobPrefix, "Requires "); set(terms_, Term::HeistJobLevel, " (Level "); set(terms_, Term::SanctumEffectPrefix, "Has "); + set(terms_, Term::LogbookFactionPrefix, "Has Logbook Faction: "); + set(terms_, Term::LogbookAreaPrefix, "Has Logbook Area: "); set(terms_, Term::Augmented, "augmented"); set(terms_, Term::AddsPrefix, "Adds "); set(terms_, Term::FireDamage, "Fire Damage"); @@ -179,7 +183,12 @@ void Lexicon::assign_english() { // The fence at the Rogue Harbour, whom every contract and blueprint is handed to. Their // usage note is two sentences and neither opens with a click instruction, so it came // back as a modifier — and on a unique contract it was the *only* one. - "Adiyah"}; + "Adiyah", + // The runesmith every Expedition Logbook is handed to. Its usage note is one sentence + // opening with "Take this item", the same shape a chart's does — and without a needle + // of its own it came back as a fourth unrecognised modifier, since a logbook is gear + // and gear needs a positive signal before prose is read as anything but a mod. + "Dannig"}; // "Quest Item" and "Divination Card" are printed with a trailing noun on some items, so // the rarity line is matched on a prefix as well as whole. lists_[static_cast(TermList::QuestRarity)] = {"Quest"}; @@ -260,6 +269,7 @@ void Lexicon::assign_english() { {"Contracts", ClassKind::HeistContract}, {"Blueprints", ClassKind::HeistBlueprint}, {"Sanctum Research", ClassKind::SanctumResearch}, + {"Expedition Logbooks", ClassKind::ExpeditionLogbook}, }; } diff --git a/src/data/lexicon.hpp b/src/data/lexicon.hpp index 139e8e2..a2bfed4 100644 --- a/src/data/lexicon.hpp +++ b/src/data/lexicon.hpp @@ -94,7 +94,8 @@ enum class PropertyKey : uint8_t { /// The item classes the app branches on. Every other class is `Other` — the trade category /// comes from the bundle and needs no enum here. enum class ClassKind : uint8_t { - Other, Flask, Map, MapFragment, Chart, HeistContract, HeistBlueprint, SanctumResearch + Other, Flask, Map, MapFragment, Chart, HeistContract, HeistBlueprint, SanctumResearch, + ExpeditionLogbook }; /// A flag the game prints on a line of its own. Influence lines are not among them: they are @@ -149,6 +150,14 @@ enum class Term : uint8_t { /// a stat's wording rather than the client's, and it is here because a translated bundle /// translates it just the same and the lookup is by exact wording. SanctumEffectPrefix, + /// "Has Logbook Faction: " and "Has Logbook Area: ", how the **pseudo stats** an Expedition + /// Logbook is searched on word a destination. Here for the reason `SanctumEffectPrefix` is: + /// the item prints the faction and the area as bare names in a block of their own, the join + /// back to a trade id is by exact stat wording, and a translated bundle translates the stat. + /// Keeping them here is also what avoids a compiled-in list of the four factions — the + /// parser recognises a destination by its shape and the *name* is only ever a lookup key. + LogbookFactionPrefix, + LogbookAreaPrefix, Augmented, ///< the "(augmented)" annotation, the one whose presence is recorded AddsPrefix, ///< "Adds " — which added-damage mod coloured which elemental entry FireDamage, diff --git a/src/item/item.cpp b/src/item/item.cpp index 34ed9f1..1d6b259 100644 --- a/src/item/item.cpp +++ b/src/item/item.cpp @@ -133,6 +133,8 @@ bool Item::is_heist() const { return is_heist_contract() || is_heist_blueprint() bool Item::is_sanctum() const { return class_kind == data::ClassKind::SanctumResearch; } +bool Item::is_logbook() const { return class_kind == data::ClassKind::ExpeditionLogbook; } + bool Item::has_defences() const { return armour || evasion || energy_shield || ward; } diff --git a/src/item/item.hpp b/src/item/item.hpp index 8187748..1ac19d9 100644 --- a/src/item/item.hpp +++ b/src/item/item.hpp @@ -118,6 +118,18 @@ struct Requirements { std::optional level, str, dex, intelligence; }; +/// One destination on an Expedition Logbook: where the expedition goes, whose land it is, and +/// what applies there. A logbook carries up to three and the player travels to **exactly one** +/// of them, which is the whole reason it is a structure rather than three more modifiers. +struct LogbookArea { + std::string area; ///< the area's own name, "Scrublands" + std::string faction; ///< "Druids of the Broken Circle" — what the destination is worth + /// This destination's implicits, as indices into `Item::mods`. They stay in the mod list so + /// the item card draws the logbook in the order the game printed it; this says which of + /// them belong to which destination, which nothing about the modifiers themselves does. + std::vector mods; +}; + struct Item { std::string item_class; ///< as printed: "Thrusting One Hand Swords" /// Which of the classes the app branches on that is, decided by the lexicon while @@ -178,6 +190,8 @@ struct Item { std::optional armour, evasion, energy_shield, ward, block; std::vector mods; + /// An Expedition Logbook's destinations, in printed order. Empty for everything else. + std::vector logbook_areas; std::vector inherent_lines; ///< a flask's own effect; rendered, never searched std::vector description; ///< what a gem or a currency item does std::vector flavour_text; @@ -241,6 +255,13 @@ struct Item { /// what it has picked up along the way. The item class ("Sanctum Research") is its own, as a /// heist item's is. bool is_sanctum() const; + /// An Expedition Logbook — the item class is its own, as a heist item's and a sanctum's are. + /// + /// The one item in the game that is **up to three items at once**: each destination it lists + /// leads somewhere else, belongs to a different faction and is worth a different amount, and + /// the player picks exactly one. Which of them is being priced is a question no clipboard + /// text can answer, so the plan asks it — see `SearchPlan::choices`. + bool is_logbook() const; bool has_defences() const; /// True while the item is an unidentified unique that could be several different items and /// nobody has said which. There is nothing to search until that is answered — the name is diff --git a/src/item/parse.cpp b/src/item/parse.cpp index 956f914..65b9673 100644 --- a/src/item/parse.cpp +++ b/src/item/parse.cpp @@ -586,6 +586,41 @@ bool is_bottom_prose(const Item& it, const Section& sec, size_t index, size_t fl return index == flavour_at && it.rarity == Rarity::Unique && !it.mods.empty(); } +/// A line with nothing on it that could be a modifier: no digit anywhere, no `Label: value` +/// colon, and none of the markers a mod line carries. What an area's name and a faction's name +/// have in common, and what tells them from the roll every affix opens with. +bool is_bare_name(const std::string& line, const data::Lexicon& lex) { + if (line.empty() || is_info_line(line) || line.find(':') != std::string::npos) return false; + std::string_view v = line; + if (take_mod_suffix(v, lex)) return false; + return std::none_of(line.begin(), line.end(), + [](char c) { return std::isdigit(static_cast(c)) != 0; }); +} + +/// A logbook's destination block — an area, the faction whose land it is, and the implicits +/// that apply there: +/// +/// Scrublands +/// Druids of the Broken Circle +/// 32% increased quantity of Artifacts dropped by Monsters (implicit) +/// Area contains 39% increased number of Monster Markers (implicit) +/// +/// **Recognised by shape, not by a table of factions.** There are four of them today and the +/// areas run to dozens, both are game data that a league can add to, and neither is a closed +/// vocabulary this layer could keep honest — so the two leading lines are matched on being +/// *bare names*, which nothing else the item prints in a section of its own is. +/// +/// Two things are checked and both are load-bearing. The **leading pair** keeps the block of +/// affixes every rare logbook prints below its destinations out: those open with a roll. And +/// the **tail** has to read as modifiers, which keeps a stray pair of prose lines out and is +/// what still holds with Advanced Mod Descriptions on, where each implicit gains an info line +/// above it. The number of implicits is deliberately not fixed — captures show two and three. +bool is_logbook_destination(const Section& sec, const data::Lexicon& lex) { + if (sec.size() < 3) return false; + if (!is_bare_name(sec[0], lex) || !is_bare_name(sec[1], lex)) return false; + return looks_like_mods(Section(sec.begin() + 2, sec.end()), lex); +} + void parse_mod_section(const Section& sec, data::ModType fallback, Item& it, const data::Lexicon& lex) { // The mod an info line opened, so its continuation lines land on the same Modifier. @@ -621,6 +656,23 @@ void parse_mod_section(const Section& sec, data::ModType fallback, Item& it, } } +/// Take a destination apart. The two names are the destination's own and go nowhere near the +/// mod list; the implicits under them do, so that the item card draws the logbook exactly as +/// the game printed it and every layer above keeps one list of modifiers to walk. +void parse_logbook_destination(const Section& sec, Item& it, const data::Lexicon& lex) { + LogbookArea dest; + dest.area = sec[0]; + dest.faction = sec[1]; + const size_t first = it.mods.size(); + // Implicit as the fallback rather than Explicit: every line here carries the game's own + // " (implicit)" suffix today, and where an info line has grouped them instead the + // generation word says the same thing — but a destination has no other kind of modifier, + // and falling back to Explicit would file one in the wrong trade namespace if it ever did. + parse_mod_section(Section(sec.begin() + 2, sec.end()), data::ModType::Implicit, it, lex); + for (size_t i = first; i < it.mods.size(); ++i) dest.mods.push_back(i); + it.logbook_areas.push_back(std::move(dest)); +} + /// Colour the elemental damage entries. The property line lists them in the game's fixed /// fire, cold, lightning order but never names them, so the mods have to say which are there. void infer_elemental_kinds(Item& it, const data::Lexicon& lex) { @@ -710,6 +762,11 @@ std::optional parse_item(std::string_view clipboard, const data::Lexicon& // Consumes 40 of 60 Charges on use / Onslaught" read as modifiers instead. parse_properties(sec, it, lex); props_seen = true; + } else if (it.is_logbook() && is_logbook_destination(sec, lex)) { + // Where this logbook can go. Ahead of the mod branch below, which read the area and + // the faction as two unmatchable modifiers apiece and lost which implicits belonged + // to which destination — the one thing a logbook is priced on. + parse_logbook_destination(sec, it, lex); } else if (it.rarity == Rarity::Gem && it.vaal_name.empty() && sec.size() == 1 && first.starts_with(lex.term(data::Term::VaalPrefix))) { // A Vaal gem is two skills in one, and the game heads the second half with its own diff --git a/src/item/plan.cpp b/src/item/plan.cpp index 3a9dbef..a6d4c94 100644 --- a/src/item/plan.cpp +++ b/src/item/plan.cpp @@ -15,8 +15,8 @@ namespace ppc::item { namespace { constexpr std::array(Strategy::Unsupported) + 1> - kStrategies{"Base item", "Modifiers", "Unique", "Currency", "Gem", "Map", - "Beast", "Ultimatum", "Heist", "Sanctum", "Unsupported"}; + kStrategies{"Base item", "Modifiers", "Unique", "Currency", "Gem", "Map", + "Beast", "Ultimatum", "Heist", "Sanctum", "Logbook", "Unsupported"}; /// How many decimals `v` needs to survive being printed. Rolls are at most hundredths. int decimals_needed(double v) { @@ -570,7 +570,12 @@ void merge_same_stat(std::vector& stats) { // Never across the divide: a hidden filter folded into a shown one would put a // modifier the strategy left out into the total of one it did not, and the row's // tick would then be sending both. - if (stats[j].id != stats[i].id || stats[j].hidden != stats[i].hidden) { + // Never across a choice either, and for a sharper version of the same reason: two + // of a logbook's destinations can grant one stat, or belong to one faction, and + // summing those gives a number no single destination has — while the whole point of + // the group is that only one destination is ever being asked about. + if (stats[j].id != stats[i].id || stats[j].hidden != stats[i].hidden || + stats[j].choice != stats[i].choice) { ++j; continue; } @@ -1183,6 +1188,149 @@ void plan_sanctum(const data::GameData& gd, const Item& it, SearchPlan& p) { add_sanctum_effects(gd, it, p); } +/// The pseudo stat a logbook's faction or area name is searched under — `Has Logbook Faction: +/// Druids of the Broken Circle`, which is the site's own wording for it. Null when this bundle +/// has no such stat, which the caller says out loud rather than dropping: the area list grows +/// with the league and a name nobody has published a stat for is a real gap. +const data::Stat* logbook_stat(const data::GameData& gd, data::Term prefix, + std::string_view name) { + const std::string_view p = gd.lexicon().term(prefix); + if (p.empty() || name.empty()) return nullptr; + const data::Stat* s = gd.find_stat_by_ref(std::string(p) + std::string(name)); + return s && s->has(data::ModType::Pseudo) ? s : nullptr; +} + +/// An Expedition Logbook: **which of its destinations is being priced**, and what the whole +/// book is worth wherever it goes. +/// +/// A logbook is up to three items in one. Each destination names an area, the faction whose +/// land it is and the implicits that apply there; the player travels to exactly one of them, +/// and the faction is what decides what that is worth — which is why a split logbook, two of +/// whose destinations share a valuable faction, is a thing people pay for. Nothing in the +/// clipboard says which destination the reader has in mind, so the plan does not guess: the +/// destinations are `SearchPlan::choices` and the panel asks. +/// +/// Inside a destination, only the **faction** is ticked. Where it goes and what it grants there +/// are offered — a buyer picking a faction is rarely picking an area with it, and an implicit +/// is one of two or three numbers that came with the area rather than something anybody chose. +/// +/// What the book grants wherever it goes: +/// - **The area level**, a floor and ticked. Unlike a map's tier or a sanctum's floor, a higher +/// one is not a different product but strictly more of the same one, and a buyer at 80 takes +/// an 83. +/// - **The item level**, offered. It bounds what the affixes can be crafted to and is a +/// question about crafting the book rather than about running it. +/// - **Quantity, rarity and pack size**, offered, all three. They are the same three properties +/// a map is searched on and they come off the same `map_filters` keys — but a map's are the +/// whole of what it is run for, and a logbook's are a second-order bonus on top of the +/// artifacts, which is what the destination decides. Left unticked until someone has measured +/// that the site indexes them for this category at all; a filter it accepts and indexes +/// nothing under empties the search, exactly as `heist_max_escape_routes` does. +/// +/// The **affixes it prints below the destinations** are the map argument, and get the map's +/// answer: they apply wherever it goes, they are re-rollable — a logbook is craftable, which is +/// most of what the affix crafting market is — and a query naming them finds the one copy in +/// the league that rolled that set. So they are `hidden`, offered under the section at the foot +/// of the list rather than dropped on the floor. +void plan_logbook(const data::GameData& gd, const Item& it, SearchPlan& p) { + // The category is the whole search on its own — one base type is filed under `logbook` — + // so the type term is sent only where the bundle resolved it, never off the printed line. + // A magic logbook's line is "Buffered Expedition Logbook", and that as a type matches + // nothing, which reads as nobody selling one. + if (it.base) { + p.type = base_wire_name(it); + if (!it.base->trade_disc.empty()) p.discriminator = it.base->trade_disc; + } + + if (const Property* lvl = property_of(it, data::PropertyKey::AreaLevel); lvl && lvl->num) + add_numeric(p, "area_level", lvl->label, *lvl->num, true); + add_numeric(p, "ilvl", "Item Level", + it.item_level ? std::optional(*it.item_level) : std::nullopt, false); + static constexpr std::pair kBonuses[]{ + {data::PropertyKey::ItemQuantity, "map_iiq"}, + {data::PropertyKey::MonsterPackSize, "map_packsize"}, + {data::PropertyKey::ItemRarity, "map_iir"}, + }; + for (const auto& [key, filter] : kBonuses) + if (const Property* prop = property_of(it, key); prop && prop->num) + add_numeric(p, filter, prop->label, *prop->num, false); + + for (size_t d = 0; d < it.logbook_areas.size(); ++d) { + const LogbookArea& dest = it.logbook_areas[d]; + ChoiceGroup g; + g.label = dest.faction; + g.note = dest.area; + p.choices.push_back(std::move(g)); + + const auto pseudo = [&](data::Term prefix, std::string_view name, bool primary) { + const data::Stat* s = logbook_stat(gd, prefix, name); + if (!s) { + p.notes.push_back("\"" + std::string(name) + + "\" is not a logbook faction or area the trade site knows in " + "this data bundle, so the search does not ask for it"); + return; + } + StatFilter f; + f.id = s->trade_ids(data::ModType::Pseudo).front(); + // The stat's own wording rather than the bare name: it is what the site's filter is + // called, and "Druids of the Broken Circle" on a row of its own says nothing about + // being a faction. The item beside the panel is where the block itself is read. + f.text = s->ref; + f.type = data::ModType::Pseudo; + f.choice = d; + f.choice_primary = primary; + // **Presence, not a count.** The pseudo stat takes a value — how many of the + // destinations belong to that faction — and it is not what decides the price: a + // logbook with two Druids destinations is still bought for a Druids run. Bounding + // it would drop every single-destination copy of the same thing. + p.stats.push_back(std::move(f)); + }; + pseudo(data::Term::LogbookFactionPrefix, dest.faction, true); + pseudo(data::Term::LogbookAreaPrefix, dest.area, false); + } +} + +/// Whether modifier `index` came out of one of the logbook's destination blocks. Everything +/// else it prints is an affix of the book itself, which applies wherever it goes. +bool logbook_destination_mod(const Item& it, size_t index) { + return std::any_of(it.logbook_areas.begin(), it.logbook_areas.end(), + [index](const LogbookArea& a) { + return std::find(a.mods.begin(), a.mods.end(), index) != a.mods.end(); + }); +} + +/// Hand every destination's implicits to the destination they came from, once the ordinary mod +/// pass has turned them into filters. +/// +/// Two things happen here that could not happen in `to_filter`, which sees one modifier at a +/// time and nothing about the block it was printed in. +/// +/// The **upper bound goes**. Trade indexes an item's implicits as one total per stat, and all +/// three destinations feed the same total — the rare capture grants "increased number of +/// Explosives" twice, at 14% and 16%, and the site sees 30%. A floor still matches under that, +/// since the total can only exceed the destination's own roll; a ceiling seeded from one +/// destination's roll asks the other two not to exist. Which side is the floor is the stat's +/// own `better`, the same question `to_filter` asks when it decides which way an open bound +/// faces. +/// +/// And the row **joins its group**, which is what keeps `merge_same_stat` from folding two +/// destinations' rolls of one stat into a sum belonging to neither. +void group_logbook_mods(const Item& it, SearchPlan& p) { + for (StatFilter& f : p.stats) { + if (!f.mod_index || f.hidden || f.choice) continue; + for (size_t d = 0; d < it.logbook_areas.size(); ++d) { + const std::vector& mods = it.logbook_areas[d].mods; + if (std::find(mods.begin(), mods.end(), *f.mod_index) == mods.end()) continue; + f.choice = d; + f.enabled = false; + const data::StatMatch& m = *it.mods[*f.mod_index].match; + if (m.stat && m.stat->better < 0) f.min.reset(); + else f.max.reset(); + break; + } + } +} + /// The property a Valdo's Puzzle Box map states its payout in, or null on any other map. No /// other map prints one, which is what makes it the marker as well as the thing searched for. const Property* reward_property(const Item& it) { @@ -1514,6 +1662,16 @@ const OptionFilter* SearchPlan::option(std::string_view key) const { return nullptr; } +void SearchPlan::select_choice(size_t i) { + if (i >= choices.size()) return; + choice = i; + // Every grouped row is decided here, both the ones being turned off and the one being + // turned on, so there is no state to get out of step: a row is enabled exactly when it is + // the live group's primary. Rows with no group are none of this function's business. + for (StatFilter& f : stats) + if (f.choice) f.enabled = *f.choice == i && f.choice_primary; +} + Strategy default_strategy(const Item& it) { // A map is priced on none of the things a rare is, at any rarity it prints: pricing one as // gear would search for a chest piece carrying map modifiers. A **chart** is the same item @@ -1541,6 +1699,12 @@ Strategy default_strategy(const Item& it) { // for an empty Sanctum Vaults Research at this item level, which is every run in the league // and none of what tells two apart. Its affixes are real, and so is everything else on it. if (it.is_sanctum()) return Strategy::Sanctum; + // A logbook prints Normal, Magic or Rare and is none of those readings. Its affixes are real + // and re-rollable, its base is the one base in the category, and what a buyer is choosing + // between is which of its up-to-three destinations they mean — a question the rarity switch + // below has nowhere to put, and which would leave the faction as an unmatchable line of + // prose and the price as whatever a logbook of that item level goes for. + if (it.is_logbook()) return Strategy::Logbook; // A map item splits on whether it prints an **item level**, which is what says whether it // is a bulk good or an item. A scarab, an ember, a splinter or a breachstone prints none: // every copy is identical, there is nothing to filter on, and they change hands on the @@ -1632,6 +1796,7 @@ SearchPlan build_plan(const data::GameData& gd, const Item& it, const Derived& d case Strategy::Ultimatum: plan_ultimatum(gd, it, p); break; case Strategy::Heist: plan_heist(gd, it, p); break; case Strategy::Sanctum: plan_sanctum(gd, it, p); break; + case Strategy::Logbook: plan_logbook(gd, it, p); break; default: // Currency is priced by poe.ninja and by the in-game exchange rather than by a stat // query — bulk is what it sells in, and a stat filter has nothing to say about a @@ -1674,10 +1839,15 @@ SearchPlan build_plan(const data::GameData& gd, const Item& it, const Derived& d // section at the foot of the list rather than on the floor. Unticked, so the search // is exactly what it was; ticked, they are ordinary filters. Every one of these is // occasionally the whole question, and there was no way to ask it here before. + // - **A logbook's own affixes**, on the map argument exactly: they apply wherever + // the book goes, a Chaos Orb redoes them, and what is being bought is where it + // goes. Its destinations' implicits are not among them — those belong to a + // destination, and `group_logbook_mods` files each under the one it came from. const bool hidden = (p.strategy == Strategy::Map && !map_searched_mod(it.mods[i])) || p.strategy == Strategy::Beast || - (p.strategy == Strategy::Ultimatum && !ultimatum_stake_mod(it.mods[i])); + (p.strategy == Strategy::Ultimatum && !ultimatum_stake_mod(it.mods[i])) || + (p.strategy == Strategy::Logbook && !logbook_destination_mod(it, i)); if (std::optional f = to_filter(it, i, p.strategy, ranges_printed, rm)) { if (hidden) { f->hidden = true; @@ -1718,6 +1888,10 @@ SearchPlan build_plan(const data::GameData& gd, const Item& it, const Derived& d // Before the merge, while every filter still points at the modifier it came from. if (p.strategy == Strategy::Unique) apply_unique_mods(gd, it, p, rm); if (p.strategy == Strategy::Map) add_map_pseudo(it, p); + // Before the merge as well, and for the same reason `apply_unique_mods` is: it reads + // `mod_index`, and merging is what makes one filter stand for several modifiers. It is + // also what tells the merge which rows are alternatives rather than repeats. + if (p.strategy == Strategy::Logbook) group_logbook_mods(it, p); merge_same_stat(p.stats); } @@ -1745,6 +1919,12 @@ SearchPlan build_plan(const data::GameData& gd, const Item& it, const Derived& d if (!it.inherent_lines.empty() && it.rarity != Rarity::Gem) p.notes.emplace_back("the base's own effect is not part of the search"); + // The first destination is live until the reader says otherwise, and it is the *first* on + // purpose: nothing here can rank the four factions, that ranking changes with the league and + // with what the player is farming, and a default dressed up as an answer would be read as + // one. The panel puts the alternatives in the game's own order and the choice is one click. + if (!p.choices.empty()) p.select_choice(0); + // Last of all, and in one place rather than at each of the dozen sites that set a bound: // the seed is *whatever this function came out with*, so a per-row reset can only ever // disagree with the plan if it is recorded somewhere the plan can still be changed after. diff --git a/src/item/plan.hpp b/src/item/plan.hpp index d2cf717..0ca235c 100644 --- a/src/item/plan.hpp +++ b/src/item/plan.hpp @@ -26,6 +26,7 @@ enum class Strategy : uint8_t { Ultimatum, ///< the trial, its stake and its payout, plus the two mods that scale the stake Heist, ///< a contract or blueprint: the area, what is revealed, and what it demands Sanctum, ///< a run in progress: how far it has got, and the boons and afflictions it holds + Logbook, ///< an expedition: one of its destinations, and what the whole book grants Unsupported }; @@ -69,6 +70,22 @@ struct StatFilter { /// ever worth asking where its absence is itself the thing being bought: a Valdo map that /// does not void the character who dies in it. Carries no bounds; there is no roll. bool negated = false; + /// One of a set of alternatives only ever asked **one at a time** — an index into + /// `SearchPlan::choices` — or absent, which is every row on every other plan. + /// + /// An Expedition Logbook is the case and so far the only one: it lists up to three + /// destinations, the player travels to exactly one, and each is worth a different amount. + /// Asking for all three at once prices the one copy in the league that leads to that exact + /// trio; asking for none prices every logbook in the league. So the rows are grouped and + /// `select_choice` keeps exactly one group live. + std::optional choice; + /// Within a group, the filter that picking the group *is* — the faction, which is what a + /// destination is worth. **It has no row of its own**: the alternative's own row is that + /// filter, so drawing it again underneath said the same thing twice and offered to untick + /// what the choice had just decided. Everything else in the group (where it goes, what it + /// grants there) is an ordinary row, offered unticked, because a buyer choosing a faction + /// is rarely choosing an area with it. + bool choice_primary = false; int dp = 0; /// What this modifier *can* roll, whichever source said so: the affix tier's own range @@ -137,6 +154,17 @@ struct OptionFilter { bool shown = false; ///< offered as a row the user can untick, rather than imposed silently }; +/// A set of filters the search sends **one of**. The item holds several answers to one +/// question and only one of them is what is being priced. +/// +/// Nothing about the wire format changes for these: `trade::build_query` reads `enabled` and +/// knows nothing about the grouping, exactly as it knows nothing about `hidden`. The exclusion +/// is `SearchPlan::select_choice` keeping the other groups unticked. +struct ChoiceGroup { + std::string label; ///< what the group is called on its row: "Druids of the Broken Circle" + std::string note; ///< the second line under it — a logbook destination's area +}; + /// Everything a search for this item would ask for. Purely declarative — building the trade /// query JSON out of this, and running it, is the next layer up. struct SearchPlan { @@ -168,9 +196,20 @@ struct SearchPlan { /// dropped filter reads as a successful price check on the wrong item. std::vector notes; + /// The mutually exclusive alternatives, in printed order, and which of them is live. Empty + /// on every plan but a logbook's, where it is the destination being priced. + std::vector choices; + size_t choice = 0; + bool has_enabled_stats() const; /// The option filter under `key`, or null. Also the answer to "is this asked at all". const OptionFilter* option(std::string_view key) const; + + /// Make `i` the live alternative: tick its primary row, untick every row of every other + /// group. The optional rows of the newly chosen group come back unticked, which is what + /// they were seeded as — switching destination is choosing a different question, not + /// carrying the last one's answers onto it. + void select_choice(size_t i); }; /// The strategy an item gets unless the user overrides it. diff --git a/src/screens/item_view.cpp b/src/screens/item_view.cpp index ea26c0d..3008776 100644 --- a/src/screens/item_view.cpp +++ b/src/screens/item_view.cpp @@ -317,6 +317,30 @@ void draw_mods(const Item& it, const Fonts& fonts, const data::Lexicon& lex, } } +/// An Expedition Logbook's destinations, each behind its own rule exactly as the game prints +/// them: where it goes, whose land it is, and what applies there. +/// +/// Drawn here rather than in the implicit block, and that is the whole reason it exists — +/// **which implicit belongs to which destination** is what a logbook is priced on, and the mod +/// list holds all six in one run with nothing in it saying where each came from. Read as one +/// block they are the sum of three places the item can go to, only one of which is being sold. +void draw_logbook_destinations(const Item& it, const Fonts& fonts, const data::Lexicon& lex) { + for (const item::LogbookArea& dest : it.logbook_areas) { + draw_rule(); + draw_line(dest.area, kColValue); + draw_line(dest.faction, kColValue); + for (const size_t i : dest.mods) { + if (i >= it.mods.size()) continue; + const Modifier& m = it.mods[i]; + ImGui::BeginGroup(); + for (const std::string& l : m.lines) + draw_line(strip_roll_ranges(l), mod_colour(m.type)); + ImGui::EndGroup(); + draw_hover_tip(join_lines(m.info_text(lex), m.reminder), fonts); + } + } +} + bool has_mods(const Item& it, std::initializer_list types) { for (const Modifier& m : it.mods) if (in(types, m.type)) return true; @@ -374,6 +398,9 @@ void draw_item_tooltip(const Item& it, const item::Derived& d, const Fonts& font draw_segments({{"Item Level: ", kColLabel}, {std::to_string(*it.item_level), kColValue}}); } + // A logbook prints its implicits inside the destinations, which is where they mean anything. + if (it.is_logbook()) draw_logbook_destinations(it, fonts, lex); + // Blocks in the order the game prints them, each behind its own rule. Explicit, fractured, // crafted and veiled mods share one block, as on the item. for (const std::initializer_list block : @@ -383,6 +410,7 @@ void draw_item_tooltip(const Item& it, const item::Derived& d, const Fonts& font data::ModType::Veiled}, {data::ModType::Scourge}, {data::ModType::Crucible}}) { + if (it.is_logbook() && in(block, data::ModType::Implicit)) continue; if (!has_mods(it, block)) continue; draw_rule(); draw_mods(it, fonts, lex, block); diff --git a/src/screens/pricecheck_screen.cpp b/src/screens/pricecheck_screen.cpp index 92bd658..8ba20b5 100644 --- a/src/screens/pricecheck_screen.cpp +++ b/src/screens/pricecheck_screen.cpp @@ -200,7 +200,10 @@ void draw_strategy_picker(App& app, const item::Item& it, item::SearchPlan& plan // modifiers are the danger the run holds and its base is the area, and the site indexes it // on a group of filters that only the heist strategy fills in. Offering the pair drew two // radio buttons with neither of them selected. - if (!it.is_map() && !it.is_chart() && !it.is_beast() && !it.is_heist() && + // Nor an Expedition Logbook, which is magic or rare and is neither reading either: its + // affixes apply wherever it goes and its base is the one base in the category, so both + // readings would search past the destinations that are the whole of what it is worth. + if (!it.is_map() && !it.is_chart() && !it.is_beast() && !it.is_heist() && !it.is_logbook() && (it.rarity == item::Rarity::Magic || it.rarity == item::Rarity::Rare)) { for (const item::Strategy s : {item::Strategy::Modifiers, item::Strategy::BaseItem}) { const bool on = plan.strategy == s; @@ -256,13 +259,21 @@ struct RowClick { /// its first: the row's height is not known until its four cells have been drawn. RowClick draw_filter_row(int id, bool& enabled, const Origin& o, const std::string& text, const std::string& note, const std::string& asks, - const std::string& caveat = {}, bool editable = false) { + const std::string& caveat = {}, bool editable = false, + float indent = 0.0f) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); const ImVec2 origin = ImGui::GetCursorScreenPos(); float bottom = origin.y; const auto reached = [&bottom] { bottom = std::max(bottom, ImGui::GetCursorScreenPos().y); }; + // Under the alternative it belongs to, rather than level with the rows that are asked + // unconditionally. The row still starts its hit test at the cell's left edge, so the + // indent costs the click target nothing. + if (indent > 0.0f) { + ImGui::Dummy(ImVec2(indent, 0.0f)); + ImGui::SameLine(0.0f, 0.0f); + } ImGui::PushID(id); // A box the height of a line of text rather than of a framed widget. This is a list of // modifiers with a tick beside each, and at the default frame padding the tick is taller @@ -661,6 +672,46 @@ bool draw_hidden_header(App& app, size_t n) { return open; } +/// How far a row belonging to an alternative sits in from the ones asked unconditionally. +constexpr float kChoiceIndent = 14.0f; + +/// One alternative's own row: a **radio button**, the thing it is (a logbook's faction) and +/// where that leads (the area), on the line under it. +/// +/// A radio and not a tick, because that is the whole statement the row makes. An Expedition +/// Logbook lists up to three destinations and the player travels to exactly one — so the rows +/// underneath are not three questions to answer independently, they are one question with +/// three answers, and three checkboxes would invite ticking two and searching for a logbook +/// that goes to both. Which is a real item and never the one in hand. +/// +/// The unchosen alternatives keep their row and lose their contents: the area under the label +/// is what says where each of them leads, and expanding all three would bury the choice under +/// nine rows of things nobody has picked yet. +/// +/// **This row is the primary filter, not a heading above it.** The faction a logbook's +/// destination belongs to is exactly what picking that destination asks for, so drawing it again +/// underneath as a tickable row said the same thing twice and offered to untick a thing the +/// radio button had just decided. `StatFilter::choice_primary` is that filter and it has no row +/// of its own — this is it. Bold, because it is the one line here a reader scans a logbook for. +bool draw_choice_row(int id, bool selected, const item::ChoiceGroup& g, const Fonts& fonts) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(id); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0)); + const bool pressed = ImGui::RadioButton("", selected); + ImGui::PopStyleVar(); + ImGui::PopID(); + + ImGui::TableSetColumnIndex(1); + ImGui::PushTextWrapPos(0.0f); + ImGui::PushFont(fonts.bold, 0.0f); + ImGui::TextUnformatted(g.label.c_str()); + ImGui::PopFont(); + if (!g.note.empty()) ImGui::TextColored(kDim, "%s", g.note.c_str()); + ImGui::PopTextWrapPos(); + return pressed && !selected; +} + /// The filter list, as a table so that every row's numbers sit under the previous row's. What /// the search asks for is the **last** column and not part of the origin beside the code: it is /// the one thing here that the user will be editing. @@ -708,9 +759,7 @@ void draw_filters(App& app, const item::Item& it, item::SearchPlan& plan) { c.clicked) app.edit_filter(FilterEdit::Kind::Numeric, i, c.top, c.bottom); }; - for (size_t i = 0; i < plan.numerics.size(); ++i) - if (!plan.numerics[i].hidden) numeric_row(i); - const auto stat_row = [&](size_t i) { + const auto stat_row = [&](size_t i, float indent = 0.0f) { item::StatFilter& f = plan.stats[i]; // Why this one is ticked on a unique whose other modifiers are not: the item picked // it out of a pool, so it is what separates this copy from every other. @@ -724,16 +773,35 @@ void draw_filters(App& app, const item::Item& it, item::SearchPlan& plan) { static_cast(1000 + i), f.enabled, origin_of(it, f), strip_roll_ranges(f.text), note, f.negated ? "absent" : filter_text(f.min, f.max, f.dp, glyphs), f.caveat, - rows_live && has_interval(f)); + rows_live && has_interval(f), indent); c.clicked) app.edit_filter(FilterEdit::Kind::Stat, i, c.top, c.bottom); }; + // The alternatives lead, ahead of the numerics as well as the modifiers: on a logbook + // they are what the item *is*, and everything below them — the area level, the book's + // own affixes — is the same wherever it goes. The chosen one shows its rows; the others + // show where they lead and nothing else. Empty on every other item, and then this + // leaves the list exactly as it was. + for (size_t g = 0; g < plan.choices.size(); ++g) { + if (draw_choice_row(static_cast(3000 + g), g == plan.choice, plan.choices[g], + app.fonts())) + plan.select_choice(g); + if (g != plan.choice) continue; + // Not the primary: the row above *is* that filter, and drawing it again would + // offer to untick what the radio button has just decided. + for (size_t i = 0; i < plan.stats.size(); ++i) + if (plan.stats[i].choice == g && !plan.stats[i].hidden && + !plan.stats[i].choice_primary) + stat_row(i, kChoiceIndent); + } + for (size_t i = 0; i < plan.numerics.size(); ++i) + if (!plan.numerics[i].hidden) numeric_row(i); size_t hidden = 0; for (const item::NumericFilter& f : plan.numerics) if (f.hidden) ++hidden; for (size_t i = 0; i < plan.stats.size(); ++i) { if (plan.stats[i].hidden) ++hidden; - else stat_row(i); + else if (!plan.stats[i].choice) stat_row(i); // drawn under its alternative, above } // Numerics first behind the disclosure as well as in front of it, so a row does not move // between the two lists depending on which one it is in. diff --git a/src/trade/query.cpp b/src/trade/query.cpp index 52284cc..86cffdd 100644 --- a/src/trade/query.cpp +++ b/src/trade/query.cpp @@ -133,6 +133,11 @@ bool searchable(const item::SearchPlan& p) { // when the bundle cannot name one is still a real question, because resolve, aureus and // the boons are what tell two runs apart and none of them lives in the type. case item::Strategy::Sanctum: return !p.type.empty() || !p.category.empty(); + // A logbook is one base type under a category of its own, so either term is the whole + // set and the destination filters are what narrow it. Unlike an ultimatum, the coarse + // search is still a real question — "some logbook at area level 83" is a market — so + // the type is not required to have brought a filter with it. + case item::Strategy::Logbook: return !p.type.empty() || !p.category.empty(); case item::Strategy::Gem: return !p.type.empty(); // Currency is bought in bulk and has nothing a stat query could ask about — except the // one kind that is not interchangeable, which the plan says so about by naming a type. diff --git a/tests/data/bundle/en-items-name.index.bin b/tests/data/bundle/en-items-name.index.bin index 475f0c8e1249161e745b99f5dc5a1e27de47f2f4..a052016abae757fe32d82a12304ee987efd0133b 100644 GIT binary patch delta 18 Zcmcb?^nhuD0wY`58q3S_@{{EltpGoK1?&I- delta 11 ScmaFBbc1Pw0^?*2MhgHL`U8Rh diff --git a/tests/data/bundle/en-items-ref.index.bin b/tests/data/bundle/en-items-ref.index.bin index 475f0c8e1249161e745b99f5dc5a1e27de47f2f4..a052016abae757fe32d82a12304ee987efd0133b 100644 GIT binary patch delta 18 Zcmcb?^nhuD0wY`58q3S_@{{EltpGoK1?&I- delta 11 ScmaFBbc1Pw0^?*2MhgHL`U8Rh diff --git a/tests/data/bundle/en-items.ndjson b/tests/data/bundle/en-items.ndjson index 63b69f1..2cca2f6 100644 --- a/tests/data/bundle/en-items.ndjson +++ b/tests/data/bundle/en-items.ndjson @@ -41,3 +41,4 @@ {"craftable":{"category":"Contracts"},"dropLevel":80,"h":1,"metadataId":"Metadata/Items/Heist/QuestContracts/HeistContractQuestWhakanoRepeatable","name":"Vigilante Contract","namespace":"ITEM","refName":"Vigilante Contract","w":1} {"art":"Art/2DItems/Currency/Heist/SlaveMerchantFightContract.png","name":"Contract: The Slaver King","namespace":"UNIQUE","refName":"Contract: The Slaver King","unique":{"base":"Vigilante Contract"}} {"craftable":{"category":"Sanctum Research"},"dropLevel":68,"h":1,"metadataId":"Metadata/Items/Sanctum/SanctumFloor2","name":"Sanctum Vaults Research","namespace":"ITEM","refName":"Sanctum Vaults Research","w":1} +{"craftable":{"category":"Expedition Logbooks"},"dropLevel":1,"h":1,"metadataId":"Metadata/Items/Expedition/ExpeditionLogbook","name":"Expedition Logbook","namespace":"ITEM","refName":"Expedition Logbook","w":1} diff --git a/tests/data/bundle/en-stats-matcher.index.bin b/tests/data/bundle/en-stats-matcher.index.bin index d3bddcaac515e6c37f189b3bd54d1e6847d432c7..e75aa5ad5fe9079617399c729d41f10ff64135cc 100644 GIT binary patch delta 462 zcmdnN{DFmI`-TM!p}GtV6D0JGaz7FlfE)I(k6c{SM8Pkz-(ZnIAIyBaohP zv1h>qDF%j$yJiawt%1JUy=v)1B?g9~Qo|&^A}& zI>_^$>fJY_fPv{YG2t%A^IQ_)kA#8x8r!9wpXO&^&%GNOq0Bl&TeEkFf07D03eLpt<0Ep~*fC4Z8052|zhq@{N04@7~k-Rbh0Q|(vk;gCq z02dvLk|Y8E0JZc*mMks+02EHbnH(Mf0O@a3oADk10GQE?ppY5>0C?Rtp)orE0A;(X zrI8{40CLLHv8^cpk?$2kKQ90Pwwvt689D#}0$g*+qbmRaPE^Ip+7|!-B3Mq&k}&`P z%!sYdNhJUPDW}NKO&tILhMg$aU^D;#Nhgcg8X=P|0Tv|BHvj-5wrlQJ82|u}IY;wX W0{{TK5vcTKAOHZNJUIRHH~;`=%z7pO delta 276 zcmV+v0qg$w1KCIA44?0SIT9smF>`+$*|ApijU#LSVT9{>Os9gC7A z0ssJ*(Tt#w8UO%z-8P{PDgXcyfX%)w6#xL!ymG&C761U*F?7Zp9smHgo9xE+C;$LX zRK?2L7XSbvSWeDwAOHXX10SO$eCIA2< awrlQJ82|u}IY;wX0{{S_JUIQ(CjbE8L|0h= diff --git a/tests/data/bundle/en-stats.ndjson b/tests/data/bundle/en-stats.ndjson index b2b5230..1e4a27f 100644 --- a/tests/data/bundle/en-stats.ndjson +++ b/tests/data/bundle/en-stats.ndjson @@ -25,6 +25,25 @@ {"better":1,"matchers":[{"string":"Monsters have #% chance to Hinder on Hit with Spells"},{"string":"Monsters Hinder on Hit with Spells","value":100.0}],"ref":"Monsters have #% chance to Hinder on Hit with Spells","trade":{"ids":{"explicit":["explicit.stat_962720646"],"fractured":["fractured.stat_962720646"]}}} {"better":1,"matchers":[{"string":"Area is infested with Fungal Growths\nMap's Item Quantity Modifiers also affect Blight Chest count at #% value\nCan be Anointed up to # times"}],"ref":"Area is infested with Fungal Growths\nMap's Item Quantity Modifiers also affect Blight Chest count at #% value\nCan be Anointed up to # times","trade":{"ids":{"implicit":["implicit.stat_299373046"]}}} {"better":1,"matchers":[{"string":"Natural inhabitants of this area have been removed"}],"ref":"Natural inhabitants of this area have been removed","trade":{"ids":{"implicit":["implicit.stat_2656027173"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Faction: Druids of the Broken Circle"}],"ref":"Has Logbook Faction: Druids of the Broken Circle","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_faction_druids"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Faction: Order of the Chalice"}],"ref":"Has Logbook Faction: Order of the Chalice","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_faction_order"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Area: Scrublands"}],"ref":"Has Logbook Area: Scrublands","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_area_scrublands"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Area: Volcanic Island"}],"ref":"Has Logbook Area: Volcanic Island","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_area_volcano"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Area: Sarn Slums"}],"ref":"Has Logbook Area: Sarn Slums","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_area_sarn_slums"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Area: Battleground Graves"}],"ref":"Has Logbook Area: Battleground Graves","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_area_battleground_graves"]}}} +{"better":1,"matchers":[{"string":"Has Logbook Area: Bluffs"}],"ref":"Has Logbook Area: Bluffs","trade":{"ids":{"pseudo":["pseudo.pseudo_logbook_area_bluffs"]}}} +{"better":1,"matchers":[{"string":"#% increased number of Explosives"}],"ref":"#% increased number of Explosives","trade":{"ids":{"explicit":["explicit.stat_3051490307"],"implicit":["implicit.stat_3051490307"]}}} +{"better":1,"matchers":[{"string":"#% increased quantity of Artifacts dropped by Monsters"},{"string":"#% reduced quantity of Artifacts dropped by Monsters"}],"ref":"#% increased quantity of Artifacts dropped by Monsters","trade":{"ids":{"explicit":["explicit.stat_4219583418"],"implicit":["implicit.stat_4219583418"]}}} +{"better":1,"matchers":[{"string":"#% increased Explosive Radius"}],"ref":"#% increased Explosive Radius","trade":{"ids":{"explicit":["explicit.stat_3289828378"],"implicit":["implicit.stat_3289828378"]}}} +{"better":1,"matchers":[{"string":"#% increased Explosive Placement Range"},{"string":"#% reduced Explosive Placement Range"}],"ref":"#% increased Explosive Placement Range","trade":{"ids":{"explicit":["explicit.stat_1539368271"],"implicit":["implicit.stat_1539368271"]}}} +{"better":1,"matchers":[{"string":"Area contains #% increased number of Monster Markers"},{"string":"Area contains #% reduced number of Monster Markers"}],"ref":"Area contains #% increased number of Monster Markers","trade":{"ids":{"implicit":["implicit.stat_1915989164"]}}} +{"better":1,"matchers":[{"string":"Area contains #% increased number of Runic Monster Markers"},{"string":"Area contains #% reduced number of Runic Monster Markers"}],"ref":"Area contains #% increased number of Runic Monster Markers","trade":{"ids":{"enchant":["enchant.stat_1640965354"],"explicit":["explicit.stat_1640965354"],"implicit":["implicit.stat_1640965354"]}}} +{"better":1,"matchers":[{"string":"Remnants have #% chance to have an additional Suffix Modifier"}],"ref":"Remnants have #% chance to have an additional Suffix Modifier","trade":{"ids":{"explicit":["explicit.stat_1871805225"],"implicit":["implicit.stat_1871805225"]}}} +{"better":1,"matchers":[{"string":"Players have #% to all maximum Resistances"}],"ref":"Players have #% to all maximum Resistances","trade":{"ids":{"explicit":["explicit.stat_3376488707"],"fractured":["fractured.stat_3376488707"]}}} +{"better":1,"matchers":[{"string":"Monsters' skills Chain # additional times"}],"ref":"Monsters' skills Chain # additional times","trade":{"ids":{"explicit":["explicit.stat_3183973644"],"fractured":["fractured.stat_3183973644"]}}} +{"better":1,"matchers":[{"string":"+#% Monster Chaos Resistance"}],"ref":"+#% Monster Chaos Resistance","trade":{"ids":{"explicit":["explicit.stat_365540634"],"fractured":["fractured.stat_365540634"]}}} +{"better":1,"matchers":[{"string":"+#% Monster Elemental Resistances"}],"ref":"+#% Monster Elemental Resistances","trade":{"ids":{"explicit":["explicit.stat_1054098949"],"fractured":["fractured.stat_1054098949"]}}} +{"better":1,"matchers":[{"string":"Monsters gain #% of Maximum Life as Extra Maximum Energy Shield"}],"ref":"Monsters gain #% of Maximum Life as Extra Maximum Energy Shield","trade":{"ids":{"explicit":["explicit.stat_2887760183"],"fractured":["fractured.stat_2887760183"]}}} {"better":1,"matchers":[{"string":"Players who Die in area are sent to the Void"}],"ref":"Players who Die in area are sent to the Void","trade":{"ids":{"explicit":["explicit.stat_1095765106"]}}} {"better":1,"matchers":[{"string":"Voyage Modifier will be revealed once Charted"}],"ref":"Voyage Modifier will be revealed once Charted","trade":{"ids":{"implicit":["implicit.stat_3582759801"]}}} {"better":1,"matchers":[{"string":"#% increased Monster Damage"}],"ref":"#% increased Monster Damage","trade":{"ids":{"explicit":["explicit.stat_1890519597"],"fractured":["fractured.stat_1890519597"]}}} diff --git a/tests/data/bundle/item-classes.ndjson b/tests/data/bundle/item-classes.ndjson index 5089265..715f4be 100644 --- a/tests/data/bundle/item-classes.ndjson +++ b/tests/data/bundle/item-classes.ndjson @@ -14,3 +14,4 @@ {"id":"HeistContract","itemClass":"Contracts","tradeCategory":"heistmission.contract"} {"id":"HeistBlueprint","itemClass":"Blueprints","tradeCategory":"heistmission.blueprint"} {"id":"ItemisedSanctum","itemClass":"Sanctum Research","tradeCategory":"sanctum.research"} +{"id":"ExpeditionLogbook","itemClass":"Expedition Logbooks","tradeCategory":"logbook"} diff --git a/tests/data/items/logbook-magic-buffered.txt b/tests/data/items/logbook-magic-buffered.txt new file mode 100644 index 0000000..7651f84 --- /dev/null +++ b/tests/data/items/logbook-magic-buffered.txt @@ -0,0 +1,26 @@ +Item Class: Expedition Logbooks +Rarity: Magic +Buffered Expedition Logbook +-------- +Item Quantity: +13% (augmented) +Item Rarity: +8% (augmented) +Monster Pack Size: +5% (augmented) +Area Level: 74 +-------- +Item Level: 74 +-------- +Bluffs +Druids of the Broken Circle +12% increased quantity of Artifacts dropped by Monsters (implicit) +26% increased Explosive Radius (implicit) +29% increased Explosive Placement Range (implicit) +-------- +Scrublands +Druids of the Broken Circle +39% increased quantity of Artifacts dropped by Monsters (implicit) +44% increased Explosive Placement Range (implicit) +Area contains 34% increased number of Monster Markers (implicit) +-------- +Monsters gain 32% of Maximum Life as Extra Maximum Energy Shield +-------- +Take this item to Dannig in your Hideout to open portals to an expedition. diff --git a/tests/data/items/logbook-normal-three-areas.txt b/tests/data/items/logbook-normal-three-areas.txt new file mode 100644 index 0000000..026325f --- /dev/null +++ b/tests/data/items/logbook-normal-three-areas.txt @@ -0,0 +1,24 @@ +Item Class: Expedition Logbooks +Rarity: Normal +Expedition Logbook +-------- +Area Level: 83 +-------- +Item Level: 85 +-------- +Scrublands +Druids of the Broken Circle +32% increased quantity of Artifacts dropped by Monsters (implicit) +Area contains 39% increased number of Monster Markers (implicit) +-------- +Volcanic Island +Druids of the Broken Circle +22% increased number of Explosives (implicit) +Remnants have 20% chance to have an additional Suffix Modifier (implicit) +-------- +Sarn Slums +Order of the Chalice +31% increased Explosive Radius (implicit) +Area contains 39% increased number of Monster Markers (implicit) +-------- +Take this item to Dannig in your Hideout to open portals to an expedition. diff --git a/tests/data/items/logbook-rare-ancient-lands.txt b/tests/data/items/logbook-rare-ancient-lands.txt new file mode 100644 index 0000000..8d3778f --- /dev/null +++ b/tests/data/items/logbook-rare-ancient-lands.txt @@ -0,0 +1,34 @@ +Item Class: Expedition Logbooks +Rarity: Rare +Ancient Lands +Expedition Logbook +-------- +Item Quantity: +61% (augmented) +Item Rarity: +36% (augmented) +Monster Pack Size: +23% (augmented) +Area Level: 80 +-------- +Item Level: 80 +-------- +Volcanic Island +Druids of the Broken Circle +14% increased number of Explosives (implicit) +42% increased Explosive Placement Range (implicit) +-------- +Battleground Graves +Druids of the Broken Circle +11% increased quantity of Artifacts dropped by Monsters (implicit) +16% increased number of Explosives (implicit) +-------- +Sarn Slums +Order of the Chalice +Area contains 27% increased number of Runic Monster Markers (implicit) +Remnants have 35% chance to have an additional Suffix Modifier (implicit) +-------- +Players have -9% to all maximum Resistances +Monsters' skills Chain 2 additional times ++25% Monster Chaos Resistance ++40% Monster Elemental Resistances +Monsters gain 45% of Maximum Life as Extra Maximum Energy Shield +-------- +Take this item to Dannig in your Hideout to open portals to an expedition. diff --git a/tests/item_parse_test.cpp b/tests/item_parse_test.cpp index a72ed9e..4438ba8 100644 --- a/tests/item_parse_test.cpp +++ b/tests/item_parse_test.cpp @@ -864,3 +864,73 @@ TEST_CASE("an itemised sanctum's state is properties, and its affixes are sanctu CHECK(it->flavour_text.size() == 1); } } + +TEST_CASE("an Expedition Logbook's destinations are structure, not modifiers") { + SUBCASE("each block is an area, a faction and the implicits that apply there") { + const Item it = parse("items", "logbook-normal-three-areas.txt"); + CHECK(it.item_class == "Expedition Logbooks"); + CHECK(it.is_logbook()); + CHECK(it.rarity == Rarity::Normal); + CHECK(it.base_type == "Expedition Logbook"); + REQUIRE(it.logbook_areas.size() == 3); + CHECK(it.logbook_areas[0].area == "Scrublands"); + CHECK(it.logbook_areas[0].faction == "Druids of the Broken Circle"); + CHECK(it.logbook_areas[2].area == "Sarn Slums"); + CHECK(it.logbook_areas[2].faction == "Order of the Chalice"); + // The two names are the destination's own and are nowhere near the mod list, which + // used to hold six of them as unmatchable lines. + REQUIRE(it.mods.size() == 6); + for (const Modifier& m : it.mods) CHECK(m.type == ModType::Implicit); + CHECK(it.mods[0].lines.front() == + "32% increased quantity of Artifacts dropped by Monsters"); + // And which implicit belongs to which destination, which nothing about the modifiers + // themselves says: the same wording is granted by two of them at different rolls. + CHECK(it.logbook_areas[0].mods == std::vector{0, 1}); + CHECK(it.logbook_areas[1].mods == std::vector{2, 3}); + CHECK(it.logbook_areas[2].mods == std::vector{4, 5}); + } + SUBCASE("the usage note is a usage note, not a seventh modifier") { + // A logbook is gear, so prose needs a positive signal before it stops being read as a + // modifier — and "Take this item to Dannig…" opens with no click instruction. Without + // a needle of its own it came back as an unrecognised mod on every logbook. + const Item it = parse("items", "logbook-normal-three-areas.txt"); + REQUIRE(it.help_text.size() == 1); + CHECK(it.help_text.front().starts_with("Take this item to Dannig")); + } + SUBCASE("a rare's own affixes sit below the destinations and stay explicit") { + const Item it = parse("items", "logbook-rare-ancient-lands.txt"); + CHECK(it.name == "Ancient Lands"); + CHECK(it.base_type == "Expedition Logbook"); + REQUIRE(it.logbook_areas.size() == 3); + CHECK(it.logbook_areas[1].area == "Battleground Graves"); + // Six implicits from the destinations and five affixes of the book's own. The affix + // block is the one that opens with a roll, which is what tells the two shapes apart. + REQUIRE(it.mods.size() == 11); + for (size_t i = 0; i < 6; ++i) CHECK(it.mods[i].type == ModType::Implicit); + for (size_t i = 6; i < 11; ++i) CHECK(it.mods[i].type == ModType::Explicit); + CHECK(it.mods[6].lines.front() == "Players have -9% to all maximum Resistances"); + CHECK(it.mods[10].lines.front() == + "Monsters gain 45% of Maximum Life as Extra Maximum Energy Shield"); + // The quantity properties a rare logbook carries are the same three a map prints. + const auto num_of = [&it](PropertyKey k) { + for (const Property& p : it.properties) + if (p.key == k) return p.num; + return std::optional(); + }; + CHECK(num_of(PropertyKey::ItemQuantity) == 61); + CHECK(num_of(PropertyKey::MonsterPackSize) == 23); + CHECK(num_of(PropertyKey::AreaLevel) == 80); + CHECK(it.item_level == 80); + } + SUBCASE("a destination is not fixed at two implicits, and a magic name is still the base") { + const Item it = parse("items", "logbook-magic-buffered.txt"); + CHECK(it.rarity == Rarity::Magic); + CHECK(it.base_type == "Buffered Expedition Logbook"); + REQUIRE(it.logbook_areas.size() == 2); + CHECK(it.logbook_areas[0].area == "Bluffs"); + CHECK(it.logbook_areas[0].mods.size() == 3); + CHECK(it.logbook_areas[1].mods.size() == 3); + REQUIRE(it.mods.size() == 7); // six implicits and the one affix below them + CHECK(it.mods.back().type == ModType::Explicit); + } +} diff --git a/tests/item_pricing_test.cpp b/tests/item_pricing_test.cpp index d41a3a4..576e443 100644 --- a/tests/item_pricing_test.cpp +++ b/tests/item_pricing_test.cpp @@ -2342,3 +2342,152 @@ TEST_CASE("an itemised sanctum is searched on the state of the run") { })); } } + +TEST_CASE("an Expedition Logbook is priced on one of its destinations at a time") { + const std::shared_ptr gd = fixture(); + + SUBCASE("the strategy, the category and the type the category already implies") { + const Item it = resolved(*gd, capture("logbook-normal-three-areas.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + CHECK(p.strategy == Strategy::Logbook); + CHECK(p.category == "logbook"); + CHECK(p.type == "Expedition Logbook"); + CHECK(p.rarity == "nonunique"); + } + + SUBCASE("one group per destination, and exactly one of them live") { + const Item it = resolved(*gd, capture("logbook-normal-three-areas.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + REQUIRE(p.choices.size() == 3); + CHECK(p.choices[0].label == "Druids of the Broken Circle"); + CHECK(p.choices[0].note == "Scrublands"); + CHECK(p.choices[2].label == "Order of the Chalice"); + CHECK(p.choice == 0); + // Every stat on this plan belongs to a destination — a Normal logbook has no affixes — + // and the only ticked one is the live group's faction. + std::vector enabled; + for (const StatFilter& f : p.stats) { + REQUIRE(f.choice.has_value()); + if (f.enabled) enabled.push_back(f.id); + } + CHECK(enabled == std::vector{"pseudo.pseudo_logbook_faction_druids"}); + } + + SUBCASE("the faction is ticked; where it goes and what it grants there are offered") { + const Item it = resolved(*gd, capture("logbook-normal-three-areas.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + std::vector> live; + for (const StatFilter& f : p.stats) + if (f.choice == 0) live.emplace_back(f.text, f.enabled); + REQUIRE(live.size() == 4); + CHECK(live[0] == std::pair{ + "Has Logbook Faction: Druids of the Broken Circle", true}); + CHECK(live[1] == + std::pair{"Has Logbook Area: Scrublands", false}); + CHECK(live[2].first == "32% increased quantity of Artifacts dropped by Monsters"); + CHECK_FALSE(live[2].second); + } + + SUBCASE("the faction filter is presence, never a count") { + // The pseudo stat takes one — how many destinations belong to that faction — and it is + // not what decides the price. A logbook with two Druids destinations is still bought + // for a Druids run, and bounding it drops every single-destination copy of the same + // thing. The rare capture has exactly that pair. + const Item it = resolved(*gd, capture("logbook-rare-ancient-lands.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + for (const StatFilter& f : p.stats) + if (f.id.starts_with("pseudo.pseudo_logbook_faction")) { + CHECK_FALSE(f.min.has_value()); + CHECK_FALSE(f.max.has_value()); + } + } + + SUBCASE("two destinations of one faction stay two alternatives") { + // Both are Druids, so both rows carry the same trade id — and `merge_same_stat` would + // fold them into one row standing for a choice that is not a choice. + const Item it = resolved(*gd, capture("logbook-rare-ancient-lands.txt")); + SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + REQUIRE(p.choices.size() == 3); + CHECK(p.choices[0].note == "Volcanic Island"); + CHECK(p.choices[1].note == "Battleground Graves"); + CHECK(p.choices[0].label == p.choices[1].label); + size_t druids = 0; + for (const StatFilter& f : p.stats) + if (f.id == "pseudo.pseudo_logbook_faction_druids") ++druids; + CHECK(druids == 2); + + // And switching group ticks the new faction and unticks everything of the old one. + p.select_choice(1); + CHECK(p.choice == 1); + for (const StatFilter& f : p.stats) + CHECK(f.enabled == (f.choice == 1 && f.choice_primary)); + } + + SUBCASE("a destination's implicit is a floor, because trade totals them across the book") { + // Volcanic Island grants 14% increased number of Explosives and Battleground Graves + // grants 16%, and the site indexes the item's implicits as one total per stat — so a + // ceiling seeded from one destination's roll asks the other two not to exist. + const Item it = resolved(*gd, capture("logbook-rare-ancient-lands.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + std::vector explosives; + for (const StatFilter& f : p.stats) + if (f.text.find("increased number of Explosives") != std::string::npos) { + REQUIRE(f.min.has_value()); + CHECK_FALSE(f.max.has_value()); + explosives.push_back(*f.min); + } + REQUIRE(explosives.size() == 2); + CHECK(explosives[0] < explosives[1]); + } + + SUBCASE("the book's own affixes are hidden, exactly as a map's are") { + const Item it = resolved(*gd, capture("logbook-rare-ancient-lands.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + for (const StatFilter& f : p.stats) CHECK(f.hidden != f.choice.has_value()); + size_t hidden = 0; + for (const StatFilter& f : p.stats) + if (f.hidden) { + CHECK_FALSE(f.enabled); + ++hidden; + } + // Three of the five, and the two missing ones are a data gap rather than this rule: + // "+25% Monster Chaos Resistance" and "+40% Monster Elemental Resistances" are + // published with the leading sign inside the matcher ("+#% Monster Chaos Resistance"), + // and `placeholder_form` replaces the sign along with the digits — so nothing the + // clipboard prints can reach them. 34 of the bundle's 15,148 matchers are shaped that + // way and none of them is a logbook's. Fix that and this becomes 5. + CHECK(hidden == 3); + // And not one of them is a note: they were left out on purpose, and the reader has + // them on the item card beside the panel. + CHECK(p.notes.empty()); + } + + SUBCASE("the area level is a floor and ticked; everything else about the book is offered") { + const Item it = resolved(*gd, capture("logbook-rare-ancient-lands.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + const NumericFilter* lvl = numeric_for(p, "area_level"); + REQUIRE(lvl != nullptr); + CHECK(lvl->min == 80); + CHECK_FALSE(lvl->max.has_value()); // an 83 answers a search for an 80 + CHECK(lvl->enabled); + for (const char* key : {"ilvl", "map_iiq", "map_packsize", "map_iir"}) { + const NumericFilter* f = numeric_for(p, key); + REQUIRE_MESSAGE(f != nullptr, key); + CHECK_FALSE(f->enabled); + } + CHECK(numeric_for(p, "map_iiq")->min == 61); + } + + SUBCASE("a magic logbook is searched as the base, never as the name the affix decorated") { + const Item it = resolved(*gd, capture("logbook-magic-buffered.txt")); + const SearchPlan p = build_plan(*gd, it, derive(gd.get(), it)); + CHECK(p.type == "Expedition Logbook"); + REQUIRE(p.choices.size() == 2); + CHECK(p.choices[0].note == "Bluffs"); + // Three implicits on that destination, not the two the other captures print. + size_t rows = 0; + for (const StatFilter& f : p.stats) + if (f.choice == 0) ++rows; + CHECK(rows == 5); // the faction, the area, and three implicits + } +} diff --git a/tests/trade_query_test.cpp b/tests/trade_query_test.cpp index d47d7e1..a72e18f 100644 --- a/tests/trade_query_test.cpp +++ b/tests/trade_query_test.cpp @@ -702,3 +702,44 @@ TEST_CASE("a sanctum's filters go in sanctum_filters, and its area level does no CHECK_FALSE(searchable(p)); } } + +TEST_CASE("a logbook sends one destination of several, and the others are simply unticked") { + // The exclusion is entirely `SearchPlan::select_choice` keeping the other groups off. This + // layer reads `enabled` and knows nothing about the grouping, exactly as it knows nothing + // about `hidden` — which is the guarantee worth stating on the wire rather than in the plan. + SearchPlan p; + p.strategy = Strategy::Logbook; + p.category = "logbook"; + p.type = "Expedition Logbook"; + p.choices = {{"Druids of the Broken Circle", "Volcanic Island"}, + {"Order of the Chalice", "Sarn Slums"}}; + const auto add = [&p](const char* id, size_t group, bool primary) { + ppc::item::StatFilter f; + f.id = id; + f.type = ppc::data::ModType::Pseudo; + f.choice = group; + f.choice_primary = primary; + p.stats.push_back(f); + }; + add("pseudo.pseudo_logbook_faction_druids", 0, true); + add("pseudo.pseudo_logbook_area_volcano", 0, false); + add("pseudo.pseudo_logbook_faction_order", 1, true); + add("pseudo.pseudo_logbook_area_sarn_slums", 1, false); + p.numerics = {{"area_level", "Area Level", 80.0, std::nullopt, true}}; + + CHECK(searchable(p)); + p.select_choice(0); + json q = query_of(p); + REQUIRE(q["stats"].size() == 1); + REQUIRE(q["stats"][0]["filters"].size() == 1); + CHECK(q["stats"][0]["filters"][0]["id"] == "pseudo.pseudo_logbook_faction_druids"); + // Presence: the pseudo stat takes a count and the search deliberately does not send one. + CHECK_FALSE(q["stats"][0]["filters"][0].contains("value")); + CHECK(q["filters"]["map_filters"]["filters"]["area_level"]["min"] == 80.0); + + // The other destination, and nothing of the first one left behind. + p.select_choice(1); + q = query_of(p); + REQUIRE(q["stats"][0]["filters"].size() == 1); + CHECK(q["stats"][0]["filters"][0]["id"] == "pseudo.pseudo_logbook_faction_order"); +} From 94f3193c1888d9b15ca44817041139d5ff85e6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Mon, 10 Aug 2026 18:49:23 +0200 Subject: [PATCH 2/2] A logbook's quantity is a decision, not a pending measurement CHANGED: Quantity, rarity and pack size stay unticked because a logbook is not bought for them, where they read as waiting on a measurement of whether the site indexes them. - The one place this strategy parts company with the map keys it borrows: a map is run for its quantity and pack size and ticks them, and a logbook's are a second-order bonus on top of the artifacts the destination decides. They also only exist on a magic or rare book, so ticking them would search the same logbook two ways depending on whether it had rolled affixes at all. Co-Authored-By: Claude Opus 5 (1M context) --- docs/strategy-logbook.md | 24 +++++++++++++++--------- src/item/plan.cpp | 14 ++++++++------ tests/item_pricing_test.cpp | 5 +++++ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/strategy-logbook.md b/docs/strategy-logbook.md index 658cbcd..4d0e5e4 100644 --- a/docs/strategy-logbook.md +++ b/docs/strategy-logbook.md @@ -22,7 +22,8 @@ colon, no mod-type suffix, not an info line — which nothing else a logbook pri its own is. Two checks and both are load-bearing. The **leading pair** is what keeps the block of affixes a -rare logbook prints *below* its destinations out, because an affix opens with its roll. And the +magic or rare logbook prints *below* its destinations out, because an affix opens with its roll. +And the **tail has to read as modifiers** (`looks_like_mods`), which keeps a stray pair of prose lines out and is the half that still holds with Advanced Mod Descriptions on, where each implicit gains an info line above it. The number of implicits is deliberately not fixed: the captures show two and @@ -78,12 +79,16 @@ a note, as an unknown sanctum boon or beast species is: the area list grows with an 83. - **The item level**, offered. It bounds what the affixes can be crafted to, which is a question about crafting the book rather than about running it. -- **Quantity, rarity and pack size**, offered, all three unticked. They are the same three - properties a map is searched on and come off the same `map_filters` keys, but a map's are the - whole of what it is run for and a logbook's are a second-order bonus on top of the artifacts, - which the destination decides. **Not yet measured** for this category: a filter the site accepts - and indexes nothing under empties the search, exactly as `heist_max_escape_routes` does, and - unticked is the state that cannot do that. Measure one at a time before ticking any of them. +- **Quantity, rarity and pack size**, offered, and all three **unticked on purpose — decided, not + deferred.** They are the same three properties a map is searched on and come off the same + `map_filters` keys, and this is the one place a logbook deliberately parts company with the map + strategy: a map's quantity and pack size are the whole of what it is run for and are ticked, + where a logbook's are a second-order bonus on top of the artifacts, which the *destination* + decides. They also only exist on a magic or rare book, so ticking them would make the same + logbook searched two different ways depending on whether it had rolled affixes at all. A + secondary reason to leave them alone: nobody has measured that the site indexes them for this + category, and a filter it accepts and indexes nothing under empties the search exactly as + `heist_max_escape_routes` does. Unticked is the state that cannot do that either way. - **The type is sent only where the bundle resolved the base.** The category is the whole search on its own — one base type is filed under `logbook` — so the type says nothing it does not, and a magic logbook's printed line is "Buffered Expedition Logbook", which as a type matches nothing @@ -110,5 +115,6 @@ three ordinary booleans — returned **983 listings** in Allflame. So the catego it, the area level and the faction pseudo are all indexed for this category and none of them is a filter the site accepts and answers with nothing. -What has **not** been measured, one variable at a time and against this same capture, is -`map_iiq`, `map_packsize` and `map_iir` — see above. +`map_iiq`, `map_packsize` and `map_iir` are **not** measured, and are not waiting on a +measurement either: they are unticked because a logbook is not bought for them, and a measurement +would only decide whether a row the search does not send is safe to send. diff --git a/src/item/plan.cpp b/src/item/plan.cpp index a6d4c94..681ae1c 100644 --- a/src/item/plan.cpp +++ b/src/item/plan.cpp @@ -1220,12 +1220,14 @@ const data::Stat* logbook_stat(const data::GameData& gd, data::Term prefix, /// an 83. /// - **The item level**, offered. It bounds what the affixes can be crafted to and is a /// question about crafting the book rather than about running it. -/// - **Quantity, rarity and pack size**, offered, all three. They are the same three properties -/// a map is searched on and they come off the same `map_filters` keys — but a map's are the -/// whole of what it is run for, and a logbook's are a second-order bonus on top of the -/// artifacts, which is what the destination decides. Left unticked until someone has measured -/// that the site indexes them for this category at all; a filter it accepts and indexes -/// nothing under empties the search, exactly as `heist_max_escape_routes` does. +/// - **Quantity, rarity and pack size**, offered and unticked — **decided, not deferred**, and +/// the one place this parts company with the map strategy it borrows the keys from. A map's +/// quantity and pack size are the whole of what it is run for and are ticked; a logbook's are +/// a second-order bonus on top of the artifacts, which the *destination* decides. They also +/// only exist on a magic or rare book, so ticking them would search the same logbook two +/// different ways depending on whether it had rolled affixes at all. Nobody has measured that +/// the site indexes them for this category either, and a filter it accepts and indexes nothing +/// under empties the search exactly as `heist_max_escape_routes` does. /// /// The **affixes it prints below the destinations** are the map argument, and get the map's /// answer: they apply wherever it goes, they are re-rollable — a logbook is craftable, which is diff --git a/tests/item_pricing_test.cpp b/tests/item_pricing_test.cpp index 576e443..91a563f 100644 --- a/tests/item_pricing_test.cpp +++ b/tests/item_pricing_test.cpp @@ -2470,6 +2470,11 @@ TEST_CASE("an Expedition Logbook is priced on one of its destinations at a time" CHECK(lvl->min == 80); CHECK_FALSE(lvl->max.has_value()); // an 83 answers a search for an 80 CHECK(lvl->enabled); + // Quantity and pack size are ticked on a map and deliberately are not here, which is the + // one place this strategy parts company with the keys it borrows: a map is run for them, + // and a logbook's are a second-order bonus on top of the artifacts the destination + // decides. They also only exist on a magic or rare book, so ticking them would search + // the same logbook two ways depending on whether it had rolled affixes at all. for (const char* key : {"ilvl", "map_iiq", "map_packsize", "map_iir"}) { const NumericFilter* f = numeric_for(p, key); REQUIRE_MESSAGE(f != nullptr, key);