PREMIUM SHOP

Server-authoritative store for Wraithgold. Prices and durations are authored in codex/premium_shop.csv — never in code.

What is for sale

SKUNameCostDurationEffect
SHOP-001Incense Burner10 Wraithgold30 daysDoubles the wild respawn rate
SHOP-002Bait10 Wraithgold30 daysHalves the wild respawn interval; stacks with Incense
SHOP-003Auto PVP Attacks4 Wraithgoldnone (one-shot)+10 Auto PVP attacks for today, even above the daily ten; they expire at the daily reset

The authored respawner gives one spawn per 60 seconds while below the cap of ten. Incense halves that interval to 30 seconds. Bait multiplies whatever interval is running by 0.5: 30 seconds alone, 15 seconds with Incense (interval = (60 s − incense 30 s) × bait 0.5, tables.json spawn_budget). A timed boon bought again adds 30 days. Auto PVP Attacks is sold by the same door; the money is spent in the player's object and the ten tickets are granted in the world object on the purchase's own idem, so a retry ends at exactly +10 once. The pool cap is unchanged — the cap is how many creatures can be waiting, not how fast they arrive.

The boon starts when it is paid for and never reaches backwards. Credits accrue lazily — the row holds a balance and the instant it was last touched, and the accrual splits that window at the boon boundary, so time before the purchase settles at 60 seconds and time after it at 30. A purchase therefore settles the row first: it banks what the old rate earned, stamps the instant, and only then hangs the new expiry.

Until 2026-08-07 the purchase wrote the expiry alone and left the instant at the last spawn spent, so the whole already-elapsed window was re-accrued at the boosted rate — buying after three idle minutes handed the player six spawns for three minutes of ordinary waiting. Bounded by the cap of ten, and always in the player's favour, but paid time that was never paid for.

How a purchase works

The client names a SKU and an idempotency key. It never sends a price. The worker resolves the authored row and the player's own object performs the balance check, the debit and the grant inside a single transaction. A repeated key returns the original answer rather than charging twice.

RuleBehaviour
Insufficient balanceRefused with 402. A balance never goes negative on a purchase.
Repeat purchase while activeExtends the expiry by another 30 days. Time already paid for is never burned.
Unauthored SKURefused. Only rows in the CSV are for sale.
Catalogue unreachableRefuses to sell rather than inventing a price.
ExpiryComputed server-side from the grant. A save rollback can neither lose nor extend it.

The wallet ledger

Wraithgold is held in an append-only ledger. The balance is the sum of its entries; there is no authoritative balance stored anywhere else. Every entry carries its provenance, so paid, granted and promotional units stay distinguishable — that distinction cannot be reconstructed once units mix.

Paid Wraithgold survives a reset. A per-account reset (Settings; T61, D 2026-11-02: “per account reset should RESET everything”) clears the save and every ledger row that was earned or granted, and keeps every row that was paid for (paid_*). The full wipe of 2026-10 (T37) was the exception by D’s order: it took everything, entitlements included.

Item grants

Items owed to a player — compensation, purchases, promotions — are minted server-side and stay pending until the client takes delivery and records the grant in its own save. If a save is rolled back, the record rolls back with it and the grant becomes owed again, so a restore can never silently swallow an item. A grant with nowhere to go waits rather than being destroyed.

The incense prompt (P7, shipped 2026-08-03)

When a floor's spawns are exhausted and no boon is running, the floor offers BURN INCENSE TO GET MORE SPAWNS. incPromptOn and onIncensePrompt had been computed and inert in app.js with no markup at all — the block was removed and never rebuilt. Six candidate causes were ruled out across earlier attempts (the scene, the condition, a missing spawnSb, wiped markup, a missing hint-placeholder-val, and placement) without finding one, because the block was not in the document to begin with. It is deliberately a sibling of the drought gate rather than nested inside it: nesting in the drought pill is the placement that was already tried and emitted nothing.

It shows only when the pool is empty and no Incense boon is active, and hides the moment one is burned. Probe: tools/probe_deadprops.py (8/8), which asserts the condition is true before believing the render.

Provenance and real-money Wraithgold (D5, built 2026-10-23, OFF)

