From 782e9241162aaf4ca918d308a51bda4fb965df36 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 15:58:40 +0200 Subject: [PATCH] The slider's track reaches past the tier, and the ticks carry the claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: `ui::widen_track` in `ppc_core` — the one rule every slider track is built with: half again either side of the numbers it is drawn around, at least one step at the row's own precision, rounded outwards. ADDED: `tests/track_test.cpp`, which is why the arithmetic sits in `ppc_core` rather than beside the ImGui widget. CHANGED: a published range is what the track is built around rather than where it ends, and the two ticks are what mark it. - The tier in hand is the only tier anything we have can name, so a track stopping there made the two most useful drags — a bit better than this, a bit worse — impossible without typing. The width is a convenience; the ticks are the claim, and they stay on `roll_min`/`roll_max` wherever the ends get to. CHANGED: `RangeTrack` carries `tick_lo`/`tick_hi` in place of a `published` flag, so the ticks are there from the first frame instead of appearing once the track has been grown. CHANGED: a tier that rolls a single number keeps its ticks; it used to fall through to the derived branch and lose them. CHANGED: the reach's floor is one step at the row's `dp` rather than a flat 1, which was ten steps on an attack speed. CHANGED: a pegged knob grows the track by a quarter of the span it started with rather than a fifth, measured against the widened track the user can actually see. CHANGED: `docs/roadmap.md` records what a per-tier affix table would really cost. - The `Mods` table the data build already extracts carries the ladders — grouping its 4,369 item-domain prefix and suffix rows by stat-key set gives `local_physical_damage_+%` as eight tiers, 40–49 through 170–179. What is missing is the spawn weights that decide which ladders can appear on the base in hand, without which the union overstates the range. REMOVED: `kDerivedSpread` and `kDerivedFloor`, folded into the single constant. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 2 + docs/roadmap.md | 30 ++++++++---- docs/trade-layer.md | 53 ++++++++++++-------- src/screens/pricecheck_screen.cpp | 65 +++++++++++++------------ src/ui/range_slider.cpp | 39 ++++++++------- src/ui/range_slider.hpp | 41 ++++++++-------- src/ui/track.cpp | 22 +++++++++ src/ui/track.hpp | 42 ++++++++++++++++ tests/track_test.cpp | 80 +++++++++++++++++++++++++++++++ 9 files changed, 270 insertions(+), 104 deletions(-) create mode 100644 src/ui/track.cpp create mode 100644 src/ui/track.hpp create mode 100644 tests/track_test.cpp 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)); +}