Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
30 changes: 20 additions & 10 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 32 additions & 21 deletions docs/trade-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 32 additions & 33 deletions src/screens/pricecheck_screen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
#include <algorithm>
#include <cctype>
#include <charconv>
#include <cmath>
#include <cstdio>
#include <ctime>
#include <memory>
Expand All @@ -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 {
Expand Down Expand Up @@ -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<double> 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> 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<std::optional<double>> candidates) {
Expand All @@ -371,12 +373,8 @@ std::optional<Track> track_for(const Interval& iv) {
const std::optional<double> lo = anchor({iv.seed_min, iv.seed_max, *iv.min, *iv.max, iv.roll_min});
const std::optional<double> 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
Expand Down Expand Up @@ -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
Expand Down
Loading