Every ledger entry carries exactly one provenance class: earned, spent, refund, granted, promo, legacy, transfer, paid_web, paid_steam, paid_ios, paid_android. The server refuses any other value. Wraithgold is the only currency a store may sell.

No purchasable currency feeds a roll. A grade, soul grade or other roll step priced in Wraithgold is refused before the draw (403 paid_currency_roll), and the commit that would author one fails its gate. Rolls stay paid in farmed currency only, so no random draw is a paid chance.

Steam purchase, when switched on: the server opens the order, Steam shows its overlay, and only after Steam confirms the charge does the server add the pack's Wraithgold, once per order (paid_steam). A Steam refund or chargeback takes the same amount back out, even below zero. Packs and prices come from codex/store_packs.csv. The store is off until D enables it.

The flow, end to end (S7, built 2026-11-03). The premium shop opens with a WRAITHGOLD PACKS grid above the boons: each pack’s name, its Wraithgold, and its price in the catalogue’s currency. On the Steam client a BUY plaque runs the order: the server opens it, Steam shows its purchase overlay in the game, the client relays Steam’s yes or no, and the server confirms the charge and adds the Wraithgold once — the wallet the shop paints is the server’s answer, never the old balance plus the pack. The shop says what happened (waiting for Steam, complete, declined, did not go through) and keeps a “Last purchase” line. On the web build the packs show with “Buy in the Steam client” and no buy action: the store sells only through Steam (the mobile stores come later). While the store is off the grid says it opens soon. An order the overlay never answered is settled by the server within the hour against Steam’s own record: an authorised one is granted, a stale one is voided; nothing is ever charged without a grant or granted without a charge.

No one starts with Wraithgold (T56, D 2026-11-02: “NO ONE STARTS WITH WRAITHGOLD. it is premium currency bought from the shop”). economy_params.csv row WG_Seed is 0 and the server books no seed row on any touch; a new account’s ledger is empty and its wallet reads 0 until a purchase lands (paid_*) or an operator credits it (granted). The alpha grant of 100 (D 2026-07-27, booked as one legacy row seed:<realm> on the first save) ran until 2026-11-02; accounts that already hold that row keep it — nothing is clawed back. The shop’s info line (info.csv row shop) says what Wraithgold is and that it is bought here.


