Skip to content

Commit 887eb02

Browse files
authored
Crate: configurable animation model + desktop picker (#65)
* Crate: configurable animation model + desktop picker 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. * Review fix: handle a failed animation save, and show what was actually 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.
1 parent 2521fd1 commit 887eb02

11 files changed

Lines changed: 183 additions & 2 deletions

File tree

‎src/main/ipc/register.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ export function registerIpc(): void {
412412
balances: economy.listBalances(id),
413413
categories: economy.listCategories(id)
414414
}))
415+
H(IPC.storeCrateAnimation, (_e, id: string, animation: string) =>
416+
economy.setCrateAnimation(id, animation)
417+
)
415418
H(IPC.economyUpsertCategory, (_e, id: string, category: EconomyCategory) =>
416419
economy.upsertCategory(id, category)
417420
)

‎src/main/smoke.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import * as siteMod from './web/site'
1414
import { pickSiteLang } from './web/siteLang'
1515
import type { LedgerEntry, Product } from '@shared/web'
1616
import { categoryName, filterLedger, ledgerSummary } from '@shared/economy'
17+
import {
18+
CRATE_ANIMATIONS,
19+
DEFAULT_CRATE_ANIMATION,
20+
crateDuration,
21+
normalizeCrateAnimation
22+
} from '@shared/crate'
1723
import { getProvider } from './core/versions'
1824
import { createServer } from './core/createServer'
1925
import { pickForgeRunJar } from './core/serverDetect'
@@ -3516,6 +3522,37 @@ export async function runWebSmoke(): Promise<void> {
35163522
return fail('a deleted category should fall back to its raw id')
35173523
}
35183524
console.log('WEB-SMOKE: economy categories OK (validated on write, history not rewritten on delete)')
3525+
3526+
// Crate animation (#16): a bad value must never stop a player receiving
3527+
// what they paid for, so every path coerces to a real animation.
3528+
if (normalizeCrateAnimation('spin') !== 'spin') return fail('a valid animation was rejected')
3529+
for (const bad of [undefined, null, '', 'nope', 42, {}, []]) {
3530+
if (normalizeCrateAnimation(bad) !== DEFAULT_CRATE_ANIMATION) {
3531+
return fail('a bad animation did not fall back to the default: ' + String(bad))
3532+
}
3533+
}
3534+
if (crateDuration('instant') !== 0) return fail('instant should have no wait')
3535+
if (crateDuration('garbage') !== crateDuration(DEFAULT_CRATE_ANIMATION)) {
3536+
return fail('an unknown animation should time like the default')
3537+
}
3538+
if (CRATE_ANIMATIONS[0].id !== DEFAULT_CRATE_ANIMATION) {
3539+
return fail('the default should be the first option in the picker')
3540+
}
3541+
// Round-trip through the store, including the buyer-facing payload - the
3542+
// panel plays the animation from publicStore, not from the admin config.
3543+
const anSrv = 'crate-smoke-server'
3544+
if (economy.getStoreConfig(anSrv).crateAnimation !== DEFAULT_CRATE_ANIMATION) {
3545+
return fail('a fresh store should default its animation')
3546+
}
3547+
if (economy.setCrateAnimation(anSrv, 'flip') !== 'flip') return fail('set did not return the value')
3548+
if (economy.getStoreConfig(anSrv).crateAnimation !== 'flip') return fail('animation did not persist')
3549+
if (economy.publicStore(anSrv).crateAnimation !== 'flip') {
3550+
return fail('the buyer-facing store must carry the animation')
3551+
}
3552+
if (economy.setCrateAnimation(anSrv, 'not-real') !== DEFAULT_CRATE_ANIMATION) {
3553+
return fail('an invalid animation must be coerced, not stored')
3554+
}
3555+
console.log('WEB-SMOKE: crate animation OK (coerced on every path, reaches the buyer payload)')
35193556
} catch (e) {
35203557
return fail('exception: ' + String(e))
35213558
} finally {

‎src/main/store/economy.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { processManager } from '../core/processManager'
55
import * as rcon from '../core/rcon'
66
import { log } from '../logger'
77
import { DEFAULT_CATEGORIES } from '@shared/economy'
8+
import { DEFAULT_CRATE_ANIMATION, normalizeCrateAnimation } from '@shared/crate'
9+
import type { CrateAnimation } from '@shared/crate'
810
import type {
911
BuyResult,
1012
CrateReward,
@@ -19,6 +21,7 @@ import type {
1921

2022
interface StoreState {
2123
currency: string
24+
crateAnimation: CrateAnimation
2225
products: Product[]
2326
/** Economy categories - independent of `products` (#13). */
2427
categories: EconomyCategory[]
@@ -51,6 +54,7 @@ function getStore(serverId: string): StoreState {
5154
if (!stores[serverId]) {
5255
stores[serverId] = {
5356
currency: 'Coins',
57+
crateAnimation: DEFAULT_CRATE_ANIMATION,
5458
products: [],
5559
categories: DEFAULT_CATEGORIES.map((c) => ({ ...c })),
5660
balances: {},
@@ -66,6 +70,9 @@ function getStore(serverId: string): StoreState {
6670
if (!Array.isArray(stores[serverId].categories)) {
6771
stores[serverId].categories = DEFAULT_CATEGORIES.map((c) => ({ ...c }))
6872
}
73+
// ...and files that predate configurable crate animations. Normalised on read
74+
// so a hand-edited json cannot leave the panel with an animation it cannot play.
75+
stores[serverId].crateAnimation = normalizeCrateAnimation(stores[serverId].crateAnimation)
6976
return stores[serverId]
7077
}
7178

@@ -196,7 +203,11 @@ function toPublic(p: Product): ProductPublic {
196203
}
197204
export function publicStore(serverId: string): StorePublic {
198205
const st = getStore(serverId)
199-
return { currency: st.currency, products: st.products.map(toPublic) }
206+
return {
207+
currency: st.currency,
208+
products: st.products.map(toPublic),
209+
crateAnimation: st.crateAnimation
210+
}
200211
}
201212
export function getBalance(serverId: string, mcName: string): number {
202213
return getStore(serverId).balances[mcName] ?? 0
@@ -208,12 +219,18 @@ export function getTxns(serverId: string, mcName: string): Txn[] {
208219
// ---- admin (trusted: desktop, or web users with 'store' scope) ----
209220
export function getStoreConfig(serverId: string): StoreConfig {
210221
const st = getStore(serverId)
211-
return { currency: st.currency, products: st.products }
222+
return { currency: st.currency, products: st.products, crateAnimation: st.crateAnimation }
212223
}
213224
export function setCurrency(serverId: string, currency: string): void {
214225
getStore(serverId).currency = currency.trim() || 'Coins'
215226
save()
216227
}
228+
export function setCrateAnimation(serverId: string, animation: unknown): CrateAnimation {
229+
const st = getStore(serverId)
230+
st.crateAnimation = normalizeCrateAnimation(animation)
231+
save()
232+
return st.crateAnimation
233+
}
217234
export function upsertProduct(serverId: string, product: Product): Product {
218235
const st = getStore(serverId)
219236
const clean: Product = {

‎src/main/web/server.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,11 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise<v
526526
return sendJson(res, 400, { error: String((e as Error)?.message ?? e) })
527527
}
528528
}
529+
if (rest === 'admin/crate-animation' && method === 'POST') {
530+
if (!gate('store')) return
531+
const b = (await readBody(req).catch(() => ({}))) as { animation?: string }
532+
return sendJson(res, 200, { animation: economy.setCrateAnimation(id, b.animation) })
533+
}
529534
if (rest === 'admin/category' && method === 'POST') {
530535
if (!gate('store')) return
531536
const b = (await readBody(req).catch(() => ({}))) as EconomyCategory

‎src/preload/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const api: MsmsApi = {
134134
ipcRenderer.invoke(IPC.storeAddBalance, id, mcName, amount, reason, category),
135135
setStoreBalance: (id, mcName, amount, reason, category) =>
136136
ipcRenderer.invoke(IPC.storeSetBalance, id, mcName, amount, reason, category),
137+
setCrateAnimation: (id, animation) => ipcRenderer.invoke(IPC.storeCrateAnimation, id, animation),
137138
upsertEconomyCategory: (id, category) =>
138139
ipcRenderer.invoke(IPC.economyUpsertCategory, id, category),
139140
deleteEconomyCategory: (id, categoryId) =>

‎src/renderer/src/locales/en.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,17 @@ export default {
730730
newCategory: 'New category name',
731731
catAll: 'All categories',
732732
catNone: 'Uncategorised',
733+
crateAnimation: 'Crate opening animation',
734+
anim_reel: 'Reel (horizontal scroll)',
735+
anim_spin: 'Slot machine (vertical spin)',
736+
anim_flip: 'Card flip',
737+
anim_burst: 'Quick burst',
738+
anim_instant: 'Instant (no animation)',
739+
animDesc_reel: 'The classic sideways-scrolling strip that slows to a stop on the reward. About 4 seconds.',
740+
animDesc_spin: 'A vertical column spins like a slot machine and lands on the reward. About 3 seconds.',
741+
animDesc_flip: 'Face-down cards flip over one by one until the reward is revealed. About 3 seconds.',
742+
animDesc_burst: 'A short shuffle, then the reward pops in. Under 2 seconds — good for busy stores.',
743+
animDesc_instant: 'No animation at all; the reward is shown immediately. Best for slow devices.',
733744
ledger: 'Balance log',
734745
noLedger: 'No balance changes yet.',
735746
noLedgerMatch: 'No entries match this filter.',

‎src/renderer/src/locales/tr.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,17 @@ const tr: typeof en = {
734734
newCategory: 'Yeni kategori adı',
735735
catAll: 'Tüm kategoriler',
736736
catNone: 'Kategorisiz',
737+
crateAnimation: 'Sandık açma animasyonu',
738+
anim_reel: 'Şerit (yatay kayma)',
739+
anim_spin: 'Slot makinesi (dikey dönüş)',
740+
anim_flip: 'Kart çevirme',
741+
anim_burst: 'Hızlı patlama',
742+
anim_instant: 'Anında (animasyonsuz)',
743+
animDesc_reel: 'Yavaşlayarak ödülün üzerinde duran klasik yatay şerit. Yaklaşık 4 saniye.',
744+
animDesc_spin: 'Dikey bir sütun slot makinesi gibi döner ve ödülde durur. Yaklaşık 3 saniye.',
745+
animDesc_flip: 'Kapalı kartlar ödül çıkana kadar tek tek çevrilir. Yaklaşık 3 saniye.',
746+
animDesc_burst: 'Kısa bir karıştırma, ardından ödül belirir. 2 saniyenin altında — yoğun mağazalar için.',
747+
animDesc_instant: 'Hiç animasyon yok; ödül anında gösterilir. Yavaş cihazlar için en iyisi.',
737748
ledger: 'Bakiye günlüğü',
738749
noLedger: 'Henüz bakiye değişikliği yok.',
739750
noLedgerMatch: 'Bu filtreyle eşleşen kayıt yok.',

‎src/renderer/src/views/StoreView.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
} from 'lucide-react'
1616
import { useStore } from '../store'
1717
import { categoryName, filterLedger, ledgerSummary } from '@shared/economy'
18+
import { CRATE_ANIMATIONS, DEFAULT_CRATE_ANIMATION } from '@shared/crate'
19+
import type { CrateAnimation } from '@shared/crate'
1820
import type { LedgerKind } from '@shared/economy'
1921
import type {
2022
Product,
@@ -48,6 +50,7 @@ export function StoreView(): JSX.Element {
4850
const [section, setSection] = useState<'economy' | 'store'>('economy')
4951
const [balCategory, setBalCategory] = useState('')
5052
const [newCat, setNewCat] = useState('')
53+
const [crateAnim, setCrateAnim] = useState<CrateAnimation>(DEFAULT_CRATE_ANIMATION)
5154
const [edit, setEdit] = useState<Product | null>(null)
5255
const [cmdText, setCmdText] = useState('')
5356

@@ -62,6 +65,7 @@ export function StoreView(): JSX.Element {
6265
const d = await window.msms.getStore(id)
6366
setData(d)
6467
setCurrency(d.currency)
68+
setCrateAnim(d.crateAnimation ?? DEFAULT_CRATE_ANIMATION)
6569
setLedger(await window.msms.getStoreLedger(id))
6670
}
6771
useEffect(() => {
@@ -120,6 +124,20 @@ export function StoreView(): JSX.Element {
120124
toast('success', 'store.saved')
121125
void load()
122126
}
127+
const saveCrateAnimation = async (animation: CrateAnimation): Promise<void> => {
128+
const previous = crateAnim
129+
setCrateAnim(animation)
130+
try {
131+
// Trust what was actually stored, not what was asked for - the main side
132+
// coerces an unknown value, and the picker must not claim otherwise.
133+
const saved = await window.msms.setCrateAnimation(id, animation)
134+
setCrateAnim(saved)
135+
toast('success', 'store.saved')
136+
} catch (e) {
137+
setCrateAnim(previous)
138+
toast('error', String((e as Error)?.message ?? e))
139+
}
140+
}
123141
const addCategory = async (): Promise<void> => {
124142
const name = newCat.trim()
125143
if (!name) return
@@ -175,6 +193,27 @@ export function StoreView(): JSX.Element {
175193
<Check size={14} /> {t('common.save')}
176194
</button>
177195
</div>
196+
197+
{/* Per-server, because the people who see it are the players buying
198+
from this server's panel - not the operator at the desktop (#17). */}
199+
<div className="field" style={{ marginTop: 14, marginBottom: 0, maxWidth: 420 }}>
200+
<label>
201+
<Gift size={13} style={{ verticalAlign: -2, marginRight: 5 }} />
202+
{t('store.crateAnimation')}
203+
</label>
204+
<select
205+
className="input"
206+
value={crateAnim}
207+
onChange={(e) => void saveCrateAnimation(e.target.value as CrateAnimation)}
208+
>
209+
{CRATE_ANIMATIONS.map((a) => (
210+
<option key={a.id} value={a.id}>
211+
{t(`store.anim_${a.id}`)}
212+
</option>
213+
))}
214+
</select>
215+
<p className="hint" style={{ marginBottom: 0 }}>{t(`store.animDesc_${crateAnim}`)}</p>
216+
</div>
178217
</div>
179218

180219
<div style={{ display: section === 'economy' ? undefined : 'none' }}>

‎src/shared/crate.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Crate opening animations (#16).
3+
*
4+
* The animation is per-server store config, not a global app preference: the
5+
* people who see it are the players buying from that server's panel, not the
6+
* operator sitting at the desktop app.
7+
*/
8+
9+
export type CrateAnimation = 'reel' | 'spin' | 'flip' | 'burst' | 'instant'
10+
11+
export interface CrateAnimationMeta {
12+
id: CrateAnimation
13+
/** How long the animation runs before the reward is announced, in ms. */
14+
durationMs: number
15+
}
16+
17+
/**
18+
* Order is the order shown in the picker. `reel` stays first and is the
19+
* default: it is what every existing store already plays, so an operator who
20+
* never touches this setting sees no change.
21+
*/
22+
export const CRATE_ANIMATIONS: CrateAnimationMeta[] = [
23+
{ id: 'reel', durationMs: 4000 },
24+
{ id: 'spin', durationMs: 3200 },
25+
{ id: 'flip', durationMs: 2800 },
26+
{ id: 'burst', durationMs: 1600 },
27+
{ id: 'instant', durationMs: 0 }
28+
]
29+
30+
export const DEFAULT_CRATE_ANIMATION: CrateAnimation = 'reel'
31+
32+
const IDS = CRATE_ANIMATIONS.map((a) => a.id)
33+
34+
/**
35+
* Pure: coerce anything (an old config, a hand-edited json, a renderer that
36+
* sent nonsense) to a real animation. Never throws - a bad value must not stop
37+
* a player from receiving what they paid for, so it degrades to the default.
38+
*/
39+
export function normalizeCrateAnimation(value: unknown): CrateAnimation {
40+
return typeof value === 'string' && (IDS as string[]).includes(value)
41+
? (value as CrateAnimation)
42+
: DEFAULT_CRATE_ANIMATION
43+
}
44+
45+
/** Pure: how long the panel should wait before announcing the reward. */
46+
export function crateDuration(animation: unknown): number {
47+
const id = normalizeCrateAnimation(animation)
48+
return CRATE_ANIMATIONS.find((a) => a.id === id)?.durationMs ?? 4000
49+
}

‎src/shared/ipc.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type {
5454
LedgerEntry,
5555
EconomyCategory
5656
} from './web'
57+
import type { CrateAnimation } from './crate'
5758

5859
/** request/response channels (renderer -> main via invoke). */
5960
export const IPC = {
@@ -182,6 +183,7 @@ export const IPC = {
182183
storeAddBalance: 'store:add-balance',
183184
storeSetBalance: 'store:set-balance',
184185
storeLedger: 'store:ledger',
186+
storeCrateAnimation: 'store:crate-animation',
185187
economyUpsertCategory: 'economy:upsert-category',
186188
economyDeleteCategory: 'economy:delete-category',
187189

@@ -395,6 +397,7 @@ export interface MsmsApi {
395397
reason?: string,
396398
category?: string
397399
): Promise<number>
400+
setCrateAnimation(id: string, animation: CrateAnimation): Promise<CrateAnimation>
398401
upsertEconomyCategory(id: string, category: EconomyCategory): Promise<EconomyCategory>
399402
deleteEconomyCategory(id: string, categoryId: string): Promise<void>
400403
getStoreLedger(id: string, mcName?: string): Promise<LedgerEntry[]>

0 commit comments

Comments
 (0)