Crates: an animation you can pick per crate, preview, and see the odds - #87
Conversation
Closes #74, #75 and #79. The report was that creating a crate from the web panel offers no animation choice and does not work. Half of that is confirmed, and it is the more interesting half. Crate creation over HTTP round-trips correctly - proven by driving the panel's own script in a stub DOM and watching pmSave post a well-formed crate, on top of the existing smoke that reads one back with its reward pool intact. What does not exist is any animation control in the panel at all. The route POST .../store/admin/crate-animation has been there since #17 and nothing in panelHtml.ts ever called it; the setting was reachable only from the desktop. Three things follow from that. **The picker exists now**, in both admin UIs, per crate. Product gains an optional crateAnimation and the store-level setting becomes the fallback, so every crate created before today has no value of its own and keeps playing exactly what it played before. resolveCrateAnimation() in shared/crate.ts is the single rule: the crate's own choice wins, absent inherits, and anything unrecognised degrades to the default rather than leaving a paying player in front of a crate that never opens. The resolved animation travels on the purchase result. The client only ever receives BuyResult.reward, never the product behind it, so a per-crate setting has no route to the code that plays it unless it rides along. **The public website played none of them.** openCrate() there was a hardcoded 5.2 second reel that ignored the setting completely - so an operator could choose among five animations and the place most buyers actually look showed a sixth. The panel and the site now paste in the same shared/crateUi.ts, one implementation instead of two that had already drifted this far apart. The desktop preview shares that file's CSS and re-implements only the motion sequencing in React, because a srcdoc iframe needs inline script and the packaged renderer runs under script-src 'self'. **Preview.** Choosing an animation used to mean saving it, buying something, and watching what happened. Both admin UIs now play it on demand with the crate's real reward names and nothing purchased. **Contents before buying (#79).** ProductPublic.rewardNames - names only, and rendered by nothing - becomes rewards[{name, icon, chancePct}], shown on the card in the panel store tab and on the storefront. Percentages rather than raw weights, because a weight only means something next to every other weight in the pool. toPublic() still lists its fields explicitly: a reward's commands are console commands, and publishing them tells every visitor exactly what to have a compromised account run. Also fixed while in publicSiteHtml.ts: it had no escAttr at all, and wrote product icons into src="..." with esc(), which escapes < > & but not quotes. Any store-scoped web user could close the attribute and add an onerror that runs for every visitor. Same bug class as the crate reel one fixed in #65, different file. Verified with MSMS_SMOKE_WEB and MSMS_SMOKE. New coverage: both served pages' inline scripts are parsed and run in a stub DOM - previously nothing checked that a mis-escaped quote had not turned the panel into a blank screen, and this harness caught two bugs while writing it, including a picker that rendered two selected options and so displayed Reel on a store whose default was Flip. Also asserted: resolution precedence, odds normalised from weights (1:3 to 25/75), an all-zero pool not dividing by zero, the animation reaching BuyResult, no command text anywhere in the public payload, and a hostile reward icon failing to escape its attribute.
rollCrate treated an all-zero pool as `total || 1`, so the loop never found a reward and fell through to `rewards[rewards.length - 1]`. Measured over 10000 rolls of a four-reward pool with every weight at zero: D won 10000 times. Meanwhile publicRewards, added in this PR, publishes that same pool as 25% each. Publishing odds the roll does not honour is worse than publishing none - and an operator who has not filled the weights in yet does not expect one reward to be the only one that ever drops. The degenerate case is now an even split for real, matching what is published. A smoke assertion draws 400 times from an unweighted pool and requires all four outcomes; a fixed pick shows up as a single name, and 400 draws missing one of four is roughly 1 in 10^49. Two smaller things from the same pass: `dstore` was left assigned and never read - crateVariant() used to source the animation from it, and the animation now rides on the reward. The panel lost its celebration emoji when openCrate moved to the shared module, because the prefix is now a caller argument; and the public site localised the crate modal's title and OK button only from fillAuthTexts(), which a visitor with a stored token never triggers, so they met an English crate. Both set at open time now.
Self reviewOne finding that matters, two small ones. All fixed in 5470a8d. 1. An unweighted crate advertises odds it does not honourThis PR added const total = rewards.reduce((s, r) => s + Math.max(0, r.weight), 0) || 1
let roll = Math.random() * total
for (const r of rewards) { roll -= Math.max(0, r.weight); if (roll <= 0) return r }
return rewards[rewards.length - 1]With every weight at zero the loop subtracts nothing, So the crate card would tell a buyer they have a one-in-four shot at A while A The degenerate case is now a real even split, which also happens to be what an 2. Dead global left behind
3. Two regressions from moving
|
…t that did not test (#89) Three loose ends from #87/#88, found reading back over what was merged. **common.clear existed in neither locale.** ImageField uses it for the clear button's tooltip, so that button's title read the literal string "common.clear". TypeScript could not catch it: tr is typed as `typeof en` and the key was missing from both, so the shapes still matched. **#80 said the SVG badge replaces the gift emoji "in the storefront and in both admin product lists", and the web panel's Manage list still had the emoji.** The storefront and the desktop list were done; that one was not. It uses the same CRATE_ICON_SVG now. **The assertion written to catch the first of those did not catch it.** It scanned document.body.innerText for anything shaped like a translation key, which sounds right and misses every key used in a title, placeholder or aria-label - `common.clear` among them, since innerText contains no attributes. Worse, the clear button only renders once an image field has a value, so an untouched editor never draws it at all. Fixed by scanning attributes too and by filling the icon field first, through the native value setter so React notices. Then proved: with the key removed the gate now exits 1 with "untranslated keys rendered in the store view: common.clear", and with it restored it passes. A test that cannot fail is worse than no test, because it reports coverage that is not there - the same shape as the single-instance-lock false pass fixed in the previous PR. The tab sweep already mounts every view, so nothing was crashing; what was missing was any assertion about what the Store view actually renders. It now also checks the crate animation picker and image fields are present, so #75 and #76 have renderer-level coverage rather than only shared-logic coverage. All twelve smoke gates pass.
Closes #74. Closes #75. Closes #79.
First, what "crate creation from the web doesn't work" actually is
Half the report is confirmed and half is not, and the half that isn't matters
because it changes what needed fixing.
Crate creation over HTTP works. I drove the panel's own script in a stub DOM
and watched
pmSave()post a well-formed crate:and the existing
MSMS_SMOKE_WEBalready read one back with its reward poolintact. The editor renders, the rewards render, the request is right.
What does not exist is any animation control in the panel at all. The route
POST .../store/admin/crate-animationhas been there since #17, and nothing inpanelHtml.tsever called it. The Manage tab had Currency, Player credits,Balance ledger and Products — and that was the whole list. The setting was
reachable only from the desktop app.
What changed
The picker exists, and it is per crate (#74, #75)
Product.crateAnimationis new and optional; the store-level setting becomesthe fallback. Every crate created before today has no value of its own, so they
all keep playing exactly what they played before and an operator who never
touches this sees no change.
resolveCrateAnimation(product, storeDefault)inshared/crate.tsis the onerule everything uses — crate's own choice wins, absent inherits, anything
unrecognised degrades to the default rather than leaving a player who has
already paid in front of a crate that never opens.
The resolved animation travels on the purchase result. The client only ever
receives
BuyResult.reward, never the product behind it, so a per-crate settinghas no route to the code that plays it unless it rides along.
The public website was playing none of them (#75)
openCrate()inpublicSiteHtml.tswas a hardcoded 5.2-second reel thatignored the setting entirely. An operator could pick one of five animations and
the place most buyers actually look showed a sixth.
The panel and the website now paste in the same
shared/crateUi.ts— oneimplementation instead of two that had already drifted this far apart. The
desktop preview shares that file's CSS and re-implements only the ~40 lines
of motion sequencing in React, because a
srcdociframe needs inline script andthe packaged renderer runs under
script-src 'self'.Preview (#75)
Choosing an animation used to mean saving it, buying something, and watching
what happened. Both admin UIs now play it on demand, with the crate's real
reward names, nothing purchased and no reward given.
Contents before buying (#79)
ProductPublic.rewardNames— names only, and rendered by nothing — becomesrewards: [{ name, icon?, chancePct }], shown on the card in the panel's storetab and on the public storefront, with long-odds entries highlighted.
Percentages rather than raw weights: a weight only means something next to every
other weight in the pool, and nobody should have to normalise a column of
numbers in their head to find out how likely something is.
toPublic()still lists its fields explicitly rather than spreading. It is theonly boundary between the store config and anything a player can read, and a
reward's
commandsare console commands — publishing them tells everyvisitor exactly what to get a compromised account to run. A new field on
Producthas to be opted in, not leaked by default.Security fix found on the way
publicSiteHtml.tshad noescAttrat all, and wrote product icons intosrc="..."withesc()— which escapes<,>and&but leaves quotesalone. Any
store-scoped web user could close the attribute and add anonerrorthat runs for every visitor to the public site. Same bug class as thecrate-reel one fixed in #65, different file. Added
escAttrand used it for theicon and the two Discord
hrefs.Verification
MSMS_SMOKE_WEBandMSMS_SMOKE, both exit 0.New: the served pages' inline scripts are now actually executed by the test
suite. They are hand-written vanilla JS inside a TypeScript template literal,
so every backslash resolves twice and nothing type-checks them — a mis-escaped
quote produces a page that throws on load and renders blank, and until now the
only thing that caught that was opening a browser. Both pages are parsed and run
in a stub DOM, and the crate editor is clicked through.
That harness caught two bugs while I was writing it:
\'that made the whole panel script fail to parseselectedoptions — the browser takesthe last, so a crate set to inherit displayed "Reel" even on a store whose
default was "Flip"
Also asserted: resolution precedence (own beats default, absent inherits,
garbage degrades), odds normalised from weights (1:3 → 25/75), an all-zero pool
not dividing by zero, the animation reaching
BuyResult, no command textanywhere in the serialised public payload, a hostile reward icon failing to
escape its attribute, an inheriting crate not pinning an animation on save, and
a chosen one travelling.
Not in this PR
The gift emoji, crate/item sections, image upload and storefront layout are
#76–#82 and land next.