1/** 2 * Bad Crew badge artwork â THE single source of truth for badge imagery. 3 * 4 * This module is imported unchanged by both the browser (member dashboard, public 5 * roster) and the test suite, exactly like `badge-rules.mjs` and `bad-mickey.mjs`. 6 * It maps each badge key from the ladder onto the supplied artwork and says how 7 * that artwork must be framed. It contains NO qualification logic: whether a 8 * badge is earned is decided only by `computeBadgeState()` in `badge-rules.mjs`, 9 * and this module merely renders whatever that engine already decided. 10 * 11 * THERE IS NO GOOFY BADGE. The ladder ends at Bad Crew, and the Bad Crew badge is 12 * the full five-character group artwork â that group image IS what a member is 13 * working toward when they complete the Goofy stage of the ecosystem. The 14 * standalone Bad Goofy artwork is deliberately NOT in this map; it is kept in the 15 * repository for future Bad Goofy token/presale content only. See `UNUSED_ART`. 16 */ 17import { BADGE_KEYS, getBadge } from './badge-rules.mjs'; 18 19/** 20 * How a badge image is fitted into its frame. 21 * 22 * `cover` fills the frame and crops the edges â correct for the single-character 23 * portraits, which have one subject in the middle and nothing to lose at the 24 * sides. `contain` letterboxes instead of cropping, which is mandatory for the 25 * Bad Crew group image: all five characters have to stay visible, so it is never 26 * cropped down into a portrait shape. 27 */ 28export const FIT = { COVER: 'cover', CONTAIN: 'contain' }; 29 30export const ORIENTATION = { PORTRAIT: 'portrait', LANDSCAPE: 'landscape' }; 31 32/** 33 * Badge key â artwork. 34 * 35 * `alt` is the accessible name and is required: badge state is never 36 * communicated by brightness or colour alone, and the image itself always 37 * announces which badge it depicts. 38 * 39 * `focus` is the CSS `object-position` used when `fit` is `cover`. The portraits 40 * are tall (2:3) with the character's face in the upper half, so the crop is 41 * biased upward â centring it would cut heads off on wide frames. 42 */ 43export const BADGE_ART = { 44 bad_badge: { 45 src: '/assets/crew/badges/bad-badge.jpg', 46 alt: 'Bad Badge artwork', 47 orientation: ORIENTATION.PORTRAIT, 48 fit: FIT.COVER, 49 focus: '50% 22%', 50 /** Solo Bad Mickey. */ 51 subject: 'Bad Mickey', 52 }, 53 partners_in_crime: { 54 src: '/assets/crew/badges/partners-in-crime.jpg', 55 alt: 'Partners in Crime badge artwork', 56 orientation: ORIENTATION.PORTRAIT, 57 fit: FIT.COVER, 58 focus: '50% 28%', 59 /** Bad Mickey and Bad Minnie together â "he did not come back alone". */ 60 subject: 'Bad Mickey and Bad Minnie', 61 }, 62 getaway_crew: { 63 src: '/assets/crew/badges/getaway-crew.jpg', 64 alt: 'Getaway Crew badge artwork', 65 orientation: ORIENTATION.PORTRAIT, 66 fit: FIT.COVER, 67 focus: '50% 24%', 68 subject: 'Bad Donald', 69 }, 70 double_cross: { 71 src: '/assets/crew/badges/double-cross.jpg', 72 alt: 'Double Cross badge artwork', 73 orientation: ORIENTATION.PORTRAIT, 74 fit: FIT.COVER, 75 focus: '50% 24%', 76 subject: 'Bad Daisy', 77 }, 78 bad_crew: { 79 src: '/assets/crew/badges/bad-crew.jpg', 80 alt: 'Bad Crew badge artwork', 81 /** 82 * The only landscape badge, and the only one that must never be cropped: 83 * it is the full crew lineup, and forcing it into a portrait frame would 84 * delete members from the group. 85 */ 86 orientation: ORIENTATION.LANDSCAPE, 87 fit: FIT.CONTAIN, 88 focus: '50% 50%', 89 subject: 'the full Bad Crew â Bad Minnie, Bad Donald, Bad Mickey, Bad Daisy and Bad Goofy', 90 }, 91}; 92 93/** 94 * Artwork that exists in the repository but is deliberately NOT a badge. 95 * 96 * The standalone Bad Goofy portrait is held for future Bad Goofy token and 97 * presale content. Completing the Goofy stage awards the Bad Crew badge (the 98 * group artwork above), so a Goofy badge does not exist and must never be added 99 * here without a product decision that also adds a sixth rung to the ladder. 100 */ 101export const UNUSED_ART = ['/assets/bad-goofy.jpg']; 102 103/** Look up the artwork for a badge key. Returns null for anything unknown. */ 104export function badgeArt(badgeKey) { 105 return BADGE_ART[badgeKey] ?? null; 106} 107 108/* ---------- Visual state ---------- */ 109
110/** 111 * The three visually distinct badge states. 112 * 113 * These are DERIVED from the badge engine's own output. Nothing here can award 114 * or withhold a badge; `badgeArtState()` only reads the decision that 115 * `computeBadgeState()` already made. 116 */ 117export const ART_STATE = { 118 /** Every requirement met, including verified Telegram. Full colour. */ 119 EARNED: 'earned', 120 /** Wallet/token requirements met, Telegram not verified. NOT earned. */ 121 AWAITING_TELEGRAM: 'awaiting-telegram', 122 /** Requirements not met. Dimmed and locked, but still visible. */ 123 LOCKED: 'locked', 124}; 125 126/** 127 * Primary status word for each state. Always rendered as text, so the state is 128 * never carried by colour or brightness alone. 129 */ 130export const ART_STATE_LABEL = { 131 [ART_STATE.EARNED]: 'EARNED', 132 [ART_STATE.AWAITING_TELEGRAM]: 'WALLET REQUIREMENT MET', 133 [ART_STATE.LOCKED]: 'LOCKED', 134}; 135 136/** Second line, present only where there is a specific action to name. */ 137export const ART_STATE_HINT = { 138 [ART_STATE.EARNED]: 'Badge awarded', 139 [ART_STATE.AWAITING_TELEGRAM]: 'VERIFY TELEGRAM TO UNLOCK', 140 [ART_STATE.LOCKED]: 'Requirements not met', 141}; 142 143/** 144 * Map one tier of the engine's output onto an artwork state. 145 * 146 * `unlocked` and `awaitingTelegram` are computed in `computeBadgeState()`: 147 * `unlocked` already includes the verified-Telegram award gate, and 148 * `awaitingTelegram` is precisely "wallet qualified, badge withheld". Reading 149 * them here â rather than re-deriving anything from amounts â is what keeps the 150 * visuals honest. 151 * 152 * @param {{unlocked?: boolean, awaitingTelegram?: boolean}} tierState 153 */ 154export function badgeArtState(tierState) { 155 if (tierState?.unlocked === true) return ART_STATE.EARNED; 156 if (tierState?.awaitingTelegram === true) return ART_STATE.AWAITING_TELEGRAM; 157 return ART_STATE.LOCKED; 158} 159 160/** True only for the fully awarded state. */ 161export function isEarnedState(state) { 162 return state === ART_STATE.EARNED; 163} 164 165/** 166 * Accessible name for a badge image including its status, e.g. 167 * "Bad Crew badge artwork â LOCKED". Used where the status text is not already 168 * adjacent to the image in the reading order. 169 */ 170export function badgeArtLabel(badgeKey, state) { 171 const art = badgeArt(badgeKey); 172 const badge = getBadge(badgeKey); 173 const name = art ? art.alt : `${badge ? badge.name : 'Badge'} artwork`; 174 const status = ART_STATE_LABEL[state]; 175 return status ? `${name} â ${status}` : name; 176} 177 178/** 179 * Every badge on the ladder has artwork, and there is artwork for nothing else. 180 * Exported so both the test suite and a boot-time check can assert it. 181 */ 182export function badgeArtCoverageError() { 183 const artKeys = Object.keys(BADGE_ART); 184 const missing = BADGE_KEYS.filter((key) => !artKeys.includes(key)); 185 const extra = artKeys.filter((key) =>
185 !BADGE_KEYS.includes(key)); 186 if (missing.length) return `badges without artwork: ${missing.join(', ')}`; 187 if (extra.length) return `artwork for badges that do not exist: ${extra.join(', ')}`; 188 return null; 189}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.