feat(points): add effect engines and effects shop

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent 2ec5833f29
commit dd45c0b5f6
12 files changed
+697 -11

No files matched your search

+53 -1
View File
@@ -1,10 +1,11 @@
import {Swords, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
import {storage} from './storage.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME'];
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND'];
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
// An unknown code is skipped.
@@ -17,6 +18,28 @@ export const LOOKS = {
ACCENT_TEAL: {accent: 'teal'},
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'},
FRAME_NEON: {className: 'ring-2 ring-fuchsia-500 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-fuchsia-500/60'},
// Completed effects (effects.css): `cover` is the class set of a tile, `detail` the decoration of the details page.
FX_GOLD_SHINE: {cover: 'fx fx-cover-gold-shine outline outline-2 -outline-offset-2 outline-amber-400', detail: 'fx-detail-gold-shine'},
FX_NEON_PULSE: {cover: 'fx fx-cover-neon-pulse outline outline-2 -outline-offset-2 outline-primary', detail: 'fx-detail-neon-pulse'},
FX_HOLO: {cover: 'fx fx-cover-holo', detail: 'fx-detail-holo'},
FX_EMBER: {cover: 'fx fx-cover-ember', detail: 'fx-detail-ember'},
// Page and banner backgrounds: BG_ and BN_ of a name share one engine (components/EffectBackground.jsx).
BG_TRIANGLES: {effect: 'triangles'},
BG_STARFIELD: {effect: 'starfield'},
BG_PIXELS: {effect: 'pixels'},
BG_AURORA: {effect: 'aurora'},
BG_MESH: {effect: 'mesh'},
BG_GRID: {effect: 'grid'},
BG_WAVES: {effect: 'waves'},
BG_BOKEH: {effect: 'bokeh'},
BN_TRIANGLES: {effect: 'triangles'},
BN_STARFIELD: {effect: 'starfield'},
BN_PIXELS: {effect: 'pixels'},
BN_AURORA: {effect: 'aurora'},
BN_MESH: {effect: 'mesh'},
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
@@ -25,12 +48,41 @@ export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? n
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
// Only a COMPLETED game shows the completed effect (FINISHED is not 100 %).
const completedLook = (equipped, status) => (status === 'COMPLETED' ? LOOKS[equipped?.COMPLETED_EFFECT] : undefined);
/** The cover classes of the completed effect worn, or undefined. */
export const coverEffect = (equipped, status) => completedLook(equipped, status)?.cover;
/** The details page decoration class of the completed effect worn, or undefined. */
export const detailEffect = (equipped, status) => completedLook(equipped, status)?.detail;
/** The effect name of the page background worn, or null (then the game art applies). */
export const backgroundEffect = (equipped) => LOOKS[equipped?.PAGE_BACKGROUND]?.effect ?? null;
/** The effect name of the banner background worn, or null. */
export const bannerEffect = (equipped) => LOOKS[equipped?.BANNER_BACKGROUND]?.effect ?? null;
/** The badge worn: `{code, Icon, className}` or null. */
export const equippedBadge = (equipped) => {
const look = LOOKS[equipped?.BADGE];
return look?.Icon ? {code: equipped.BADGE, ...look} : null;
};
const EQUIPPED_STORAGE_KEY = 'tbp_equipped';
/** The last known worn looks, so the first paint does not flash the item 10 art before the wallet answers. */
export const readCachedEquipped = () => {
try {
const value = JSON.parse(storage.get(EQUIPPED_STORAGE_KEY));
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch {
return {};
}
};
export const cacheEquipped = (equipped) => storage.set(EQUIPPED_STORAGE_KEY, JSON.stringify(equipped));
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
+26
View File
@@ -0,0 +1,26 @@
import {describe, expect, it} from 'vitest';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js';
describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
it('shows the completed effect only on a COMPLETED game', () => {
expect(coverEffect(equipped, 'COMPLETED')).toContain('fx-cover-gold-shine');
expect(detailEffect(equipped, 'COMPLETED')).toBe('fx-detail-gold-shine');
expect(coverEffect(equipped, 'FINISHED')).toBeUndefined();
expect(detailEffect(equipped, 'FINISHED')).toBeUndefined();
});
it('ignores an unknown or missing code', () => {
expect(coverEffect({COMPLETED_EFFECT: 'FX_FUTURE'}, 'COMPLETED')).toBeUndefined();
expect(coverEffect(undefined, 'COMPLETED')).toBeUndefined();
expect(backgroundEffect({PAGE_BACKGROUND: 'BG_FUTURE'})).toBeNull();
expect(bannerEffect({})).toBeNull();
});
it('maps a background and a banner of one name to the same engine', () => {
expect(backgroundEffect(equipped)).toBe('starfield');
expect(bannerEffect(equipped)).toBe('waves');
expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect);
});
});