dev #7

Merged
yami merged 6 commits from dev into main 2026-08-11 19:38:30 -04:00
Owner
No description provided.
Groundwork for the Optimize feature. Progression is gradual — a static three
weeks into a tier may be farming M1-M2 while still progging M3 — and which
floors are actually clearable is the single biggest input to any planning
maths, because book income is per-floor:

  * a player accrues one book per week only for floors in this set;
  * needs whose floor is absent are unreachable rather than merely slow, and
    have to be reported that way instead of given a fabricated ETA;
  * the floor-4 conversion (F4 books trade 1:1 into F1-F3 books, the main
    relief valve for someone stuck on one slot) only exists while floor 4 is
    clearable.

Stored on LootRoadmapSettings, which is already the per-static roadmap row.
Empty means "not configured" and every reader expands it to all four floors —
so existing statics keep today's behaviour, and an unconfigured static stays
distinguishable from one a lead deliberately set to all four.

Gated at Lead rather than Member, unlike SetMasterPriorityList: this is a
statement about the group's progression, not a personal preference, and it
moves everyone's projected timeline.

The migration sets defaultValueSql '{}' deliberately. ADD COLUMN ... NOT NULL
with no default fails outright against a table that already has rows, and
lootroadmapsettings is populated in production where migrations auto-apply at
startup — so without it this would have crash-looped the API on deploy rather
than failing at review time.

Input is normalised, not rejected: the UI sends checkbox state, so order and
duplicates carry no meaning and values outside 1-4 are not floors.
GET api/LootRoadmap/Optimize/{staticUuid} suggests a loot order that reaches
full BiS in the fewest weeks, plus a per-player book breakdown.

Shape of the problem, which drives the design:

  * Chest drops are random, so a week-by-week script would be fiction. What is
    decidable is the priority order per loot type — who takes a Body coffer if
    one drops — which is what the roadmap already stores, so Apply can reuse
    SetOverride unchanged.
  * Book income is not random: one book per clearable floor per week. That
    gives every player a guaranteed worst-case finish date, and a computable
    timeline. Drops only ever pull it in, so the estimate is a ceiling rather
    than a guess.

Objective is makespan — the last player's finish week — so a drop goes to
whoever has the most outstanding book cost on that item's floor, since that is
whose finish date it actually moves. Ties fall back to the static's curated
master list rather than the optimizer inventing a winner.

Two timelines are always returned: at today's floors, and as if all four were
open. Asking a lead to predict when they will kill the next floor would be
inventing a number; the gap between the two bounds says what progression is
costing without pretending to know.

Needs on an unclearable floor return null rather than a large number — no
books accrue and no chest can drop there, so any week count would be
fabricated. Those queues are still emitted, flagged unreachable, so they are
ready the week the floor opens.

Book prices move to RaidTier (nullable, falling back to the Dawntrail-era
values in SavageBookRules). They are balance numbers Square Enix has changed
between tiers, so hardcoding them would quietly produce wrong plans a patch
after nobody is looking. The floor-per-slot map lives there too rather than
being duplicated a third time alongside StaticController's loot breakdown.

Read-only by design: it returns a proposal and never writes, so a run cannot
silently overwrite an order the static agreed on.

Tests cover the floor-4 conversion (a lone 6-book body piece lands week 3, not
week 6, because floor-4 books convert once no weapon is owed), unreachable
floors, the makespan ordering, curated tie-breaks, alt exclusion, and tier
price overrides.
The model was already right — books accrue only for floors in the clearable
set, so an unticked floor contributes nothing — but every label around it said
"can reclear", which reads as capability. The setting means what the static
*does* clear, and the distinction matters because skipping is not free.

This also corrects advice given earlier in review: "stop clearing M1 once
nobody needs chests from it" is wrong in most cases. The book is guaranteed
income precisely in the 5+-cleared weeks where no chest appears at all, and
F1-F3 books are floor-specific, so dropping a floor stalls every need on it
outright rather than merely slowing chest RNG.

Wording tightened in the model, DTO, controller, service accrual site, types,
service and panel, plus a line in the UI telling leads to untick a floor only
if they genuinely skip it.
feat(roadmap): detect which floors are genuinely skippable
All checks were successful
Docker image / build (push) Successful in 1m8s
e656cca547
Answers "can we stop clearing this floor?" per floor, which is the honest
version of an idea that was wrong the first time it came up in review.

