diff --git a/CMakeLists.txt b/CMakeLists.txt index 49195c8..9b5374b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,6 +126,7 @@ add_library(ppc_core STATIC src/data/index.cpp src/data/lexicon.cpp src/ui/strings.cpp + src/ui/track.cpp src/data/stat_normalize.cpp src/data/game_data.cpp src/data/stat_matcher.cpp @@ -278,3 +279,4 @@ ppc_add_test(trade_query_test) ppc_add_test(ninja_test) ppc_add_test(exchange_test) ppc_add_test(ratelimit_test) +ppc_add_test(track_test) diff --git a/docs/roadmap.md b/docs/roadmap.md index c6dd8a3..bd50017 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,16 +149,26 @@ Known, argued, and unscheduled — except where a planned version claims one, wh rolled tier and only with Advanced Mod Descriptions on; `Stat` carries no tiers, and `en-unique-mods.ndjson` is per-unique rather than per-affix. **Upstream**: the data build would have to emit the mod table (GGG's `Mods.dat` joined to its tiers, keyed by item class and - domain, in displayed units the way `UniqueModFilter::ranges` already are). - Until then `ui::range_slider` answers the need without the claim — the knobs carry on past the - ends, a knob released against an end grows the track, and the numbers can be typed. **The line - that must hold is the one between a track and a statement**: a *published* track is drawn as - `roll_min`..`roll_max` and marked with ticks once it has been widened, and every other row gets a - track derived from the number in hand (`track_for`, half either side) that is deliberately given - no ticks and says on hover that it is not a published range. **Do not promote a derived track to - a published one, and do not widen a published one by a guessed factor.** A track with ticks is - read as a statement about what the affix rolls, and a wrong one is the confident wrong number - this layer exists to avoid. + domain, in displayed units the way `UniqueModFilter::ranges` already are). **The tables are + already extracted** — `Mods` comes out of `game_bundle.py` with `Level`, `Domain`, + `GenerationType` and all eight `StatMin`/`StatMax` pairs, and grouping the 4,369 item-domain + prefix/suffix rows by their stat-key set gives the ladders straight off (`local_physical_damage_+%` + comes back as eight tiers, `Heavy` 40–49 through `Merciless` 170–179). What is *not* extracted is + `SpawnWeight_TagsKeys`/`SpawnWeight_Values`, which is what decides whether a ladder can appear on + the base in hand at all; without it the union would sweep in essence-, influence- and + class-specific variants and overstate the range. So the cost is: two more columns, an emitter, a + bundle asset keyed by stat id and item class, and the app-side join — plus a rule for the case + where the wording in hand maps to more than one ladder, which is **refuse to guess**, the same as + everywhere else here. + Until then `ui::range_slider` answers the need without the claim — the track reaches past what is + known (`ui::widen_track`, half again either side, one constant for every row), the knobs carry on + past even that, a knob released against an end grows the track, and the numbers can be typed. + **The line that must hold is the one between a track and a statement**: what the game published is + `roll_min`..`roll_max` and is marked with ticks, wherever the track's own ends have got to, and a + row with no published range gets no ticks and says on hover that it has none. **The track's width + may be a convenience; the ticks may not.** Do not tick a derived track, and do not move a tick off + `roll_min`/`roll_max` to make the reach look authoritative. A tick is read as a statement about + what the affix rolls, and a wrong one is the confident wrong number this layer exists to avoid. - **Pseudo mods on gear** — trade's `pseudo.*` totals (total resistances, total life) are not built; mods are matched verbatim. The bundle does carry the ids (`pseudo.pseudo_total_cold_resistance` and the rest), so this is a plan-layer job, not a data one. A map's pseudo stats *are* built (see diff --git a/docs/trade-layer.md b/docs/trade-layer.md index 18d7a60..fa5191e 100644 --- a/docs/trade-layer.md +++ b/docs/trade-layer.md @@ -279,28 +279,39 @@ Six things about it are decided rather than incidental: because "no ceiling" and "a ceiling at the top of the range" are different searches that look identical otherwise. - **Every row with a number gets one, and `track_for` decides what it is drawn over.** Where the - game printed a range, `roll_min`..`roll_max` is the track and `published` is set. Most rows are - not that: item level, quality, total energy shield and the derived damage numbers are facts about - the item rather than an affix's tier, and with Advanced Mod Descriptions off a modifier prints no - range either. Those get a track derived from the number in hand, **half of it either side**. - Withholding the slider there was worse than deriving one — an editor that is two boxes on one row - and a slider on the next reads as a slider that failed to load, and the numbers people most want - to loosen are exactly the ones with no published range. **The distinction is kept where it - belongs**: a derived track gets no ticks and says so on hover, so nothing draws it as what the - affix rolls. -- **The track is not a cage, and it grows.** Even a published range is only the tier in hand — + game printed a range, `roll_min`..`roll_max` is what the track is built around and the pair of + ticks marks it. Most rows are not that: item level, quality, total energy shield and the derived + damage numbers are facts about the item rather than an affix's tier, and with Advanced Mod + Descriptions off a modifier prints no range either. Those get a track built around the number in + hand and no ticks. Withholding the slider there was worse than deriving one — an editor that is + two boxes on one row and a slider on the next reads as a slider that failed to load, and the + numbers people most want to loosen are exactly the ones with no published range. **The + distinction is kept where it belongs**: a track with no ticks says so on hover, so nothing draws + it as what the affix rolls. +- **`ui::widen_track` sets the width, and it is the same rule for both kinds.** Each end moves out + by `kTrackSpread` — half again — of *its own* magnitude, at least one step at the row's `dp`, and + the result is rounded outwards. One constant, in `ui/track.hpp`, deliberately not a setting: + nothing in the data makes one number here more correct than another, so a setting would be asking + the user a question nothing can answer. Consequences worth knowing: a range printed negative grows + *away* from zero, since the sign is the game's and the reach is on the magnitude; a tier that + rolls a single number (an eldritch implicit, a unique's fixed mod) still gets a real track and + keeps its ticks, where it used to fall through to the derived branch and lose them; and one step + is the floor, so a row at `dp` 0 sitting on zero reaches ±1 and one at `dp` 2 reaches ±0.01. It is + covered by `tests/track_test.cpp`, which is why the arithmetic lives in `ppc_core` and not beside + the widget. +- **The track is not a cage, and it grows further.** A published range is only the tier in hand — **the bundle carries no per-tier affix table**, so what a *different* tier of the same modifier - rolls is not known and a wider track drawn as fact would be a picture of a guess. But a buyer is - entitled to ask for a better roll than the one they are holding, so: a knob pushed past an end - keeps going (to `kOvershoot` ranges out, with the boxes for anything beyond); a knob **released - hard against an end grows the track by a fifth of the range it started with**, at least 1, so the - next drag has somewhere to go and repeated pegging walks outwards; and **two ticks mark where the - known range was** on a published track — without them the widened track would read as the affix's - own range, which is exactly the claim nothing here may make. The domain lives in the widget's - storage, frozen for the duration of a drag (rescaling the track under a moving knob makes the - number race away from the cursor) and reset by `RangeTrack::reset` when the editor opens on a new - row, since one popup id serves every row. `ui::kRangeLimit` (INT32_MAX) is where all of it stops, - typed bounds included. + rolls is not known, and the reach above is a place to put the mouse rather than a claim about + those tiers. That line is what the ticks hold: they sit at `roll_min`..`roll_max` inside a wider + track, and without them the reach would read as the affix's own range, which is exactly the claim + nothing here may make. Past the reach a buyer can still ask for more: a knob pushed past an end + keeps going (to `kOvershoot` spans out, with the boxes for anything beyond), and a knob **released + hard against an end grows the track by a quarter of the span it started with**, at least one step, + so the next drag has somewhere to go and repeated pegging walks outwards. The domain lives in the + widget's storage, frozen for the duration of a drag (rescaling the track under a moving knob makes + the number race away from the cursor) and reset by `RangeTrack::reset` when the editor opens on a + new row, since one popup id serves every row. `ui::kRangeLimit` (INT32_MAX) is where all of it + stops, typed bounds included. - **The boxes are `InputTextWithHint`, not `InputDouble`.** Empty has to be sayable — it is how a bound is taken off, and "both, a floor, a ceiling, or neither" is the whole promise — so the box holds text, hints `min`/`max` when empty, and is parsed with **`std::from_chars`**: `strtod` and diff --git a/src/screens/pricecheck_screen.cpp b/src/screens/pricecheck_screen.cpp index 1578b2c..92bd658 100644 --- a/src/screens/pricecheck_screen.cpp +++ b/src/screens/pricecheck_screen.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -20,6 +19,7 @@ #include "trade/query.hpp" #include "ui/glyphs.hpp" #include "ui/range_slider.hpp" +#include "ui/track.hpp" #include "util/debug_log.hpp" namespace ppc { @@ -331,36 +331,38 @@ struct Interval { std::string label; }; -/// The slider's track, and whether it is a range the game published or one derived here. +/// The slider's track: the span to draw, and the published range to tick on it where there is one. struct Track { double lo = 0, hi = 0; - bool published = false; + std::optional tick_lo, tick_hi; }; -/// How far a derived track reaches either side of the number it is built around, and the smallest -/// half-width worth drawing. The floor is what keeps a value of zero — a resistance rolled off, a -/// filter typed down to nothing — from collapsing the track to a point and taking the slider away -/// again. -constexpr double kDerivedSpread = 0.5; -constexpr double kDerivedFloor = 1.0; - /// The track to draw for an interval. /// -/// Where the game printed a range, that range is the track and there is nothing to decide. Most -/// rows are not that: item level, quality, total energy shield and the derived damage numbers are -/// **facts about the item rather than an affix's tier**, so no range exists for them anywhere, and -/// a modifier read with Advanced Mod Descriptions off prints its roll without one. Those rows used -/// to get two boxes and no slider, which made the editor look broken on exactly the numbers people -/// most often want to loosen by a bit. +/// **Both kinds of track are the same width for the same reason**, `ui::widen_track`: half again +/// either side of the numbers they are built around, at least one step. What differs is what those +/// numbers are, and whether the track carries ticks. +/// +/// Where the game printed a range, that range is what the track is built around and gets the +/// ticks. It is not the ends: the tier in hand is the only tier we know, a buyer is entitled to +/// drag toward a better one, and a track that stopped at the tier made the two most useful drags — +/// "a bit better than this" and "a bit worse" — impossible without typing. The ticks are what keeps +/// the reach from reading as a claim about the tiers either side, which nothing here can make. /// -/// So a track is derived from the number in hand — **half of it either side** — and marked -/// unpublished so nothing draws it as the affix's own range. This is a place to put the mouse and -/// not a statement about what the item could have rolled: the ends still do not stop a drag, and -/// the boxes still take anything. Read `range_slider`'s note on the ticks for the line between the -/// two. +/// Most rows have no published range at all: item level, quality, total energy shield and the +/// derived damage numbers are **facts about the item rather than an affix's tier**, so no range +/// exists for them anywhere, and a modifier read with Advanced Mod Descriptions off prints its roll +/// without one. Those get a track around the number in hand and no ticks — a place to put the +/// mouse, and deliberately not a statement about what the item could have rolled. Read +/// `range_slider`'s note on the ticks for the line between the two. std::optional track_for(const Interval& iv) { - if (iv.roll_min && iv.roll_max && *iv.roll_min < *iv.roll_max) - return Track{*iv.roll_min, *iv.roll_max, true}; + // `<=`, not `<`: a tier that rolls a single number — an eldritch implicit, a unique's fixed + // mod — is still a published range, and widening gives it a track to sit on. It used to fall + // through to the derived branch and quietly lose its ticks. + if (iv.roll_min && iv.roll_max && *iv.roll_min <= *iv.roll_max) { + const ui::TrackSpan s = ui::widen_track(*iv.roll_min, *iv.roll_max, iv.dp); + return Track{s.lo, s.hi, *iv.roll_min, *iv.roll_max}; + } // Whatever the row is actually about, preferring what the plan seeded over what the user has // since typed — the track should not slide out from under a number being edited. const auto anchor = [](std::initializer_list> candidates) { @@ -371,12 +373,8 @@ std::optional track_for(const Interval& iv) { const std::optional lo = anchor({iv.seed_min, iv.seed_max, *iv.min, *iv.max, iv.roll_min}); const std::optional hi = anchor({iv.seed_max, iv.seed_min, *iv.max, *iv.min, iv.roll_max}); if (!lo || !hi) return std::nullopt; // nothing numeric to build one around - const double p = std::pow(10.0, iv.dp); - const double reach_lo = std::max(std::abs(*lo) * kDerivedSpread, kDerivedFloor); - const double reach_hi = std::max(std::abs(*hi) * kDerivedSpread, kDerivedFloor); - // Outwards, so the ends are round numbers at the row's own precision and the half either side - // is never rounded away. - return Track{std::floor((*lo - reach_lo) * p) / p, std::ceil((*hi + reach_hi) * p) / p, false}; + const ui::TrackSpan s = ui::widen_track(*lo, *hi, iv.dp); + return Track{s.lo, s.hi, std::nullopt, std::nullopt}; } /// The editor is the panel's width less this much on each side, so it reads as belonging to the @@ -545,16 +543,17 @@ void draw_range_editor(App& app, const Interval& iv, bool glyphs) { const float track = one_line ? inner - fixed : inner - kBoundField * 2 - style.ItemSpacing.x * 2; if (ui::range_slider("##track", *iv.min, *iv.max, - ui::RangeTrack{bar->lo, bar->hi, iv.dp, track, bar->published, fresh})) + ui::RangeTrack{bar->lo, bar->hi, bar->tick_lo, bar->tick_hi, iv.dp, + track, fresh})) sync_bounds(text, iv); // The numbers the track is drawn over. A hover rather than two labels under the ends: // this is one line, and the row above already prints the same range in its own column. // **The hover is where the two kinds of track are told apart** — one says what the game // printed, the other says plainly that it is a place to drag and not a published range. if (ImGui::IsItemHovered()) { - if (bar->published) - ImGui::SetTooltip("Rolls %s to %s at this tier \xe2\x80\x94 drag past an end, or " - "type, to ask for more", + if (bar->tick_lo) + ImGui::SetTooltip("Rolls %s to %s at this tier \xe2\x80\x94 the ticks. The track " + "reaches past it; drag past an end, or type, to ask for more", format_number(*iv.roll_min, iv.dp).c_str(), format_number(*iv.roll_max, iv.dp).c_str()); else diff --git a/src/ui/range_slider.cpp b/src/ui/range_slider.cpp index 62e6377..38dc876 100644 --- a/src/ui/range_slider.cpp +++ b/src/ui/range_slider.cpp @@ -36,16 +36,16 @@ ImGuiID key_have(ImGuiID id) { return id + 3; } /// user gets to the number they meant, with the boxes for anything beyond it. constexpr double kOvershoot = 2.0; -/// How much a knob released hard against an end adds to the track, as a fraction of the range it -/// started with. Of the range it *started* with, so repeated pegging walks outwards in equal +/// How much a knob released hard against an end adds to the track, as a fraction of the span it +/// started with. Of the span it *started* with, so repeated pegging walks outwards in equal /// steps rather than doubling away from the numbers the user is aiming at. /// /// The floor is one step at the row's own precision, and is only there for a track with no width -/// to speak of — a modifier known to roll a single number — where a fifth of nothing is nothing. -/// Anything blunter overshoots the gesture: a flat 1 on an attack speed's `1.30 to 1.45` grows the -/// track sevenfold in a single release and leaves the knob most of the way across it, which is the -/// opposite of the nudge that was asked for. -constexpr double kPegGrowth = 0.2; +/// to speak of, where a quarter of nothing is nothing. Anything blunter overshoots the gesture: a +/// flat 1 on an attack speed's `1.30 to 1.45` grows the track sevenfold in a single release and +/// leaves the knob most of the way across it, which is the opposite of the nudge that was asked +/// for. +constexpr double kPegGrowth = 0.25; double round_to(double v, int dp) { const double p = std::pow(10.0, dp); @@ -59,10 +59,10 @@ bool range_slider(const char* id, std::optional& min, std::optionalSkipItems) return false; - // What the modifier is known to roll, kept aside: once a bound has been pushed past it the - // domain is wider than this, and the two ticks that say where the known range was are the - // only thing left telling the reader which numbers are the affix's and which are theirs. - const double known_lo = track.lo, known_hi = track.hi; + // The span the caller handed over, kept aside: the domain below grows — with the bounds, and + // with pegging — and the growth has to stay measured against what the track started as rather + // than compounding on whatever it has become. + const double initial_span = track.hi - track.lo; const float h = ImGui::GetFrameHeight(); const float knob = h * kKnobFrac; @@ -171,13 +171,12 @@ bool range_slider(const char* id, std::optional& min, std::optional& v = grabbed == kMin ? min : max; if (grabbed != kNone && v) { - const double grow = - std::max((known_hi - known_lo) * kPegGrowth, std::pow(10.0, -track.dp)); + const double grow = std::max(initial_span * kPegGrowth, std::pow(10.0, -track.dp)); if (*v <= lo) lo = std::max(*v - grow, -kRangeLimit); if (*v >= hi) hi = std::min(*v + grow, kRangeLimit); } @@ -195,12 +194,12 @@ bool range_slider(const char* id, std::optional& min, std::optionalAddRectFilled(ImVec2(x0 - knob, cy - t), ImVec2(x1 + knob, cy + t), bg, t); dl->AddRect(ImVec2(x0 - knob, cy - t), ImVec2(x1 + knob, cy + t), edge, t); - // Where the **known** range sits, once the track is wider than the affix is. Without these - // the widened track reads as the affix's own range, which is a claim about other tiers that - // nothing here is entitled to make. A derived track has no such range to mark, and ticking - // one on would be inventing the claim outright. - if (track.published && (known_lo > lo || known_hi < hi)) - for (const double v : {known_lo, known_hi}) { + // Where the **published** range sits inside a track that deliberately reaches past it. + // Without these the reach reads as the affix's own range, which is a claim about other tiers + // that nothing here is entitled to make. A derived track has no such range to mark, and + // ticking one on would be inventing the claim outright. + if (track.tick_lo && track.tick_hi) + for (const double v : {*track.tick_lo, *track.tick_hi}) { const float x = to_x(v); dl->AddLine(ImVec2(x, cy - t * 2.2f), ImVec2(x, cy + t * 2.2f), edge, 1.0f); } diff --git a/src/ui/range_slider.hpp b/src/ui/range_slider.hpp index 39c55af..62421a4 100644 --- a/src/ui/range_slider.hpp +++ b/src/ui/range_slider.hpp @@ -2,23 +2,23 @@ #include -namespace ppc::ui { +#include "ui/track.hpp" -/// The furthest any bound here will go, either sign. Nothing the game prints is within six orders -/// of magnitude of it; it is here so that a track grown a few times, or a number pasted into one -/// of the boxes beside it, cannot run away into a value arithmetic stops being exact at. -inline constexpr double kRangeLimit = 2147483647.0; // INT32_MAX +namespace ppc::ui { /// What a `range_slider` draws over, beside the pair of bounds it edits. struct RangeTrack { double lo = 0, hi = 0; ///< the track's ends, before any growing this widget has done - int dp = 0; ///< decimals the values are rounded and read at + /// The range the game published for this modifier, when there is one, drawn as a pair of + /// ticks on the track. **Only a published range gets them**: their meaning is "the affix + /// rolls between these", and ticking a track the caller derived from the number in hand would + /// turn a convenience into a claim. Everything else about the widget is the same either way. + /// + /// They are not the ends. The track reaches past what the affix rolls on purpose — see + /// `widen_track` — and these are what keeps that reach from reading as the affix's own range. + std::optional tick_lo, tick_hi; + int dp = 0; ///< decimals the values are rounded and read at float width = 0; - /// Whether `lo`/`hi` are a range the game published or one the caller derived from the number - /// in hand. **Only a published range gets the ticks**: their meaning is "the affix rolls - /// between these", and drawing them around a derived track would turn a convenience into a - /// claim. Everything else about the widget is the same either way. - bool published = false; /// First frame on a new pair of bounds. The widget keeps a widened track between frames, and /// that state is keyed by id — which one popup reused for every row shares. Without this the /// track grown on one modifier would be inherited by the next one opened. @@ -38,20 +38,21 @@ struct RangeTrack { /// track and draws it hollow, so the widget still reads as an interval; dragging it sets a /// value, and only clearing the box beside the slider takes it back to absent. /// -/// **The ends do not stop the drag.** `lo`/`hi` are only what the modifier is *known* to roll — -/// which for an affix is the tier in hand and nothing else, because no data we have enumerates -/// its other tiers — and a buyer is entitled to ask for a roll better than the one they are -/// holding. So a knob pushed past an end keeps going, the domain widens to hold it, and the -/// pair of ticks left behind marks where the known range was. The domain is frozen for the -/// duration of a drag: rescaling the track under a moving knob makes the number jump about. +/// **The ends do not stop the drag.** `lo`/`hi` already reach past what the modifier is known to +/// roll — the caller widens them, see `widen_track` — because for an affix the known range is the +/// tier in hand and nothing else, no data we have enumerates its other tiers, and a buyer is +/// entitled to ask for a roll better than the one they are holding. Past even that reach a knob +/// keeps going, the domain widens to hold it, and `tick_lo`/`tick_hi` go on marking what the game +/// actually published. The domain is frozen for the duration of a drag: rescaling the track under +/// a moving knob makes the number jump about. /// -/// **A knob released hard against an end grows the track**, by a fifth of the range it started +/// **A knob released hard against an end grows the track**, by a quarter of the span it started /// with, so the next drag has somewhere to go. Pegging is how a user says the number they want is /// further out than the track offers, and answering it with a track that is still exactly as long -/// is how a slider reads as broken. The fifth is added beyond *where the knob was left* rather +/// is how a slider reads as broken. The quarter is added beyond *where the knob was left* rather /// than beyond the end it was pushed past, which is what leaves it visibly clear of the corner /// afterwards — a drag does not stop at the end, so the two are not the same place. Repeated, this -/// walks outwards a fifth at a time, and `kRangeLimit` is where it stops. +/// walks outwards a quarter at a time, and `kRangeLimit` is where it stops. /// /// Values are rounded to `dp` decimals. **A drag** never leaves `min` above `max` — pushing one /// knob past the other carries the other along, which is what makes an interval collapsible to a diff --git a/src/ui/track.cpp b/src/ui/track.cpp new file mode 100644 index 0000000..2db4b04 --- /dev/null +++ b/src/ui/track.cpp @@ -0,0 +1,22 @@ +#include "ui/track.hpp" + +#include +#include + +namespace ppc::ui { + +TrackSpan widen_track(double lo, double hi, int dp) { + if (hi < lo) std::swap(lo, hi); + // Clamped first, so the arithmetic below starts from a number it can be exact about. + lo = std::clamp(lo, -kRangeLimit, kRangeLimit); + hi = std::clamp(hi, -kRangeLimit, kRangeLimit); + + const double step = std::pow(10.0, -dp); + const double p = std::pow(10.0, dp); + const double out_lo = lo - std::max(std::abs(lo) * kTrackSpread, step); + const double out_hi = hi + std::max(std::abs(hi) * kTrackSpread, step); + return {std::clamp(std::floor(out_lo * p) / p, -kRangeLimit, kRangeLimit), + std::clamp(std::ceil(out_hi * p) / p, -kRangeLimit, kRangeLimit)}; +} + +} // namespace ppc::ui diff --git a/src/ui/track.hpp b/src/ui/track.hpp new file mode 100644 index 0000000..b1d6a1e --- /dev/null +++ b/src/ui/track.hpp @@ -0,0 +1,42 @@ +#pragma once + +namespace ppc::ui { + +/// The furthest any bound will go, either sign. Nothing the game prints is within six orders of +/// magnitude of it; it is here so that a track grown a few times, or a number pasted into one of +/// the boxes beside it, cannot run away into a value arithmetic stops being exact at. +inline constexpr double kRangeLimit = 2147483647.0; // INT32_MAX + +/// How far a track reaches past the numbers it is drawn around, as a fraction of each end's own +/// magnitude. **One constant for every track**, published range or derived one, because the +/// argument for the reach is the same either way: a buyer is entitled to drag toward a roll better +/// than the one in hand, and a track that stops exactly where the item does gives them nowhere to +/// drag. Half again is wide enough to cover the tier either side of most affixes without making +/// the roll itself a speck in the middle of the track. +/// +/// It is deliberately **not** a setting. There is no reading of the game data that makes one number +/// here more correct than another — see `widen_track` on what this may and may not claim — so a +/// setting would be asking the user a question nothing can answer. Change it here. +inline constexpr double kTrackSpread = 0.5; + +/// A track's two ends. +struct TrackSpan { + double lo = 0, hi = 0; +}; + +/// Widen `[lo, hi]` outwards into the span a slider is drawn over. +/// +/// Each end moves by `kTrackSpread` of **its own** magnitude, so a range the game prints negative +/// grows away from zero in the same direction it is read in, and at least one step at `dp` — the +/// smallest number the row can express — so a range that is a single value, or one sitting on +/// zero, still comes back with a track to aim at rather than a point. The result is rounded +/// outwards at `dp`, which keeps the ends readable numbers and never rounds the reach away. +/// +/// **This is a place to put the mouse, not a statement about what the affix rolls.** The caller +/// holds the published range separately and marks it on the track; nothing here knows what the +/// tiers either side are, and the spread must never be drawn as though it did. +/// +/// Crossed input is ordered rather than refused: the bounds behind a track can be mid-edit. +TrackSpan widen_track(double lo, double hi, int dp); + +} // namespace ppc::ui diff --git a/tests/track_test.cpp b/tests/track_test.cpp new file mode 100644 index 0000000..bbeb83a --- /dev/null +++ b/tests/track_test.cpp @@ -0,0 +1,80 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include "ui/track.hpp" + +using namespace ppc::ui; + +// The arithmetic behind every range slider's track. It is here rather than beside the widget +// because the widget links ImGui and this does not — and because the numbers are the part worth +// pinning: a track that fails to reach past the roll is the slider people call broken, and one +// that reaches by a different rule on each row is worse. + +TEST_CASE("each end moves by half its own magnitude") { + const TrackSpan s = widen_track(110, 134, 0); // a physical damage tier + CHECK(s.lo == doctest::Approx(55)); + CHECK(s.hi == doctest::Approx(201)); +} + +TEST_CASE("the roll always has room either side of it") { + // The property the whole thing exists for, over the shapes the game actually prints. + struct Case { + double lo, hi; + int dp; + }; + for (const Case c : {Case{110, 134, 0}, Case{1.30, 1.45, 2}, Case{-13, -9, 0}, Case{1, 1, 0}, + Case{0, 0, 0}, Case{0, 0, 2}, Case{6, 6, 0}, Case{20, 24, 1}}) { + const TrackSpan s = widen_track(c.lo, c.hi, c.dp); + CHECK(s.lo < c.lo); + CHECK(s.hi > c.hi); + } +} + +TEST_CASE("a range printed negative grows away from zero, not toward it") { + const TrackSpan s = widen_track(-13, -9, 0); // an eldritch implicit's cold resistance + CHECK(s.lo == doctest::Approx(-20)); // -13 - 6.5, rounded outwards + CHECK(s.hi == doctest::Approx(-4)); // -9 + 4.5, rounded outwards +} + +TEST_CASE("a tier that rolls one number still gets a track") { + const TrackSpan s = widen_track(6, 6, 0); + CHECK(s.lo == doctest::Approx(3)); + CHECK(s.hi == doctest::Approx(9)); +} + +TEST_CASE("the reach is at least one step at the row's own precision") { + // Half of nothing is nothing, which would hand back a track with no width at all. + const TrackSpan zero = widen_track(0, 0, 0); + CHECK(zero.lo == doctest::Approx(-1)); + CHECK(zero.hi == doctest::Approx(1)); + + const TrackSpan tenths = widen_track(0, 0, 1); + CHECK(tenths.lo == doctest::Approx(-0.1)); + CHECK(tenths.hi == doctest::Approx(0.1)); + + // And a value small enough that half of it rounds away at this precision: 0.02 either side + // of 0.04 would land inside the same step, so the step wins. + const TrackSpan small = widen_track(0.04, 0.04, 2); + CHECK(small.lo == doctest::Approx(0.02)); + CHECK(small.hi == doctest::Approx(0.06)); +} + +TEST_CASE("ends are rounded outwards, never inwards") { + // 1.30 - 0.65 = 0.65 and 1.45 + 0.725 = 2.175 at one decimal: 0.6 and 2.2, never 0.7 or 2.1. + const TrackSpan s = widen_track(1.30, 1.45, 1); // an attack speed + CHECK(s.lo == doctest::Approx(0.6)); + CHECK(s.hi == doctest::Approx(2.2)); +} + +TEST_CASE("a crossed range is ordered, not refused") { + // The bounds behind a track can be mid-edit, and half of a number being typed is a real one. + const TrackSpan s = widen_track(134, 110, 0); + CHECK(s.lo == doctest::Approx(55)); + CHECK(s.hi == doctest::Approx(201)); +} + +TEST_CASE("nothing escapes the range limit") { + const TrackSpan s = widen_track(-kRangeLimit, kRangeLimit, 0); + CHECK(s.lo == doctest::Approx(-kRangeLimit)); + CHECK(s.hi == doctest::Approx(kRangeLimit)); +}