Crate: configurable animation model + desktop picker - #65
Merged
Conversation
Closes #16, closes #17. shared/crate.ts defines five variants (reel, spin, flip, burst, instant) with their durations. reel is first and is the default - it is what every existing store already plays, so an operator who never opens this setting sees no change. normalizeCrateAnimation coerces anything - an old config, a hand-edited json, a renderer sending nonsense - to a real variant and never throws. A bad animation value must not stop a player receiving what they paid for. The setting is per-server store config, not a global app preference: the people who see it are the players buying from that server's panel, not the operator at the desktop. It is carried on StorePublic too, because the panel plays it from the buyer-facing payload. Desktop picker sits in the Store tab with a plain-language description of each variant (including how long it takes). Panel rendering of the new variants is the next slice.
…y stored The picker updated state optimistically and awaited with no catch: a failed IPC left the UI claiming a setting the server never took, and rejected the promise into nothing. It now reverts on failure and adopts the value the main side returns, which is the coerced one.
Owner
Author
|
This was referenced Jul 27, 2026
CaYatur
added a commit
that referenced
this pull request
Jul 28, 2026
#87) * Crates: an animation you can pick per crate, preview, and see the odds 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. * Review fixes: an unweighted crate advertised odds it did not honour 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes #16, closes #17. The crate opening animation becomes a real, selectable setting.
shared/crate.tsdefines five variants with their durations:reelspinflipburstinstantThe desktop picker lives in the Store tab with a plain-language description of each, including how long it takes.
Decisions
reelis first and is the default. It is exactly what every existing store plays today, so an operator who never opens this setting sees no behaviour change at all.Per-server, not a global app preference. The people who see this animation are the players buying from that server's web panel — not the operator sitting at the desktop app. It therefore lives in that server's store config, and is carried on
StorePublicas well, because the panel plays it from the buyer-facing payload rather than the admin one.normalizeCrateAnimationnever throws. An old config, a hand-editedstore.json, or a renderer sending nonsense all coerce to the default. A bad animation string must never be able to stop a player receiving something they already paid for — the purchase has already been deducted by the time the animation matters.instantexists on purpose. A 4-second animation on every purchase is a real annoyance on a busy store and on slow devices, and there was previously no way to turn it off.Verification
MSMS_SMOKE_WEBexit 0;MSMS_SMOKEexit 0.undefined,null,'','nope',42,{},[]) all fall back to the default;instanthas zero wait; an unknown id times like the default; the default is the first picker option; a fresh store defaults correctly; set →getStoreConfig→publicStoreall carry it; andsetCrateAnimation('not-real')is coerced, not stored.storeblock parity: 64/64.Disclosed gaps
fliptoday storesflipand changes nothing visible.store.json(nocrateAnimationkey) is handled by normalising on read, verified by construction rather than against a real pre-existing file.