Changelog
2026-11-03 — S7 Steam purchase flow end-to-end (board S7; D5 / T37 “purchasing systems through steam”; D 2026-11-03 “build those too”; dfmc-client lane purchase). On dev, the store still OFF (STORE_ENABLED unset — D’s word). The 2026-11-03 backlog audit found D5 struck DONE with only the server half built. Built: the WRAITHGOLD PACKS grid in the premium shop (from /api/store/catalogue; BUY on the Steam client only, “Buy in the Steam client” on the web, “opens soon” while off), the shell leg (DFMC_SHELL.store.buy: Steam’s authorisation of one order, with a polling fallback when the shell lacks the callback), the wallet painted from the server’s finalize answer only, the status door that settles one order against Steam’s QueryTxn, the hourly sweep on the cron that voids a stale order and finalises an authorised one, the ops listing of open orders, twelve strings in en + pt-BR. Proof tools/probe_purchase.py 5/15 → 16/16. Section “Provenance and real-money Wraithgold” gained the end-to-end paragraph.
2026-11-03 — codex review (dfmc-client lane codexreview-a; D 2026-11-03 “review the codex, clean it up, update whatever needs updating”). WIKI ONLY. The three prices and the two durations in the table bind to codex/premium_shop.csv (csv-bind) instead of being pasted; the wipe paragraph said “resetting a realm” never touches the wallet — it now states the T61 rule (paid rows survive a per-account reset, earned and granted rows do not) and the T37 full wipe. Catalogue (three SKUs, Enabled=1), the 402 / idempotency contract, D5 provenance and STORE_ENABLED unset re-checked against dev 0.93.
2026-11-02 — T56 (D, Tower rulings card T56 “the shop never says where Wraithgold comes from”: “NO ONE STARTS WITH WRAITHGOLD. it is premium currency bought from the shop”; dfmc-client lane wgseed). On dev. The 100 Wraithgold account-creation grant is over: economy_params.csv WG_Seed 100 → 0, the worker’s alpha-grant flag is off and wgEnsureSeed books nothing when the grant is 0, so a fresh account’s first save no longer seeds a row and its wallet reads 0 on every touch. The client no longer raises a new hero’s wallet to the seed at creation or on save adoption (the server’s ledger took it back on the next save anyway), and the Buy plate’s copy no longer promises a grant or a Steam date. The Premium Shop info line (info.csv shop) now opens with “Wraithgold is the premium currency. It is bought here in the shop; no one starts with any.” Accounts holding the old seed row keep it. The hub’s slot-pricing note no longer says every account is credited 100. Gate tools/probe_t56_wgseed.py (dfmc-client, worker rig) 5/19 → 19/19.
2026-10-23 — Boon tile touch hold (dfmc-client lane probe-triage-4; board 9.1-9.3). On dev. A finger held on the Incense or Bait floor tile shows its plate; lifting the finger folds it and opens nothing. The plate had come back and stuck open: after the release the browser fires the touch’s compat mouse-enter, which re-opened it, and the click that ends the touch was eaten by the hold guard. That click now folds the plate. Gate tools/probe_boontiles.py 19/19.
2026-10-23 — D5 (T37: “purchasing systems through steam and mobile stores … the priority is steam”; dfmc-client lane d5-purchasing). On dev, OFF (STORE_ENABLED unset). The ledger’s provenance is one fixed vocabulary (paid_web / paid_steam / paid_ios / paid_android / granted / promo, plus earned, spent, refund, legacy, transfer); older free-text tags were migrated once and kept as detail. A roll priced in a purchasable currency is refused (D11). Steam MicroTxn order, finalize and refund revoke built against a mock; the catalogue is the new codex/store_packs.csv (placeholder prices). Store rules: dfmc-client docs/store-rules-2026-10.md.
2026-10-01 — SHOP-FX (D, Bug Run 0.89 board Q7: the premium icons “make them breathe, give them outline auras”; the Shadow Trader “should also breathe, his eyes should shine brighter every few seconds, the coin in his hand should have wraithfire gently burning around it”). LIVE on release since 2026-10-23 (0.89). PRESENTATION ONLY. Each catalogue row’s icon sits over a baked outline aura (img/shop/fx/aura_<incense|bait|apvp>.webp, the icon’s own silhouette dilated, blurred and tinted, body knocked out); the icon breathes (scale 1 → 1.035) and the aura with it (opacity .4 → 1, 3.6 s), each row 1.2 s behind the one above. The Shadow Trader painting breathes (scale 1.006 × 1.013 from his feet, 5.2 s), his eyes flare with a baked cyan glint every 4.6 s, and a 16-frame wraithfire strip burns round the coin in his raised hand (steps, 1.6 s loop). The #1052 portal technique: every glow, blur and shadow is pre-baked (tools/make_shop_fx_art.py, dfmc-client) and the keyframes move only transform and opacity; the trader’s old CSS drop-shadow filter is a baked static shadow under the breathing body. Reduced-motion stops all of it. Also: the close cross sits 40 stage px further in, clear of the shell’s fullscreen button at 1920 wide. Rows, prices and the purchase flow are unchanged. Gate tools/probe_shopfx.py (dfmc-client) 5/24 → 24/24; paints per frame with the trader open unchanged (0.10 → 0.07 desktop, 0.07 → 0.08 phone: the stage’s own, none from the effects).
2026-10-01 — SHOP-UI (D, Bug Run 0.89 board Q7, after the #1050 rework on dev: “Shop frame is OK as is. Shopkeep is fine.”). LIVE on release since 2026-10-23 (0.89). The MENU plate is back top left while the shop is open, as on the arena, breeding and rankings screens (the shop sits at z 99990, over the menu hud’s 110; the hud lifts over the shop while it is open), and a menu pick now closes the shop. The PREMIUM / SHADOW TRADER tabs are the arena’s hammered-bronze plate (img/pvp/queue.webp), the open tab with its runes lit (queue_press), where they were a flat gold gradient and a dark pill. The buy plaque is 104 stage px tall (was 72) with the price at 42px (was 24) and the Wraithgold coin at 56 stage px (was 30). Rows, prices, wallet math and the purchase flow are unchanged. Gate tools/probe_shopui.py (dfmc-client) 6/24 → 24/24.
2026-10-01 — #1050 LAYOUT half (D: “Occupy the entire screen real-estate it has instead of being a window in the middle … Full rework of that UI.”). LIVE on release since 2026-10-23 (0.89). The shop is a SCREEN on the PvP Arena’s shape, not a window: 100% of the stage, the painted floor (shop_floor) full-bleed at 40%, the title on the banner past the 65px header line, the Shadow Trader (trader) standing on the left, the catalogue in the 9-slice frame on the right, the balance the shop’s own coin at 64 stage px (was 26 off the header icon), every buy control the bronze buy plaque. Rows, prices, wallet math, the purchase flow and #722’s hold guard are unchanged — the rows only gained the classes that dress them. The bottom CLOSE bar is gone; the header’s cross and a tap on the backdrop close it. #958’s effect stands: the frame column is the stage’s height less the header, overflow:auto kept as the safety valve. Verified at 1920×1080 and the report’s 1067×487 (the stage is one 1920×1080 box zoomed by fitScale). Gate tools/probe_01050.py 8/22 → 22/22.
2026-09-30 — #1050 ART half (D: “the premium shop needs a UI redesign similar to the pvp UI … Thematic assets. The shadow trader needs a character generated. Wraithsilver icon is too small.”). On dev. The shop had no art of its own — a gradient window, and the Wraithgold balance drawn off the 64×64 header icon at 26px. A painted ornament set now ships under play/img/shop/, made by tools/make_shop_art.py on the arena’s #1017 recipe (gpt-image-2.5-flare, one STYLE block byte-identical across the set, the accent the wraith-teal of the coin instead of the arena’s ember): shop_floor 1920×1016 (the Trader’s vault under the tower, full-bleed), trader 640×900 and trader_bust 320×320 (the Shadow Trader — a hooded merchant-wraith, coin in one hand, scale in the other; the bust is cropped from the same painting), wraithgold 256×256 (the skull-faced coin in wraith-fire; the 64×64 CURSET icon is untouched and the currency keeps its name — “Wraithsilver” is Wraithgold), frame / frame_hover 176×176 9-slice (slice 44), buy / buy_press 480×120 plaque, banner 640×184. Raws under local-only/art_raw/shop. The layout (full-screen instead of the centred window) is the other half of #1050, a separate lane; markup and app.js are untouched here. Gate tools/probe_01050art.py 0/18 → 27/27.
2026-09-30 — review lane Q (F115, F116, F117, F104 shop half, F107). On dev. A boon purchase awaits its entitlement grant now. If the grant is refused (the 40/hour account guard or the game-wide guard), the Wraithgold is refunded under the same idem and the answer is a refusal (429). A retry gets the same refusal. The spawn budget is moved only once the entitlement exists. Before, the debit stood, no entitlement was written, and the budget still ran at the boon rate while the floor clock said none. The Auto PVP ticket SKU answers its balance as wg, like every other SKU, so the header no longer reads undefined. A paid ticket purchase whose world grant did not land (grantPending) is re-sent under its own idem, up to 5 times, 3 s apart. The re-send can never charge twice. NOTE (F107): for Incense and Bait, Effect_Value in premium_shop.csv is display only. The magnitudes live in tools/gen_tables.py (incense_sub_ms 30000, bait_mult 0.5), so editing the CSV value changes nothing. Incense’s 2 equals −30 s only at the 60 s base. Gate tools/probe_laneq.mjs 0/15 → 15/15.
2026-09-30 — review fix F6-033 (client). On dev. The catalogue answer’s status is read now: a 429 (the shop shares the ‘other’ rate class) or a 5xx that answered JSON used to paint an empty shop with no message. Only a 2xx with an items list is a catalogue; anything else shows an error (a 429: “The shop is busy — close it and try again in a moment.”). Gate tools/probe_f6_033.py 1/3 → 3/3.
2026-09-30 — review drift fixes: F111, F120. WIKI ONLY. The sale table listed only Incense; it now carries all three enabled SKUs of premium_shop.csv — Bait (10 Wraithgold, 30 days, interval ×0.5, 15 s with Incense) and Auto PVP Attacks (4 Wraithgold, +10 tickets until the daily reset). DESCRIPTIVE ONLY.
2026-09-17 — The shop panel is bigger and neither tab scrolls (#958, D: “shadow trader window should not have a scroll, make the shop window bigger and resize items inside of it so it never needs to be scrolled”). ON DEV. min(1320px,94%) at 94% height, padding 24px 36px — see “Panel size and offer layout” below. The offer list's 12px gap is deliberately left alone: it is the token a 2026-08-05 registry entry verifies itself at, and that entry pins list-versus-grid, which this report does not touch. The overflow itself was the Shadow Trader tab's, and the rest of the 236 stage px came off its rows (Shadow Trader). The catalogue's own offer sizes are unchanged — the 2026-08-05 phone sizing below still holds row for row. Gate tools/probe_958.py 5/7 red → 7/7.
2026-09-06 — Premium buff icons (board 9.1 / 9.2 / 9.3, D). LIVE on release since 2026-09-08 (0.71). The INCENSE / BAIT text pills are two icon tiles now (see the section below).
2026-08-05 — Offers are a LIST, not a grid, and sized for the phone: name 30px, description 21px, buy control 24px with an 18×30 pad (D: “the items are too small… mobile friendly font size”). Measured, not estimated — the old name drew at 5.03 CSS px at fitScale 0.36. A tap on the backdrop now closes the panel (D: “clicking outside of the window is not closing it”); the backdrop had no handler at all. LIVE on release since 2026-08-05 (0.32).
2026-08-03 — The incense prompt renders at last (P7, LIVE on release). The block had never been rebuilt after its removal, so every investigation into why it did not appear was searching a document that did not contain it.
2026-08-07 — The boon no longer reaches backwards: a purchase settles the spawn row before hanging the new expiry. The buy answer carries the expiry and budget, and the pill expires on its own clock.
2026-08-02 — Page created. Premium Shop, Incense Burner, the Wraithgold ledger and the item grant lane.

Incense — the player can see it burning (the pill described here became an icon tile on 2026-09-06 — next section)

Incense doubles wild-spawn refill for 30 days. The expiry has always been server-side (spawn_budget.boon_until) and /api/floor/enter has always returned it to the client as inc, which the client stored as spawnInc — and nothing ever rendered it, so a player who had paid for Incense had no way to tell how much was left. The floor screen now shows a pill reading INCENSE · 4d 23h, under the MENU button. Coarse by design: the boon runs 30 days, so days and hours are the useful unit.

The pill is a floor-scene element: incLeftOn requires the floor phase and that no panel is open, because panels are overlays and the phase stays floor underneath every one of them.

Two delivery holes closed on 2026-08-07. The buy answer now returns the expiry and the spawn budget alongside the balance, so Incense bought while standing on a floor takes effect on screen immediately instead of waiting for the next /api/floor/enter — previously the shop said it was active and the floor showed nothing. And incPump() arms a single timeout on the boon's own expiry, so the pill and the upsell prompt stop reading a boon that has ended on an idle floor; the one-second drought tick only runs while the pool is starved and could not serve this.

Placement was measured, not chosen. The obvious spot — centred under the floor title — is covered by the floor header bar, which is z-index 80 with an opaque background. A pill placed there is present in the DOM with every computed style reading visible, and invisible on screen. The centre lower down belongs to the incense prompt (top:12%), which can be showing at the same time. Under MENU is clear, and z-index 105 clears the bar. Verify this class of change with document.elementFromPoint() at the element's own centre, never by reading its text.

The boons as icon tiles (board 9.1 / 9.2 / 9.3, 2026-09-06). LIVE on release since 2026-09-08 (0.71).

The two text pills above are gone. In their place, at the same spot under MENU, sit two icon tiles — the Incense Burner and the Bait, generated in the currency-icon style and keyed locally (masters in _parking/ui/boon-icons/). They are always on the floor under the pills’ own rule (floor scene, no window over it): an active boon shows its icon with the countdown under it (12d 4h, the same formatter as before); an expired or never-bought boon shows the icon dimmed with NOT APPLIED. Hovering a tile (or pressing and holding it on touch) opens a plate to its right — the name, what the boon does, the time left, and where to buy; a click or tap opens the Premium Shop. The plate carries no price and no day count: those are the catalogue’s numbers and the shop prints them.

In the shop, a timed offer says what the account holds — ACTIVE · 12d 4h left · buying again adds 30 days or Not active · 30 days per purchase — from the same expiry the floor reads, and its button reads +30 DAYS · 10 beside the Wraithgold mark while the boon runs (the WG text was retired 2026-09-08, #791 — ON DEV). Buying an active boon extends it: the buy door adds the days to the time already paid for (max(now, current expiry) + days) rather than replacing it; the shop now says so. The Auto PVP ticket bundle has no timer and prints nothing extra.

One producer (boonTileProps) feeds both tiles; the four pill properties were retired with their bindings. A boon that expires while the player stands on the floor flips to NOT APPLIED by itself, through the same single timeout the pill used. Gate tools/probe_boontiles.py 15/15, red-first 1/15.

The shop rows carry their icons (#791, 2026-09-08). LIVE on release since 2026-09-08 (0.72).

Every row in the store now paints the same icon its floor tile draws, 96 stage px at the head of the row: Incense Burner, Bait, and a third generated for Auto PVP Attacks — a parchment scroll with crossed swords, in the same style, prompt committed before the call and masters parked in _parking/ui/boon-icons/. The file is named in one place (BOON_ICON); the floor tile reads it by key and the shop resolves it from the row’s own Effect_Kind, so the two surfaces cannot drift apart.

The shop icon is full colour whether or not the boon is running. The floor tile greys its icon to say NOT APPLIED; a storefront does not, and the ACTIVE / Not active line under the name already says it. A row whose effect has no art yet keeps an empty slot of the same width, so the names stay in one column.

The price carries its currency icon ahead of the number, from the same set the bag, the equipment header, the reward window and the trader draw from. The old hardcoded WG printed Wraithgold’s short name on every row and would have been wrong the day an offer is authored in another currency. Gate tools/probe_shopicons.py 11/11, red-first 5/11.

Panel size and offer layout

Since #1050 (2026-10-01) the shop is a full-stage screen on the PvP Arena’s shape, not a window: a 65px header (the title on the banner, the 64px Wraithgold coin and balance, a cross), then the Shadow Trader standing in a 560 stage px column on the left and the catalogue in the painted 9-slice frame on the right, the frame’s inner column the stage’s remaining height with overflow:auto as the safety valve. The note is pinned to the bottom of the frame so a short catalogue does not leave it floating. Before that it was a min(1320px,94%) × 94% window with 24px 36px of padding (#958, 2026-09-17 — and min(1180px,94%) at 86% before the Shadow Trader tab turned out not to fit), and a fixed 520px column before 2026-08-05.

The offers are a LIST, one row per sku (display:flex; flex-direction:column; gap:12px). They were briefly a responsive grid (auto-fill, minmax(360px,1fr)); that is reversed as of 2026-08-05. The grid capped every card at roughly 360 stage px, and the reason that matters is the stage: the whole UI is a fixed 1920×1080 box scaled by fitScale = min(innerWidth/1920, innerHeight/1080), which on a phone is about 0.36. Authored sizes are therefore stage units, and what a player sees is the size × fitScale. Measured on a 690×674 viewport, the old 14px offer name drew at 5.03 CSS px and the buy control stood 12 CSS px tall — below any usable tap target.

ElementStage sizeDrawn at fitScale 0.36
Offer name30px10.8 CSS px
Offer description21px7.5 CSS px
Buy control24px, padding 18px 30px52.3 × 25.9 CSS px
Buy plaque (since SHOP-UI, 2026-10-01)104 tall, ≥330 wide; price 42px, coin 5637 CSS px tall; price 15 CSS px

RULE: any size authored for this panel is a STAGE size. Divide nothing; multiply by fitScale to find out what a phone actually shows, and check the result rather than the source.

Dismissal

The panel closes three ways: the CLOSE control, and — since 2026-08-05 — a tap anywhere on the backdrop. The backdrop carries onCloseShop and the window itself carries onStopProp, so a tap inside never bubbles out and closes it. This is the same pattern the shop backdrop simply carried no handler at all, so a tap outside did nothing. Verified with document.elementFromPoint() at a point derived from the backdrop's rect minus the window's — note that on a phone the stage is letterboxed, so the top-left of the SCREEN is page background and is not outside the window. Probe: tools/probe_shoplist.py (BEFORE 8/8, AFTER 9/9).