Patch notes
The full technical record. For the plain-language version, see what's new.
Undercut — patch notes
The full technical record, newest first. Served publicly at the site's
/patchnotes; the plain-language player version lives in whatsnew.yaml
(/whatsnew). Release ritual mirrors regear/docs/RELEASING.md: bump
VERSION in app.py, add a dated entry here, and (for user-visible changes)
a whatsnew.yaml entry — all in the same PR. Don't put anything here you
wouldn't post publicly.
v0.18.0 — the polish train — 2026-07-11
Transmutation, eighth car — and a silent /craft/top distortion fixed on the
way. Research surprise: the dumps model transmutes as ordinary alternative
recipes (flat @silver fee + a TRANSMUTE craft-button override), and the
parser was ingesting them while silently dropping the fee — so live
/craft/top has been ranking tier-ups/enchant-ups as FREE conversions.
Now: staticdata parses silver_cost + transmute onto recipes (schema
migration, fixture-tested), /craft/top excludes transmute recipes, and the
new /transmute board ranks every conversion — buy the source, pay the
fee, sell the target (calc.transmute_profit, golden-tested; no returns,
no focus) — with the full integrity kit: capped asks, proven-first,
Vol/day, freshness chips on both legs, top-7 behind the counter, free tail
cap. Fees come from the weekly static refresh, not a hand-copied wiki
table, so balance patches update them automatically. Farming/alchemy
economies stay research items on the roadmap.
The snappy pass, seventh car — measured first, then three fixes the numbers
asked for. Measure: a timing middleware stamps Server-Timing on every
response and logs anything over 300 ms (no APM). Fix 1: /craft/top's
rank_crafts (the one CPU-bound request path) joins the boards' 60 s cache —
stock-knob combinations only (bool toggles × tier × sort × city × region is a
bounded key space; a user-typed fce/fee would grow the cache dict without
bound, so personalized knobs recompute per request as before). Fix 2 — attempted,
measured, rejected: the partial index the roadmap earmarked for the boards'
fresh-CTE scans (flip parallel-seq-scans the whole 360 MB prices_current
heap, 435 ms of 665 ms). Built it CONCURRENTLY on live (a plain build under
ingest's HOT updates came out indcheckxmin — planner refuses those), then
measured: the ~30k fresh rows scatter across the heap, and on Longhorn's
random-read profile the bitmap heap scan ran 1.5–3.1 s vs 556 ms for the seq
scan it was meant to replace. Dropped; schema.sql keeps the negative result
as a comment so it isn't re-tried blind. Fix 3: jit=off on the app pool — JIT was compiling
120 ms of machine code per flip query to return 6 k rows.
One account, two doors, sixth car. A Discord login and an email login used
to be two unrelated accounts; /account now has a "Sign-in methods" card that
links them. Discord-only accounts add email+password (POST /account/email,
same validation as /register); email accounts click "Connect Discord" — the
OAuth state payload carries the link intent (auth.make_state(link=True) /
read_state; pre-deploy plain-string states still accepted), and the
callback attaches the identity to the live session instead of logging in
(auth.fetch_discord_identity split out of handle_callback, no users-table
write). Both directions are one guarded UPDATE (queries.link_discord /
add_email_login): only onto an account missing that door, only an identity
no other account owns — race-safe, no transaction. Link-only v1: merging two
EXISTING accounts (watchlists, presets, plans) is deliberately out of scope.
Copy link grows up, fifth car. The button was on two pages (craft, flip)
with no explanation of what it copies; now every tool page has it in a
consistent spot — result header next to the watch button (craft, refine,
item) or right under the board's summary line (flip, haul, blackmarket,
craft/top) — and a title spells out the intent: the URL carries city,
filters and your prices, so the link IS the calculation. No mechanics
changed (still location.href, still no deps).
The calculators get personal, fourth car. Two assumptions dropped: that you
craft and sell in the same city, and that every material comes off the
market board. Sell city — craft and refine forms grow a "Sell in" select
(defaults to the craft city, so existing URLs and presets are untouched);
_craft_with_prices takes a separate sell-price map, the by-city explorer
deliberately stays same-city ("where is this craft best"). Your prices —
every material row gains an editor (px_<ITEM_ID> query params via the
native form= attribute, no JS): serious crafters buying from trade
discords type what they actually pay, overrides win over the market, also
fill market holes, and wear a brass "your price" badge instead of a
freshness dot (a user's number is not a market observation — DESIGN rule 2
respected, not faked). Overrides ride in the URL: Copy link and presets
carry a fully personal calculation. Dead _craft_in_city helper deleted.
Outlier defense on What-to-craft, third car — and the answer to "why is the
board all capes". Three structural holes let one manipulated listing own the
ranking: no volume weighting, price_suspect silently passing anything
without a 7-day baseline (exactly the thin artifact markets), and a lone RMT
ask setting the sell price. Now: product asks above suspect_ratio × the
market's own avg_7d are re-ranked instead of hidden — a flagged ask
is a flag, not a price, so the row ranks as if selling at the week's average
(calc.capped_ask, golden-tested; ranking at the 3× ceiling still let a
pumped listing own the board), stays visible, and wears an "ask held" badge
disclosing the real ask; recipes
with no weekly trade evidence sort after proven ones (stable partition)
and wear "unproven"; a Vol/day column (from price_baselines.daily_volume,
now returned by sell_price_map) shows the throughput evidence. Suspect
input prices still skip the recipe — a cost built on a manipulated ask lies
both ways. CSV export gains daily_volume/proven/ask_capped_from.
Gates + grades, second car.
The top-7 lock reaches every ranked board. PRO_TOP_ROWS 5→7, and
/craft/top — which redacted nothing (free saw the top 20 openly, only the
tail capped) — now holds its best rows behind the counter like
flip/haul/blackmarket: podium cards and table rows keep their numbers but
identity (name, icon, link) is never rendered, option-B style. The CSV was
already Pro-only.
Scores wear letters. calc.score_grade buckets trade_score into S+/S/A/B/C/D
(log-ish, ~×3 a step, golden-tested); flip/haul/blackmarket render a
.c-grade badge (styleguide'd) instead of a seven-digit silver/day figure
that read like a bug. The column header tooltip explains the letters; raw
scores still order the board server-side and stay in the Pro CSV exports.
Polish pass, first car of the v0.18.0 train.
Region switcher actually switches now. The nav <select>'s inline handler
did new URL(location) — but inline handlers resolve bare identifiers
through the element→document→window scope chain, so URL hit document.URL
(a string) first and threw URL is not a constructor. The cookie write
before it succeeded, which is why the next navigation always showed the
right region and nobody noticed the missing reload. Reproduced live with
agent-browser; fix is new window.URL(...).
Status page stats align. .c-stat is inline-block and sat on the default
vertical-align: baseline, so a card whose label wraps (ledger table names)
or carries a freshness chip (gold) pushed its big value upward relative to
its neighbors. Now vertical-align: top.
Ticker under prefers-reduced-motion becomes a hand-scrollable strip
(overflow-x: auto) instead of a frozen bar. (Owner's "ticker doesn't move
on my PC" was this media query honoring the OS animations-off setting —
kept, it's correct a11y.)
Identity for shares and tabs. Hand-written static/favicon.svg (geometric
brass "u" + the live freshness dot) with an apple-touch-icon and a
/favicon.ico route (PNG bytes; every consumer sniffs). Default
og:image — a 1200×630 Counterweight card rendered from
devtools/brand.html with the real vendored fonts (agent-browser element
screenshot, no new deps) — so any URL pasted into Discord unfurls with a
branded graphic; twitter:card upgrades to summary_large_image except on
craft-with-item pages, which keep their square item-icon summary.
v0.17.0 — the shareable release — 2026-07-11
SEO/OG groundwork. robots.txt and sitemap.xml are routes (both need
PUBLIC_BASE_URL); the sitemap lists the 11 static public pages only —
/item/{id} is deliberately absent, thousands of thin pages better reached
through internal links. base.html gains the head block: meta description
(overridable per page), path-only canonical, og:/twitter tags. Canonical
strips the query string on purpose so /craft?item=… variants collapse
into one indexable page; og:url keeps the query so a scraper re-fetch of a
shared link lands on the result, not the blank calculator.
Shareable permalinks. Craft results already lived entirely in the URL
("a URL IS a saved preset") — they now carry result-aware titles and
descriptions plus an item-icon og:image, so a pasted link unfurls with the
item, city and profit (loss copy is honest: "the math said don't"). Flip's
description is aggregates-only by constraint: _lock_top redacts row
identities in the template, so naming flips[0] would hand the Pro rows to
every unfurl. Both pages get a Copy link button (_macros.copy_link,
navigator.clipboard, no new deps).
Watchlist overnight. Logged-in, the landing hero's movers floor becomes
"your watchlist overnight": new queries.watchlist_overnight extends the
watchlist LATERAL with a second one against price_history (scale 6, the
cheapest city's OWN bucket ~24h back, 48h floor so sparse markets don't
diff against week-old data), move computed in SQL — ::float before the
divide, because bigint/bigint quietly truncates every move to 0 — biggest
movers first, per-user and uncached (cache-key discipline). EXPLAIN on the
live db: both LATERALs are index walks, the history probe a backwards PK
scan, 0.7 ms. Logged-out landing is byte-identical to before; logged-in
with an empty watchlist keeps the movers floor plus a hint line.
seed_db.py now writes scale-6 buckets so screenshots show real deltas.
Tech debt: new --radius-lg token (10px) replaces the ad-hoc 8/10/12/9px
radii on navlink/menu/floor/tile. Screenshot matrix light+dark passed with
no overflow; suite + ruff green.
docs: roadmap consolidation — one living plan — 2026-07-11
Plans were smeared across five docs, two already stale. New
docs/ROADMAP.md is now the ONLY place plans live: vision, owner handoffs,
pending decisions, now/next/icebox, launch gates, done-log, doc map — with
the standing rule (also in CLAUDE.md) that any PR shipping/deferring/
deciding a board item updates it in the same PR. Retired:
docs/BIG-UPDATE.md (fully executed 2026-07-10; git history keeps it —
its Ch. 0 dev-box operating manual moved into devtools/README.md, which
also loses its docker-only recipe: this box runs the throwaway Postgres as
a cluster pod) and docs/PRODUCTION.md (absorbed under Launch gates).
MARKET.md drops its embedded roadmap/retention lists for pointers and stays
the competitor reference. No code changes.
docs: marketing plan — Google Ads + social posting — 2026-07-11
New docs/MARKETING.md: the phased promotion plan for undercut.market.
Organic first (Reddit/forum/Discord launch posts + a weekly data-post engine
fed by the site's own numbers), then a capped €5-10/day Google Ads search
test with exact/phrase keywords, negatives, and a weekly search-terms loop.
Paid conversion tracking is deliberately deferred: the site ships zero
third-party scripts, so gtag would force the GDPR consent banner — the test
measures via URL params + logs instead, and the banner only gets built if
the test earns a real budget. Draft ad copy and the launch post are written
through the human-copy pass and lint-checked. No code changes.
docs: beta access model options + no-KVK payment routes — 2026-07-10
PRODUCTION.md gains §1b: the three ways to run the pre-payment beta (free Pro for accounts / everything open / gates + waitlist) with trade-offs, recorded for a later owner decision — the live gates currently point at a counter that can't take money, and that tension is now written down instead of ad-hoc. Also records the Stripe-needs-KVK finding and the merchant-of-record fallback (Paddle/Lemon Squeezy) for selling without a Dutch business registration. No code changes.
ops: domain cutover to undercut.market — 2026-07-10
The owner bought undercut.market and wired Zoraxy + DNS; the site serves
there with TLS. PUBLIC_BASE_URL flips from the staging domain to
https://undercut.market — this drives the Discord OAuth redirect
(/auth/callback), verification-mail links, and the (future) Stripe webhook
URL. The matching redirect URI must be added on the Discord application in
the same window. No code changes.
v0.16.1 — Daily bonus, told straight — 2026-07-10
BIG-UPDATE Ch. 6 asked for auto-applied daily bonuses from an authoritative rotation. Research verdict: there is no rotation to transcribe — the game draws two random categories at server downtime (multiple wiki/forum sources agree), shows them only in the in-game Activities window, and neither ao-bin-dumps nor any API exposes the draw. Auto-apply would mean fabricating data, so the checkbox stays manual and now explains itself: a tooltip on the craft/refine "daily bonus" checkbox says how the mechanic works and where to look. constants.yaml documents the finding; the 10%/20% magnitudes keep their [VERIFY] marks until the owner confirms them in-game.
docs: Big Update research chapters — 2026-07-10
The three research deliverables from docs/BIG-UPDATE.md, no code changes:
- docs/CLIENT.md (Ch. 3) — Pro scanner-client report. Headline findings:
the stock AODP client already dual-feeds via its
-i/-pingest flags, so the recommended architecture is "official binary + one extra URL", no fork, no binary distribution (MIT license moot); SBI's "look and analyze is fine" stance is quoted with sources; the January 2024 market-data encryption (per-account trust gating, history/gold only for encrypted accounts) is the material risk and is priced into the go/no-go gate. ~3.5 days to a beta, gated on owner TOS sign-off + a spike proving the second-ingest path. - docs/PRODUCTION.md (Ch. 4) — launch gate list with tree-verified current state: Stripe shipped-but-dark, the dormant email-verification flow, GDPR gaps (deletion/export/policy pages), rate-limit scope, missing security headers, backup-restore drill, monitoring probe, SEO groundwork, and the domain as the critical path. Includes the "Redis only when web replicas > 1" scaling note.
- docs/MARKET.md (Ch. 5) — retention & signup drivers as an effort×impact board: watchlist landing summaries and OG permalinks as immediate wins, the portfolio tracker as the strategic build, bot/digest as one delivery story behind owner-blocked credentials.
v0.16.0 — Trade-quality scoring + the counter gate — 2026-07-10
The scans ranked by absolute unit profit, so the free top-20 filled with technically-tradable but practically-dead markets (BIG-UPDATE Ch. 2b). Now they rank by what a trade can actually clear.
Data: flip/haul candidates gain buy-leg (source city) volume, and
blackmarket_candidates gains its first liquidity evidence (source volume +
average) — all cheap price_baselines joins, no LATERALs. freshest_movers
also folds in the R2 deferral: suspect prices no longer make the landing
tiles or ticker marquee.
Score: pure calc.trade_score(unit_profit, buy_volume, sell_volume,
age_s, baseline_distance) ≈ realizable silver/day — unit profit × the
thinnest KNOWN leg's daily volume, halved per 6 h of price age, discounted
by distance from the priced leg's own 7-day average. Unknown volume doesn't
bind (unknown is not zero); known-zero binds hard; nothing-known scores 0.
Golden tests. Flip/haul/BM sort by score; each row shows it in a Score
column with a plain-words tooltip; the flip CSV exports it.
Pro gating — option B: free (and logged-out) users see the whole ranked
board shape, but the top 5 rows' item/cities are redacted server-side (block
glyphs — the real strings never reach the HTML; the flip hero card redacts
too, including the item render). Numbers stay visible: the pitch is "the
best trades are behind the counter", not a blank page. PRO_TOP_ROWS = 5;
the .c-gate card and /billing copy updated to match; craft-top keeps its
plain top-20 (it's a crafting ranking, not a trade board). New .c-lockedrow
+ .u-redact on /styleguide.
v0.15.0 — The price-integrity layer — 2026-07-10
One manipulated listing used to poison everything downstream of it: craft
costs, ROI rankings, alerts. AODP ships only sell_price_min, so a lone 50x
listing IS the minimum. v0.12's spread_flags guarded the flip/haul display
only; the calculators consumed raw prices unguarded. This release builds the
one choke point (BIG-UPDATE Ch. 2a):
Evidence — new price_baselines table: per-(region, item, city) 7-day
average price + daily volume, refreshed by ingest after each daily-scale
history sync (+ 2-day prune so dead markets read as no evidence). The
flip/haul dest-leg LATERALs over 9M history rows are gone — they read the
same numbers from the table now.
Verdict — pure calc.price_suspect(sell_min, avg_7d, max_over, max_under)
-> None | 'inflated' | 'crashed', spread_flags generalized. Bound =
price_integrity.suspect_ratio (3.0) in constants.yaml — deliberately looser
than the flip display bound: excluding an input silently breaks a whole
recipe, so only clear manipulation qualifies. No baseline = no verdict — a
thin market keeps its only honest price. Golden tests.
Consumption per surface:
- Calculators (craft / refine / craft-top): suspect = MISSING via one
_hold_suspect gate — the existing missing-price degradation paths are
honest. rank_crafts already skips recipes with holes.
- By-city tables: the suspect city's row drops, with a muted "suspect price
held back in …" note naming the cities.
- /item price grid: never hides — the price wears a suspect ask warn chip
(quality-1 rows, where the baseline evidence applies).
- Alerts: _fire_alerts ignores suspect prices — a manipulation spike must
not trigger everyone's "climbed to X" alerts. No baseline = alert still
works.
- Flip/haul/BM: display logic unchanged, evidence now from the table.
Deploy order: price_baselines is applied to the live DB before this
deploys (idempotent DDL, also in schema.sql for ingest's startup apply), so
the web never queries a missing table.
seed_db grows three demos: a manipulated product (T4_2H_CROSSBOW Martlock 12x), a manipulated input (T4_METALBAR Thetford 15x — poisons every metal recipe there), and a crashed bait listing (T4_BAG Bridgewatch /8).
v0.14.1 — Landing perf hotfix — 2026-07-10
v0.14.0's tradeworthy guard on freshest_movers (an EXISTS-OR in the WHERE)
pushed the live planner into a per-item nested loop over prices_current:
8.5 s per call, on the landing page's request path and in the minutely ticker
refresh for all three regions. Two fixes, measured on the live DB:
- The guard becomes a semi-join against one
UNIONof recipe ids — same rows, 471 ms (also faster than the pre-guard query's 1.2 s). - The landing page caches its movers fetch for 60 s per region, the same
in-process pattern (and reason) as the
countsstat next to it. Warm landing hits are back well under the 400 ms budget.
v0.14.0 — Owner-testing fixes — 2026-07-10
The five bugs from the owner's testing pass (docs/BIG-UPDATE.md Ch. 1), plus two small design-debt items from Ch. 8 that ride along.
Region switcher works logged-out. The nav select now writes a plain
region cookie (unsigned — worst case a forged cookie shows another server's
prices) and resolve_region reads it between the ?region param and the
account default. The select's own fallback chain reads the cookie too, so
pages without a region context (billing, account) show the right selection.
Logged-in behavior unchanged; /settings/region still writes the DB default.
Item charts stop drawing time backwards. The Chart.js category axis
ordered labels by first appearance across datasets, so a date only a
sparse-coverage city had got appended after the dense city's last label —
non-monotonic axis, zigzag lines. item_page now ships one sorted union of
dates as labels with every city's series aligned to it; missing days are
honest null gaps (spanGaps: false). While in there, the city palette
now follows DESIGN.md rule 10: brass for the highlighted series (Caerleon),
ink neutrals told apart by dash pattern for the rest — profit/loss hues no
longer decorate non-semantic series.
Raw item ids stop leaking into names. freshest_movers — the one scan
query without the v0.12 tradeworthy guard, feeding the two most visible
surfaces (landing tiles, ticker movers) — now carries the same
recipes-or-recipe-inputs EXISTS predicate. Item search sinks unnamed rows
(name_en = id) below real matches. Watchlist and alert rows keep raw-id
items the user added deliberately but render the id in the data mono, so it
reads as an id.
Blank item renders hide themselves. render.albiononline.com has no image
for token/skin ids; both <img> sites (the iframe() macro, the landing
tiles) get an onerror that leaves an empty inventory frame instead of a
broken-image icon.
"Flip the spread" explains itself. The landing card now says what a spread is and that the tool finds it: "The same item, two cities, two prices. This finds the gaps still worth it after the round-trip tax."
Light-theme freshness chips darkened for contrast (Ch. 8 debt): --fresh
1E7C4D→#186B41, --aging #9A6A14→#85590F — the chip renders at 12 px and
was ~4.3:1 against --bg; both now clear 4.5:1 on both surfaces. Dark theme
untouched; DESIGN.md palette table updated in the same PR.
docs: the Big Update brief — 2026-07-10
New docs/BIG-UPDATE.md: the self-contained implementation brief for the next
major update, written for the agent that will execute it. Contents: an operating
manual (env quirks, the agent-browser loops, byte-diff gate, release ritual);
five root-caused bugs from owner testing (anonymous region switcher has no
persistence; item charts break on Chart.js category-axis label union; raw item
ids leak through freshest_movers — the one scan query missing the tradeworthy
filter; blank icons lack an onerror; the flip counter-card copy); the price
integrity layer (suspect-price baselines so one troll listing stops poisoning
craft costs/ROI/alerts); trade-quality scoring for the scan rankings + Pro
gating options; research chapters (Pro scanner client with TOS analysis,
production-readiness checklist, retention drivers); the real daily-bonus
rotation data task; and the deferred/roadmap carry-overs. No code changes.
v0.13.0 — The front counter — 2026-07-10
The design pass the foundation release made room for: the nav, the pro surfaces, and the four scan pages each get a designed front-of-house.
Nav. Desktop groups the ten flat links into Craft ▾ (calculator /
refine / what to craft) and Trade ▾ (flip / haul / Black Market) plus Gold
and a brass Pro link — native <details> disclosures (click/tap/keyboard
everywhere; a 4-line script adds outside-click dismiss), NOT the Popover API
(positioning support risk) and NOT hover menus (iOS never focuses buttons).
The signed-in right side becomes an account menu (Account / Watchlist /
Alerts / Log out). Status moves to the footer. Mobile keeps the flat
swipeable strip; the strip⇄groups switch sits at 920 px, undoing v0.12.0's
1279 px stopgap since the grouped bar measures ~800 px. New .c-menu
component on /styleguide.
/account (new). Plan card with usage meters (.c-meter, watchlist
7/10-style), the region default finally gets a read surface (posts to the
existing /settings/region), sign-in identity, and — for Pro subscribers
with a Stripe customer — POST /billing/portal into Stripe's billing portal
(degrades to receipt-email copy when the portal isn't available).
/billing redesign. The bare comparison table becomes two .c-plan
pricing cards — Free ("0 silver, ever") and Pro with the counter's brass top
edge, profit-green checks, and an optional PRO_PRICE_TEXT env for the
display price — with the detail table demoted below.
The Pro scan gate (the model every paid competitor converged on: free =
check things one at a time, paid = the ranked board). Free accounts see the
top FREE_SCAN_ROWS = 20 of each ranked scan (flip, haul, Black Market,
what-to-craft); a .c-gate card under the table counts what's behind the
counter. Calculators (craft/refine) are never capped; CSV stays Pro.
The scan pages, redesigned. Each keeps its data density and gains an
identity: flip opens with a best-spread hero (.c-bestrow + .c-route
buy→sell legs, both chipped); haul frames the route ("Martlock ⟶ Caerleon",
capacity, a red-zone badge replacing the old flash banner, best-trip stat);
Black Market gets its shadowed header card ("the buyer in the dark");
what-to-craft podiums the top three (.c-podium, itemframes + profit + ROI)
above the rest of the table. All four tables now sit in cards. /craft
opens populated (CRAFT_DEFAULT_ITEM = "T4_BAG") instead of an empty form —
the "craft looks boring" complaint was really "craft is empty until you
type".
Dev. get_user now returns discord_id/email/stripe_customer_id;
shoot.py shoots /account and a logged-out /billing.
v0.12.0 — The foundation — 2026-07-10
Mechanics release before the design pass: page speed, data honesty, the sweep deadlock, the frozen ticker, and a new browser-automation harness.
Performance. Live TTFB was 1–2.3 s on the heavy pages; the budget is now
<400 ms. Three moves, no new infra: (1) the ticker's TTL dict generalized into
cached(key, ttl, fetch) (60 s on the flip/haul/BM candidate scans — within
the scanner's own 120 s cadence, and every price still wears its freshness
chip — 1 h on the static recipe list; keys only ever contain clamped params,
see the _CACHE comment). (2) The /refine chain (~36 sequential round
trips: 4 queries × 7 tiers + a 7-city compare) and /craft (~13) now batch:
items_by_ids + recipes_with_inputs_many + one all-city
sell_prices_with_age_cities fetch, with _craft_in_city split so
_craft_with_prices computes from the pre-fetched map — 4 queries per page.
(3) /item fetches its 7-city history in one history_for_item_cities query
and gathers the rest; /, /status, /craft/top gather their fan-outs.
Post-deploy measurement found the last ~0.9 s on / and /status: the
ledger-size count(*) over ~9M price_history rows — now behind the same
60 s cache.
Data sanity — the 72,436% flip. The flip page could pair a real cheap
sell with a lone manipulated ask (the dear leg had no bound at all; live DB
showed 25 items with a >100× cross-city spread, worst 12,345×). New pure
calc.spread_flags(sell, dest_avg_7d, dest_daily_volume) flags a dear leg
that asks >2× the destination's own 7-day average ('outlier') or has no
recorded sales there ('no-market'); wide spreads against a real market stay —
hauling exists because cities disagree. The candidate queries also mark items
outside the crafting economy (tradeworthy: in recipes or recipe_inputs).
Flagged rows are held back with a count + "show them anyway" link (?all=1
renders them wearing a .c-badge--warn naming the reason); the Black Market
dear leg is the game's own standing buy order, so only the tradeworthy check
applies there. Haul gained the same guard plus a Vol/day column. min_price
moved from the flip SQL into Python (bounded cache keys).
The sweep deadlock. price_sweep and the NATS consumer upsert overlapping
(region, item_id, city, quality) rows; batches landed in AODP-response
order, so the two writers took row locks in different orders and Postgres
killed one every few minutes (DeadlockDetected on /status). Every upsert
batch (sweep, NATS, history) is now sorted by its conflict key before
executemany — consistent lock order is the actual fix — and v0.11.0's
prune-only deadlock retry generalized into _retry_deadlock around all of
them. The freshness-guard WHERE on PRICES_UPSERT is untouched.
/status degrades per card. A missing table used to 503 the whole canary
page through the global DBError handler; each card now degrades alone
(_card → a muted "this ledger is missing" note) and the cards fetch
concurrently.
Ticker. The track was width: max-content with ~6 short items — narrower
than an ultrawide viewport, so the -50% animation was imperceptible and the
bar sat mostly empty. The two content passes are now explicit
.c-ticker__sets with min-width: 100vw (a set always fills the bar; the
-50% keyframe is exactly one set, so the loop is seamless), and the ticker
carries more market: each region's two freshest movers, linked. Reduced
motion still gets a static, now full-width, bar.
Icons. <link rel=preconnect> to render.albiononline.com, and the icon
host is now the ICON_BASE Jinja global (env-driven) — devtools point it at
/static to run offline, and a caching proxy can swap in without template
changes.
Dev. devtools/shoot.py now drives the agent-browser CLI instead of
Playwright — same viewport matrix, same overflow --assert gate (via eval),
sessions via the existing /dev/login//auth/logout instead of minting
cookies. The agent-browser skill is installed repo-level (.agents/skills/)
for all future UI work. seed_db.py seeds history for every item/city
tracking each city's price (±10%) plus three deliberate flag demos (a 900k
outlier ask, a no-history artifact, a no-recipes token). New
undercut/docs/MARKET.md: the competitor landscape + prioritized roadmap.
v0.11.0 — The counter — 2026-07-10
A full front-of-house rebuild, plus the fix for the failed rows that were sitting on /status.
The shell. The top nav is now sticky and translucent (backdrop blur), with
a brass edge and the wordmark's undercut-step reused as the active-tab marker;
on mobile the tools collapse to a swipeable pill strip. A live market ticker
sits under the nav on every page — each region's gold price and fresh/aging/
stale counts, refreshed in-process at most once a minute (get_ticker, TTL
cache; attach_ticker HTTP middleware skips static and htmx requests).
The landing. New hero: "freshest on the floor" tiles (freshest_movers
query — one row per item, freshest first) rendered in Albion inventory-frame
thumbnails (.c-itemframe), a tool-card grid, and the floor stats. The old
centred 1200 px column is gone: a .page CSS grid gives content / wide / bleed
tracks, so the wide multi-column tables (flip, craft/top, haul,
blackmarket) break out and the hero bleeds full width. Motion is one
compositor-only page-load reveal plus native cross-document View Transitions,
both under prefers-reduced-motion.
Performance. GZipMiddleware (pages were shipping uncompressed — /status
dropped 8.6 KB → 1.8 KB on the wire), immutable long-cache on /static
(fonts + htmx + Chart.js stop revalidating every navigation; style.css is
cache-busted with ?v=VERSION), and <link rel=preload> on the two woff2.
Ingest reliability — the /status failures. The pool was the psycopg default
(max 4) against ~18 always-on ingest coroutines, so a boot-time stampede timed
out and pinned FAILED rows on /status until each task's next (up to weekly) run.
db.py now sizes the pool from DB_POOL_MAX (ingest Deployment sets 16) and
validates connections on checkout (check_connection) to survive a CNPG blip.
prune retries on deadlock against the live writers, and the long-interval
tasks (history, prune, static) retry in 5 min on failure instead of waiting out
their full interval.
Dev. devtools/shoot.py gained a --assert horizontal-overflow gate (fails
with selectors), covers every page, and aborts off-origin requests so the loop
runs offline. Added the human-copy skill and a read-only design-review
agent.
v0.10.0 — The till — 2026-07-10
Phase 5 of the launch plan: the pro tier. Everything ships dark until the
Stripe keys exist (stripe-secret-key / stripe-webhook-secret /
stripe-price-id in undercut-secrets, all optional: true in the
deployment) — /billing says "not purchasable yet" until then.
/billing: free-vs-pro table (calculators stay free — the funnel), Stripe Checkout (subscription mode) viaclient_reference_id, webhook with signature verification flipsusers.planoncheckout.session.completedand back oncustomer.subscription.deleted. Untested against live Stripe until an account exists — test-mode checkout is the acceptance step when keys land.- Pro gates: CSV exports (
/flip.csv,/craft/top.csv— full result set, not the page's top slice), watchlist beyond 10 items, more than 3 armed alerts. Gate misses redirect to /billing with honest copy. - /flip and /craft/top refactored into shared data helpers so the HTML and
CSV routes can't drift.
stripedep pinned, imported lazily. - schema:
users.stripe_customer_id,users.plan_expires_at.
v0.9.0 — Keys and tripwires — 2026-07-10
Phase 4 of the launch plan: email accounts + price alerts.
- Email + password accounts: register / login / verify / forgot / reset,
argon2id hashing (
argon2-cffi), same signed-session cookie as Discord so everything downstream is auth-method-agnostic. Login throttle (10 tries / 15 min per address, in-memory — ponytail: per-pod, DB counter if replicas grow). No address enumeration on the forgot flow. - Mailer is provider-agnostic: stdlib smtplib behind SMTP_* env vars (optional secret keys already slotted in deployment.yaml). Until a provider is chosen the app degrades: registration works unverified, forgot-password explains itself. Picking the provider = patching the secret, zero code.
- Price alerts: armed from any item page ("tell me when it drops
to/climbs to X"), checked ~every minute by a new ingest task against the
cheapest fresh royal-market sell price; fired alerts wait on
/alerts(re-arm/drop). Delivery channels (mail/Discord DM) come later — the foundation stores everything they need. - Journal income wired into /craft: manual silver-per-craft input feeding
the engine's existing
journal_incometerm, with its own ledger row. Automatic computation needs fame-per-craft data — later. - New tests: argon2 round-trip, garbage-hash safety, throttle window.
v0.8.0 — The week's rhythm — 2026-07-10
- First prediction feature: item pages show day-of-week price seasonality
("sells highest on Sundays, +8% vs the weekly average") from 8 weeks of
daily history across royal markets.
calc.weekly_rhythmis a pure fold with a noise gate (needs ≥5 weekdays covered and a ≥3% best-to-worst spread, else the panel doesn't render) — golden-tested. Copy is explicit that it's a rhythm, not a promise.# ponytail:plain average across cities/weeks; volume-weighting when the feature earns it.
v0.7.0 — The exchange board — 2026-07-10
- Gold exchange tracker (
/gold): the gold price in silver per region — current tick with freshness chip, 24 h change, window high/low, and a 7/30/90/365-day chart (gold_priceshad been collecting since v0.1 with no UI). Brass series line per the chart rules; no new engine math. - Discord login goes live:
PUBLIC_BASE_URLnowhttps://demo.broikiservices.com(staging domain via the VPS Zoraxy); real OAuth credentials applied to theundercut-secretssecret out of band. Requires the matching redirect URI (https://demo.broikiservices.com/auth/callback) in the Discord app.
v0.6.0 — The wire — 2026-07-09
- Live market feed: one NATS consumer per region (AODP
marketorders.deduped) folds every order players see in-game intoprices_currentwithin ~10 seconds — offers tightensell_min, requests raisebuy_max. Push feed, so it spends none of the REST rate budget. This is what makes the Black Market flipper's numbers actionable: BM buy orders now update sub-minute instead of whenever the next sweep lands. - New
NATS_UPSERTpreserves the side an observation doesn't carry (coalesce/CASE) — the guarded REST upsert would have dropped buy-only updates. Documented wobble: one glimpsed mid-priced offer can liftsell_minfor ≤2 min until the next sweep restores the true minimum. order_updateis a pure fold, unit-tested (tests/test_ingest.py);UnitPriceSilverconfirmed to arrive in plain silver against live REST.nats-pypinned; lazy-imported so the test suite doesn't need it. Location-id map lives in constants.yaml ([VERIFY] beyond the three ids observed live). CI now installs undercut/requirements.txt.- Reconnect-forever loop;
/statusgrowsnatsrows per region.
v0.5.0 — The fence's ledger — 2026-07-09
- Black Market flipper (
/blackmarket): the BM's standing buy orders (per quality — the BM treats each quality as its own item, so the join is on (item, quality)) against the cheapest royal-city sell price of the SAME quality. Profit isflip_profitwith tax-only friction — filling an existing buy order pays no setup fee (golden-tested). Filters: tier, quality, min profit, price age. Copy is explicit that Caerleon hauling risk is not in the number and that buy orders are finite.
v0.4.0 — The wagon ledger — 2026-07-09
- Haul planner (
/haul): pick a route and a carry capacity, get every cargo ranked by single-commodity trip profit — units that fit, both ends' prices with freshness, after the same flip friction. Caerleon routes get a full-loot warning; the copy is honest that dumping a wagon moves the price. - Item weight landed end-to-end:
items.weightcolumn (idempotent ALTER in schema.sql), parsed from the dump's@weight, upserted by static_refresh, and self-backfilled at ingest startup when the column is empty (the v0.4.0 migration is one redeploy). calc.haul_tripgolden-tested (unit fit, zero-weight and over-capacity guards). Mount capacity quick-fills are rough numbers marked [VERIFY]; the kg input is the source of truth.
v0.3.0 — The spread scanner — 2026-07-09
- Market flipper (
/flip): best cross-city spread per item — cheapest fresh sell price anywhere vs dearest anywhere else (one SQL pass withDISTINCT ONcheap/dear CTEs), profit after the 9%/13% round-trip friction from constants.yaml (calc.flip_profit, golden-tested), with destination 7-day daily volume fromprice_historyso margin can be weighed against liquidity. Filters: tier, min buy price, min volume, max price age. Black Market excluded (it buys, it doesn't sell). Presets (tool='flip').
v0.2.0 — The refinery opens — 2026-07-09
- Refining calculator (
/refine): the whole material chain (planks / metal bars / cloth / leather / stone blocks, any enchant) weighed in one table per city — each tier buying its raw resource and the refined tier below at local prices — with the full ledger and a "where to refine it" city comparison for the focused tier. Defaults to the family's specialty city. Presets supported (tool='refine'). - RRR model corrected (affects /craft and /craft/top too): the +18% layer
is now the always-on production-city base (any royal city, Caerleon,
Brecilien — previously it only applied on a category match), and the
specialty layer (+15% craft / +40% refine) stacks automatically when the
item's category matches the city — it was previously a "100 spec"
checkbox, but player mastery never touches RRR (it lowers focus cost via
FCE and raises quality). The checkbox is gone; old preset URLs carrying
specialty=1are silently ignored. - Refine categories fixed in constants.yaml: refined materials carry
their raw resource's
crafting_categoryin the dump (wood/ore/fiber/hide/rock), not the product names the table used — so the refining specialty (and, under the old model, any city bonus on refined mats) never matched. Verified against the live dump. Brecilien added as a production city (base layer, no specialties yet). - Golden tests updated to the new layer semantics + a refining golden
(
TestRefineProfit);bonus_layersno longer takes aspecialtyflag.
v0.1.1 — Ingest breathes — 2026-07-09
First contact with production AODP found two launch bugs in the ingest worker; no data had landed yet, so no migration needed.
- One token bucket per region host, for real. Each task built its own
AODataClient, so a region's four tasks ran four independent 50 req/min buckets (~200/min) — past AODP's 180/min + 300/5min caps, hence sustained 429s.main()now constructs one shared client per region and passes it to every task; client burst capacity trimmed 50 → 10 so restart bursts can't stack onto an already-spent 5-minute window. - Streamed flushing instead of whole-pass buffering.
price_sweepandhistory_syncbuffered an entire region pass (history: items × cities × qualities × buckets) before oneexecutemany— OOM-killed the pod at 768Mi and threw away everything already fetched when a late chunk failed. Newiter_prices/iter_historyasync generators yield per request chunk; ingest flushes per chunk (history additionally at a 20k-row ceiling), so memory stays flat and a mid-pass failure keeps the chunks already landed. Failed-run details now say how many rows landed before the error. prices()/history()remain as flattening wrappers; streaming contract covered by newTestStreamingClient(stub HTTP, no network).
v0.1.0 — The counter opens — 2026-07-09
Initial release: the full platform (Phases 0–2 of the launch plan) in one PR.
- Crafting profit calculator (
/craft): item picker, city/premium/ specialty/focus/daily-bonus inputs, full cost ledger (tax, setup fee, materials, returns at the correctb/(1+b)RRR, station fee), and a "where to craft it" city comparison. Focus math includes FCE halving and silver-per-focus-point output. - "What to craft now" (
/craft/top): every recipe with complete prices in a market, ranked by profit or profit-per-focus; recipes with price holes are skipped, never guessed. - Item pages (
/item/{id}): live sell/buy across all cities and qualities plus a 30-day Chart.js history. - The freshness pulse: every price site-wide carries its data age
(green <1 h / amber <24 h / rust older) — the design's signature and the
product's core honesty feature. Design system documented in
DESIGN.md("Counterweight"), light+dark, mobile-first, all vendored assets. - Ingest worker (
ingest.py, own Deployment): AODP REST polling for all three regions (price sweeps ~2 min, gold 30 min, history 6 h/daily, weekly ao-bin-dumps static refresh, daily retention prune) behind a 50 req/min/host token bucket and a freshness-guarded upsert that can never regress newer data./statusrenders the whole pipeline's health. - Accounts: Discord OAuth (identify scope only), watchlists, saved
presets, home region.
users.planexists from day one for the future paid tier. - Infra: namespace
undercut(web + ingest Deployments, one image, LoadBalancer 192.168.178.252), newundercutdatabase on the shared CNPG cluster (declarative Database CR + managedundercut_rwrole, storage 5→20 Gi), nightly pg_dump extended to the new DB, CI build with digest pinning of both deployments, tests + ruff + changelog gates wired into the repo workflows.