Skip to content

Crates: an animation you can pick per crate, preview, and see the odds - #87

Merged
CaYatur merged 2 commits into
mainfrom
feat/crates
Jul 28, 2026
Merged

Crates: an animation you can pick per crate, preview, and see the odds#87
CaYatur merged 2 commits into
mainfrom
feat/crates

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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:

POST /api/servers/srv1/store/admin/product
{"id":"","type":"crate","name":"Test Crate","price":100,"commands":[],
 "rewards":[{"name":"Common","weight":70,"icon":"","commands":[]}]}

and the existing MSMS_SMOKE_WEB already read one back with its reward pool
intact. 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-animation has been there since #17, and nothing in
panelHtml.ts ever 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.crateAnimation is new and optional; the store-level setting becomes
the 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) in shared/crate.ts is the one
rule 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 setting
has no route to the code that plays it unless it rides along.

The public website was playing none of them (#75)

openCrate() in publicSiteHtml.ts was a hardcoded 5.2-second reel that
ignored 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 — 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 ~40 lines
of motion sequencing in React, because a srcdoc iframe needs inline script and
the 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 — becomes
rewards: [{ name, icon?, chancePct }], shown on the card in the panel's store
tab 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 the
only boundary between the store config and anything a player can read, and a
reward's commands are console commands — publishing them tells every
visitor exactly what to get a compromised account to run. A new field on
Product has to be opted in, not leaked by default.

Security fix found on the way

publicSiteHtml.ts had no escAttr at all, and wrote product icons into
src="..." with esc() — which escapes <, > and & but leaves quotes
alone. Any store-scoped web user could close the attribute and add an
onerror that runs for every visitor to the public site. Same bug class as the
crate-reel one fixed in #65, different file. Added escAttr and used it for the
icon and the two Discord hrefs.

Verification

MSMS_SMOKE_WEB and MSMS_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:

  • a mis-escaped \' that made the whole panel script fail to parse
  • the animation picker rendering two selected options — the browser takes
    the 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 text
anywhere 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.

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.
Copilot AI review requested due to automatic review settings July 28, 2026 02:25
@CaYatur CaYatur added bug Something isn't working enhancement New feature or request area:crate Crates labels Jul 28, 2026

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.

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

CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Self review

One finding that matters, two small ones. All fixed in 5470a8d.

1. An unweighted crate advertises odds it does not honour

This PR added publicRewards(), which publishes an all-zero weight pool as an
even split. rollCrate() does not agree with that:

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, roll <= 0 is never true
(short of Math.random() returning exactly 0), and it falls through to the last
reward. Measured:

all-zero pool, 10000 rolls: { D: 10000 }
published odds would say:   A 25%, B 25%, C 25%, D 25%

So the crate card would tell a buyer they have a one-in-four shot at A while A
is unreachable. That bug pre-dates this PR, but it was invisible until this PR
started printing odds next to it — and shipping a storefront that states odds
the roll ignores is worse than shipping one that states none.

The degenerate case is now a real even split, which also happens to be what an
operator who has not filled the weights in yet would expect. A smoke assertion
draws 400 times from an unweighted pool and requires all four outcomes to
appear; a fixed pick shows as a single name, and 400 draws from four outcomes
missing one is roughly 1 in 10⁴⁹.

2. Dead global left behind

var dstore = {} was assigned on every store load and read by nothing.
crateVariant() used to source the animation from it; the animation now rides
on the reward. Removed.

3. Two regressions from moving openCrate into the shared module

  • The panel lost its 🎉 on the win line, because the prefix became a caller
    argument and the panel's buy() did not pass one.
  • The public site localised the crate modal's title and OK button only from
    fillAuthTexts(), which a returning visitor with a stored token never
    triggers — so they met an English crate on a Turkish site. (Before this PR
    those elements were simply blank in that case, so it was already wrong, just
    differently.) Both are set when the crate opens now.

Reviewed and deliberately left alone

  • .crate-modal z-index went 50 → 80 in the panel. Required, not
    incidental: the preview is launched from inside the product editor, which is
    z-index: 50. At the old value the preview would have opened behind the
    modal that launched it.
  • The desktop preview re-implements the motion in React. A third copy of the
    look would be the problem, and there isn't one — the CSS is the shared
    string. A srcdoc iframe would avoid even the motion duplication, but the
    packaged renderer runs under script-src 'self' and inline script in the
    iframe inherits that.
  • StorePublic.crateAnimation still exists alongside the per-product one.
    It is the store default, which the admin UIs need in order to label the
    "inherit" option with what will actually play.
  • publicRewards rounds to one decimal. A 1-in-10000 reward shows as
    <0.1% rather than 0%, which is the honest rendering; exact odds for a
    pathological pool are not worth a wider payload.

Verification after the fixes

MSMS_SMOKE_WEB exit 0, MSMS_SMOKE exit 0.

@CaYatur
CaYatur merged commit 3b487f0 into main Jul 28, 2026
1 check passed
@CaYatur
CaYatur deleted the feat/crates branch July 28, 2026 02:27
CaYatur added a commit that referenced this pull request Jul 28, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:crate Crates bug Something isn't working enhancement New feature or request

Projects

None yet

2 participants