A floor earns its slot if anyone still needs its gear OR its books, and the
second half is what makes the naive rule dangerous. The book is paid on every
clear, including the 5+-already-cleared weeks where no chest drops at all, so
"nobody needs a coffer from here" is not grounds to skip. Books also never
flow out of floors 1-3, so dropping one of those stalls every need on it
rather than merely slowing chest RNG.

Floor 4 is special and the reason this is not a one-liner: its books convert
into every other floor, so it keeps paying with zero weapons outstanding. It
is skippable only once the entire roster is finished — in practice, never
during a tier. Verified against a live stack: a roster owing only a floor-3
body reports M1/M2 skippable, M3 needed, and M4 NOT skippable despite zero
demand of its own.

Returns structured facts (cleared, outstandingBooks, playersNeeding,
skippable, convertsToOtherFloors) rather than prose, so the UI can phrase the
three distinct cases — safe to skip, keep clearing, and needed-but-not-being-
cleared — without re-deriving the conversion rule.

Five tests, including the floor-4 case a naive implementation gets backwards
and an upgrade-only need proving twine keeps floor 3 alive while leaving the
floor its gear drops from skippable.
feat(roadmap): seed the optimizer from players' current book balances
All checks were successful
Docker image / build (push) Successful in 16s
a1be3a9a6d
The simulation started every player at zero, which for a static mid-tier was
systematically pessimistic by up to one book per floor per elapsed week. It
also skewed the ordering: someone sitting on five M3 books will buy the piece
regardless, so a coffer is worth far more to a player at zero, and the
makespan heuristic could not see that.

Balances are hand-entered (PlayerBookBalance, unique per player and floor,
Member-gated so a lead can keep the whole roster current). Keyed to a player
rather than an account because books live in a character's inventory, exactly
like gear.

Staleness is the known weakness of hand-entered data, so it is treated as
part of the record rather than hidden: every row carries UpdatedAtUtc, stamped
even when the number is unchanged, since re-confirming a balance is the signal
that it is still true this week. Callers are expected to surface its age.
Missing rows mean zero, so a half-filled roster degrades to the previous
worst-case plan instead of refusing to produce one.

Two behaviours worth noting, both covered by tests:

  * A player who can already afford everything reports zero weeks, not one —
    they are waiting on a vendor trip, not a clear.
  * Reachability is now judged after spending what is banked. A locked floor
    stops income, not spending, so someone holding enough books can buy the
    piece today regardless of whether the group can still kill that boss. The
    check previously ran first and reported such a roster as unreachable when
    it was in fact already finished; a test caught it.

SpendAndConvert was extracted so the pre-week pass and each simulated week
spend by identical rules, rather than the seeded balances taking a subtly
different path.
feat(plugin): sync savage book counts from character inventory
All checks were successful
Docker image / build (push) Successful in 16s
ecf97b33a4
PUT /api/characters/{id}/books lets the plugin report book counts read from
the character's inventory, so the roadmap's estimate stops depending on
someone remembering.

Deliberately an additional source, not a replacement. The plugin does not
exist on every platform and some players would rather not run one, so a
single roster routinely mixes automatic and typed figures. PlayerBookBalance
gains a Source (Manual/Plugin) to record which, because the two have very
different trust profiles: an inventory read is true at a known moment, while
a typed figure drifts by one per cleared floor per week. Last write still
wins in both directions — a player who stops running the plugin can type over
their own numbers rather than being locked out, and a hand edit takes the row
back to Manual so its origin is not misreported.

Floors absent from a payload are left untouched rather than zeroed: a partial
sync is far likelier than a character genuinely holding none of a floor's
books, and wrongly zeroing would silently inflate the estimate. A floor sent
as zero is still honoured, since spending down to zero is real.

The plugin sends floor totals rather than raw item ids because it is the side
that can ship support for a new tier's books without an API deployment — the
server stores no book item ids, so mapping here would need a config table
that goes stale exactly when a tier launches.

Broadcasts UpdateLootRoadmap so an open Optimize panel refreshes for the
whole static the moment anyone's plugin syncs.

Covered by extending the existing device-flow test: two floors sync and land
as Plugin, the untouched floors stay absent, and a follow-up sync of a single
floor overwrites only that one.
yami merged commit 2492a55e4f into main 2026-08-11 19:38:30 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yami/XIVLoot!7
No description provided.