Skip to content

Crate: configurable animation model + desktop picker - #65

Merged
CaYatur merged 2 commits into
mainfrom
feat/crate-animation-model
Jul 27, 2026
Merged

Crate: configurable animation model + desktop picker#65
CaYatur merged 2 commits into
mainfrom
feat/crate-animation-model

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What

Closes #16, closes #17. The crate opening animation becomes a real, selectable setting.

shared/crate.ts defines five variants with their durations:

Variant Duration
reel classic sideways strip that slows onto the reward ~4 s
spin vertical slot-machine column ~3.2 s
flip face-down cards flip until the reward shows ~2.8 s
burst short shuffle, then the reward pops in ~1.6 s
instant no animation, immediate reveal 0

The desktop picker lives in the Store tab with a plain-language description of each, including how long it takes.

Decisions

reel is 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 StorePublic as well, because the panel plays it from the buyer-facing payload rather than the admin one.

normalizeCrateAnimation never throws. An old config, a hand-edited store.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.

instant exists 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

  • typecheck + build clean; MSMS_SMOKE_WEB exit 0; MSMS_SMOKE exit 0.
  • New smoke units: a valid id round-trips; seven bad inputs (undefined, null, '', 'nope', 42, {}, []) all fall back to the default; instant has zero wait; an unknown id times like the default; the default is the first picker option; a fresh store defaults correctly; set → getStoreConfigpublicStore all carry it; and setCrateAnimation('not-real') is coerced, not stored.
  • en/tr store block parity: 64/64.

Disclosed gaps

  • Nothing renders these variants yet — this slice is the model, the persistence and the picker. The panel still plays the existing reel regardless of the selection; Crate: multiple animation variants in the panel reel #18 is the next PR. Choosing flip today stores flip and changes nothing visible.
  • The desktop picker was not rendered in a browser; the smoke covers the pure coercion and the store round-trip, plus that the app mounts.
  • Migration of an old store.json (no crateAnimation key) is handled by normalising on read, verified by construction rather than against a real pre-existing file.

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.
Copilot AI review requested due to automatic review settings July 27, 2026 21:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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.
@CaYatur

CaYatur commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

/code-review — self review

One real finding, fixed on-branch.

The picker could claim a setting the server never accepted

setCrateAnim(animation)
await window.msms.setCrateAnimation(id, animation)
toast('success', 'store.saved')

Two problems in three lines:

  • No catch. Every other mutation in this view (changeBalance, saveProduct) wraps the IPC and toasts the error. This one did not, so a failure rejected into a void-ed promise — no error toast, no revert, and the select still showing the new value. The operator walks away believing crates now play flip.
  • It trusted the request, not the response. setCrateAnimation deliberately coerces an unrecognised value to the default rather than storing it. Since the UI never read the return value, a coerced save would have left the picker displaying something different from what is actually stored — the exact divergence the coercion exists to prevent.

Now it captures the previous value, reverts on failure with an error toast, and adopts the animation the main side returns.

Checked and fine

  • The picker saves on change with no Save button, unlike the currency field next to it. Deliberate: a select that applies immediately is the conventional affordance, and the toast confirms it.
  • normalizeCrateAnimation runs on read in getStore, so a legacy store.json is normalised in memory even before anything writes.
  • StorePublic carries the animation, so the buyer's panel gets it without needing store-scope access to the admin config.
  • Adding a required field to StorePublic/StoreConfig did not break any other construction site — typecheck is clean.

Accepted, not fixed

The smoke leaves a synthetic crate-smoke-server in dev-root's store.json; there is no delete-store API and I would rather disclose that than invent one for a test.

Re-verified: typecheck + build clean, MSMS_SMOKE exit 0.

@CaYatur
CaYatur merged commit 887eb02 into main Jul 27, 2026
@CaYatur
CaYatur deleted the feat/crate-animation-model branch July 27, 2026 21:03
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crate: animation settings UI (desktop) Crate: configurable animation options (model + defaults)

2 participants