From 38720b0937fcb3b1e8642a2e858cd3c3d68a5a29 Mon Sep 17 00:00:00 2001 From: "gr0wth.eth.xmr" <38635290+ethgr0wth@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:40:29 -0400 Subject: [PATCH] Secure Wheel of Fortune backend and UI --- .../fastapi-ambassador-leaderboard/main.py | 541 ++++++++++++++++ .../templates/index.html | 591 ++++++++++++++++++ .../templates/partials/game_high_roller.html | 19 + .../templates/partials/game_mystery_box.html | 19 + .../partials/game_wheel_of_fortune.html | 67 ++ 5 files changed, 1237 insertions(+) create mode 100644 ambassadors/fastapi-ambassador-leaderboard/templates/partials/game_high_roller.html create mode 100644 ambassadors/fastapi-ambassador-leaderboard/templates/partials/game_mystery_box.html create mode 100644 ambassadors/fastapi-ambassador-leaderboard/templates/partials/game_wheel_of_fortune.html diff --git a/ambassadors/fastapi-ambassador-leaderboard/main.py b/ambassadors/fastapi-ambassador-leaderboard/main.py index aa1d05f4f..ac5ce23a9 100644 --- a/ambassadors/fastapi-ambassador-leaderboard/main.py +++ b/ambassadors/fastapi-ambassador-leaderboard/main.py @@ -11,6 +11,7 @@ import csv import io import string +import random from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict @@ -24,6 +25,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from redis.asyncio import Redis +from redis.exceptions import LockError # Load environment variables @@ -109,6 +111,7 @@ redis_client: Redis = Redis.from_url(REDIS_URL, decode_responses=True) CACHE_KEY = "leaderboard_cache" +RNG = random.SystemRandom() ANALYTICS_VISITOR_KEY = "analytics:visits" ANALYTICS_VISITOR_LIMIT = 500 @@ -370,6 +373,80 @@ async def _build_profile_account_context( P2P_USER_HISTORY_PREFIX = "p2p:history:user:" P2P_USER_HISTORY_LIMIT = int(os.getenv("P2P_USER_HISTORY_LIMIT", "100")) +WHEEL_CONFIG_KEY = "gamefi:wheel:config" +WHEEL_HISTORY_KEY = "gamefi:wheel:history" +WHEEL_HISTORY_LIMIT = int(os.getenv("WHEEL_HISTORY_LIMIT", "200")) +WHEEL_LOCK_KEY = "gamefi:wheel:lock" +WHEEL_USER_LOCK_PREFIX = "gamefi:wheel:user:" +WHEEL_LAST_SPIN_PREFIX = "gamefi:wheel:last_spin:" +DEFAULT_WHEEL_ENTRY_FEE = float(os.getenv("WHEEL_DEFAULT_ENTRY_FEE", "50")) +DEFAULT_WHEEL_POOL = float(os.getenv("WHEEL_DEFAULT_POOL", "12500")) +DEFAULT_WHEEL_COOLDOWN_SECONDS = int(os.getenv("WHEEL_DEFAULT_COOLDOWN", "8")) + +DEFAULT_WHEEL_SEGMENTS = ( + { + "id": "multiplier_2x", + "label": "+2x IGP", + "type": "multiplier", + "multiplier": 2, + "tone": "win", + "weight": 2, + }, + { + "id": "fixed_500", + "label": "+500 IGP", + "type": "fixed", + "amount": 500, + "tone": "win", + "weight": 1.5, + }, + { + "id": "retry", + "label": "Try Again", + "type": "retry", + "tone": "neutral", + "weight": 1.2, + }, + { + "id": "fixed_1000", + "label": "+1000 IGP", + "type": "fixed", + "amount": 1000, + "tone": "win", + "weight": 1, + }, + { + "id": "lose", + "label": "Lose Spin", + "type": "lose", + "tone": "lose", + "weight": 1.4, + }, + { + "id": "fixed_250", + "label": "+250 IGP", + "type": "fixed", + "amount": 250, + "tone": "win", + "weight": 1.6, + }, + { + "id": "multiplier_3x", + "label": "+3x IGP", + "type": "multiplier", + "multiplier": 3, + "tone": "win", + "weight": 0.75, + }, + { + "id": "bonus", + "label": "Bonus Spin", + "type": "bonus", + "tone": "neutral", + "weight": 1.25, + }, +) + def _monday_start(dt: datetime | None = None) -> datetime: dt = dt or datetime.utcnow() @@ -414,6 +491,268 @@ def _normalize_email(email: str) -> str: return str(email or "").strip().lower() +def _default_wheel_segments() -> list[dict[str, Any]]: + return [dict(segment) for segment in DEFAULT_WHEEL_SEGMENTS] + + +def _mask_email(email: str) -> str: + email_norm = _normalize_email(email) + if not email_norm: + return "anon" + local, _, domain = email_norm.partition("@") + if len(local) <= 2: + masked_local = f"{local[:1]}***" + else: + masked_local = f"{local[:2]}***{local[-1]}" + domain_hint = domain.split(".")[0] if domain else "mail" + return f"{masked_local}@{domain_hint}" + + +def _format_clock(ts: str) -> str: + if not ts: + return "" + try: + dt = datetime.fromisoformat(ts) + except ValueError: + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + except ValueError: + return ts + return dt.strftime("%H:%M:%S") + + +def _coerce_wheel_config(raw: dict[str, Any]) -> dict[str, Any]: + config = dict(raw) + try: + entry_fee = float(config.get("entry_fee", DEFAULT_WHEEL_ENTRY_FEE) or 0.0) + except Exception: + entry_fee = DEFAULT_WHEEL_ENTRY_FEE + try: + pool_balance = float(config.get("pool_balance", DEFAULT_WHEEL_POOL) or 0.0) + except Exception: + pool_balance = DEFAULT_WHEEL_POOL + try: + cooldown = int(config.get("cooldown_seconds", DEFAULT_WHEEL_COOLDOWN_SECONDS) or 0) + except Exception: + cooldown = DEFAULT_WHEEL_COOLDOWN_SECONDS + enabled_flag = str(config.get("enabled", "1") or "1").strip() + segments_raw = config.get("segments") + if isinstance(segments_raw, str): + try: + segments = json.loads(segments_raw) + except json.JSONDecodeError: + segments = _default_wheel_segments() + elif isinstance(segments_raw, list): + segments = [dict(s) for s in segments_raw] + else: + segments = _default_wheel_segments() + if not segments: + segments = _default_wheel_segments() + + normalized_segments: list[dict[str, Any]] = [] + for idx, segment in enumerate(segments): + seg = { + "id": segment.get("id") or f"segment_{idx}", + "label": segment.get("label") or "Spin", + "type": segment.get("type") or "fixed", + "tone": segment.get("tone") or "neutral", + "weight": float(segment.get("weight", 1.0) or 1.0), + } + if "multiplier" in segment: + try: + seg["multiplier"] = float(segment.get("multiplier") or 0.0) + except Exception: + seg["multiplier"] = 0.0 + if "amount" in segment: + try: + seg["amount"] = float(segment.get("amount") or 0.0) + except Exception: + seg["amount"] = 0.0 + normalized_segments.append(seg) + + return { + "entry_fee": max(0.0, entry_fee), + "pool_balance": max(0.0, pool_balance), + "cooldown_seconds": max(0, cooldown), + "enabled": enabled_flag not in {"0", "false", "off"}, + "segments": normalized_segments, + } + + +async def _ensure_wheel_config() -> dict[str, Any]: + raw = await redis_client.hgetall(WHEEL_CONFIG_KEY) + if raw: + return _coerce_wheel_config(raw) + now_iso = datetime.utcnow().isoformat() + config = { + "entry_fee": DEFAULT_WHEEL_ENTRY_FEE, + "pool_balance": DEFAULT_WHEEL_POOL, + "cooldown_seconds": DEFAULT_WHEEL_COOLDOWN_SECONDS, + "enabled": 1, + "segments": json.dumps(_default_wheel_segments()), + "updated_at": now_iso, + } + await redis_client.hset(WHEEL_CONFIG_KEY, mapping=config) + return _coerce_wheel_config(config) + + +async def _wheel_history(limit: int = 10) -> list[dict[str, Any]]: + entries = await redis_client.lrange(WHEEL_HISTORY_KEY, 0, max(limit, 0) - 1) + history: list[dict[str, Any]] = [] + for raw in entries: + try: + payload = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + created_at = str(payload.get("created_at", "")) + history.append( + { + "player": payload.get("player", "anon"), + "message": payload.get("message", ""), + "tone": payload.get("tone", "neutral"), + "payout": float(payload.get("payout", 0.0) or 0.0), + "entry_fee": float(payload.get("entry_fee", 0.0) or 0.0), + "timestamp": created_at, + "timestamp_display": _format_clock(created_at), + "segment_id": payload.get("segment_id", ""), + "segment_label": payload.get("segment_label", ""), + } + ) + return history + + +async def _wheel_state(email: str | None, limit: int = 6) -> dict[str, Any]: + config = await _ensure_wheel_config() + scorepad = await _scorepad_balance(email or "") if email else 0.0 + history = await _wheel_history(limit) + status_message = "Ready to spin. Good luck!" + status_tone = "neutral" + if history: + status_message = history[0]["message"] or status_message + status_tone = history[0]["tone"] or status_tone + segments = [] + for idx, segment in enumerate(config["segments"]): + seg = dict(segment) + seg["index"] = idx + segments.append(seg) + return { + "entry_fee": config["entry_fee"], + "pool_balance": config["pool_balance"], + "scorepad_balance": scorepad, + "cooldown_seconds": config["cooldown_seconds"], + "enabled": config["enabled"], + "segments": segments, + "history": history, + "status_message": status_message, + "status_tone": status_tone, + } + + +async def _record_wheel_spin(entry: dict[str, Any]) -> None: + payload = json.dumps(entry) + await redis_client.lpush(WHEEL_HISTORY_KEY, payload) + await redis_client.ltrim(WHEEL_HISTORY_KEY, 0, WHEEL_HISTORY_LIMIT - 1) + + +def _choose_wheel_segment(segments: list[dict[str, Any]]) -> tuple[int, dict[str, Any]]: + if not segments: + raise ValueError("no wheel segments configured") + weights = [max(float(seg.get("weight", 1.0) or 0.0), 0.0) for seg in segments] + total_weight = sum(weights) + if total_weight <= 0: + total_weight = float(len(segments)) + weights = [1.0 for _ in segments] + roll = RNG.random() * total_weight + cumulative = 0.0 + for idx, segment in enumerate(segments): + cumulative += weights[idx] + if roll <= cumulative or idx == len(segments) - 1: + return idx, segment + return len(segments) - 1, segments[-1] + + +def _wheel_outcome( + segment: dict[str, Any], + entry_fee: float, + pool_after_fee: float, +) -> tuple[float, str, str, dict[str, float]]: + seg_type = str(segment.get("type", "")).strip().lower() + label = segment.get("label") or "Spin" + tone = segment.get("tone", "neutral") + pool_available = max(0.0, pool_after_fee) + payout = 0.0 + message = label + detail: dict[str, float] = {} + + if seg_type == "multiplier": + try: + multiplier = float(segment.get("multiplier", 0.0) or 0.0) + except Exception: + multiplier = 0.0 + potential = max(0.0, entry_fee * multiplier) + payout = min(pool_available, potential) + detail["potential"] = potential + detail["multiplier"] = multiplier + if payout <= 0: + tone = "neutral" + message = "The pool is empty. Admins need to reload rewards." + elif payout + 1e-6 < potential: + tone = "neutral" + message = f"Pool light! Paid out {payout:.0f} IGP of {potential:.0f}." + else: + tone = "win" + message = f"Multiplier hit! +{payout:.0f} IGP." + elif seg_type == "fixed": + try: + amount = float(segment.get("amount", 0.0) or 0.0) + except Exception: + amount = 0.0 + potential = max(0.0, amount) + payout = min(pool_available, potential) + detail["potential"] = potential + if payout <= 0: + tone = "neutral" + message = "The pool is empty. Admins need to reload rewards." + elif payout + 1e-6 < potential: + tone = "neutral" + message = f"Partial win: paid {payout:.0f} IGP from the remaining pool." + else: + tone = "win" + message = f"Jackpot drop! +{payout:.0f} IGP." + elif seg_type == "bonus": + refund = min(pool_available, max(0.0, entry_fee)) + payout = refund + detail["refund"] = refund + if refund <= 0: + tone = "neutral" + message = "Bonus unlocked, but the pool is empty. No refund issued." + elif refund + 1e-6 < entry_fee: + tone = "neutral" + message = "Bonus spin unlocked! Partial refund applied while the pool refills." + else: + tone = "neutral" + message = "Bonus spin! Entry refunded β keep the wheel moving." + elif seg_type == "retry": + refund = min(pool_available, max(0.0, entry_fee)) + payout = refund + detail["refund"] = refund + if refund <= 0: + tone = "neutral" + message = "Try again streak triggered, but the pool is empty. Entry stays staked." + elif refund + 1e-6 < entry_fee: + tone = "neutral" + message = "Try again streak triggered with a partial refund while the pool reloads." + else: + tone = "neutral" + message = "Try again! Entry fee refunded for another spin." + else: + payout = 0.0 + tone = "lose" + message = "House wins β bank the lesson and spin again when ready." + + return payout, message, tone, detail + + def _parse_iso(value: str | None) -> datetime | None: if not value: return None @@ -4831,6 +5170,7 @@ async def index(request: Request) -> Any: if str(row.get("email", "")).strip().lower() not in guardians ] rows = [{k: row.get(k, "") for k in columns} for row in filtered_rows] + wheel_state = await _wheel_state(email, limit=8) return templates.TemplateResponse( "index.html", { @@ -4847,10 +5187,211 @@ async def index(request: Request) -> Any: "igp_to_itc": data["igp_to_itc"], "itc_to_igp": data["itc_to_igp"], "show_kyc_toast": profile_ctx["show_kyc_toast"], + "wheel_state": wheel_state, }, ) +@app.get("/api/gamefi/wheel/state") +async def api_wheel_state(request: Request) -> JSONResponse: + email = await _current_email(request) + if not email: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + state = await _wheel_state(email, limit=10) + return JSONResponse({"ok": True, "state": state}) + + +@app.post("/api/gamefi/wheel/spin") +async def api_wheel_spin(request: Request) -> JSONResponse: + email = await _current_email(request) + if not email: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + email_norm = _normalize_email(email) + config = await _ensure_wheel_config() + if not config["enabled"]: + return JSONResponse( + {"ok": False, "error": "wheel_disabled", "detail": "Wheel is currently paused by admins."}, + status_code=423, + ) + + entry_fee = float(config.get("entry_fee", 0.0) or 0.0) + segments = list(config.get("segments", [])) + cooldown = int(config.get("cooldown_seconds", 0) or 0) + + if entry_fee <= 0: + return JSONResponse( + {"ok": False, "error": "invalid_config", "detail": "Entry fee is not configured."}, + status_code=503, + ) + if not segments: + return JSONResponse( + {"ok": False, "error": "invalid_config", "detail": "Reward segments are not configured."}, + status_code=503, + ) + + now = datetime.utcnow() + if cooldown > 0: + last_spin_key = f"{WHEEL_LAST_SPIN_PREFIX}{email_norm}" + last_spin_raw = await redis_client.get(last_spin_key) + if last_spin_raw: + last_spin_dt = _parse_iso(last_spin_raw) + if last_spin_dt is not None: + elapsed = (now - last_spin_dt).total_seconds() + if elapsed < cooldown: + retry_after = max(int(cooldown - elapsed + 0.999), 1) + return JSONResponse( + { + "ok": False, + "error": "cooldown_active", + "detail": f"Please wait {retry_after} seconds before spinning again.", + "retry_after": retry_after, + }, + status_code=429, + ) + + user_lock = redis_client.lock( + f"{WHEEL_USER_LOCK_PREFIX}{email_norm}", timeout=8, blocking=False + ) + if not await user_lock.acquire(blocking=False): + return JSONResponse( + {"ok": False, "error": "spin_in_progress", "detail": "Another spin is already processing."}, + status_code=429, + ) + + global_lock = redis_client.lock(WHEEL_LOCK_KEY, timeout=8, blocking_timeout=4) + payout = 0.0 + pool_after_fee = 0.0 + balance_after_fee = 0.0 + pool_final = 0.0 + balance_final = 0.0 + segment_index = 0 + segment: dict[str, Any] = {} + message = "" + tone = "neutral" + detail: dict[str, float] = {} + try: + try: + acquired = await global_lock.acquire(blocking=True) + except LockError: + acquired = False + if not acquired: + return JSONResponse( + { + "ok": False, + "error": "busy", + "detail": "Casino wheel is busy. Please try again in a moment.", + }, + status_code=429, + ) + + balance_before = await _scorepad_balance(email_norm) + if balance_before + 1e-9 < entry_fee: + return JSONResponse( + { + "ok": False, + "error": "insufficient_funds", + "detail": "Insufficient IGP on your score pad for this spin.", + "scorepad_balance": balance_before, + }, + status_code=400, + ) + + pipe = redis_client.pipeline() + pipe.hincrbyfloat("score_pad", email_norm, -entry_fee) + pipe.hincrbyfloat(WHEEL_CONFIG_KEY, "pool_balance", entry_fee) + results = await pipe.execute() + balance_after_fee = float(results[0] or 0.0) + pool_after_fee = float(results[1] or 0.0) + + try: + segment_index, segment = _choose_wheel_segment(segments) + except ValueError: + revert = redis_client.pipeline() + revert.hincrbyfloat("score_pad", email_norm, entry_fee) + revert.hset(WHEEL_CONFIG_KEY, mapping={"pool_balance": pool_after_fee}) + await revert.execute() + return JSONResponse( + {"ok": False, "error": "invalid_config", "detail": "Wheel segments unavailable."}, + status_code=503, + ) + + payout, message, tone, detail = _wheel_outcome(segment, entry_fee, pool_after_fee) + payout = max(0.0, payout) + pool_final = max(0.0, pool_after_fee - payout) + balance_final = max(0.0, balance_after_fee + payout) + + adjust_pipe = redis_client.pipeline() + if payout > 0: + adjust_pipe.hincrbyfloat("score_pad", email_norm, payout) + adjust_pipe.hset(WHEEL_CONFIG_KEY, mapping={"pool_balance": pool_final}) + await adjust_pipe.execute() + + if pool_final < -1e-6 or balance_final < -1e-6: + revert = redis_client.pipeline() + revert.hincrbyfloat("score_pad", email_norm, entry_fee) + revert.hset(WHEEL_CONFIG_KEY, mapping={"pool_balance": pool_after_fee}) + if payout > 0: + revert.hincrbyfloat("score_pad", email_norm, -payout) + await revert.execute() + return JSONResponse( + { + "ok": False, + "error": "state_conflict", + "detail": "Wheel state changed during your spin. Please try again.", + }, + status_code=409, + ) + + await redis_client.set( + f"{WHEEL_LAST_SPIN_PREFIX}{email_norm}", + now.isoformat(), + ex=max(cooldown, 1) if cooldown else None, + ) + finally: + try: + await global_lock.release() + except LockError: + pass + try: + await user_lock.release() + except LockError: + pass + + history_entry = { + "email": email_norm, + "player": _mask_email(email_norm), + "segment_id": segment.get("id", ""), + "segment_label": segment.get("label", ""), + "message": message, + "tone": tone, + "entry_fee": entry_fee, + "payout": payout, + "pool_balance": pool_final, + "scorepad_balance": balance_final, + "detail": detail, + "created_at": now.isoformat(), + } + await _record_wheel_spin(history_entry) + + history = await _wheel_history(10) + + response_payload = { + "ok": True, + "segment_index": segment_index, + "segment": segment, + "entry_fee": entry_fee, + "payout": payout, + "pool_balance": pool_final, + "scorepad_balance": balance_final, + "tone": tone, + "message": message, + "history": history, + "cooldown_seconds": cooldown, + } + return JSONResponse(response_payload) + + @app.get("/api/leaderboard.json") async def api_leaderboard(request: Request, refresh: bool = Query(False)) -> JSONResponse: email = await _current_email(request) diff --git a/ambassadors/fastapi-ambassador-leaderboard/templates/index.html b/ambassadors/fastapi-ambassador-leaderboard/templates/index.html index be7610773..cfe757997 100644 --- a/ambassadors/fastapi-ambassador-leaderboard/templates/index.html +++ b/ambassadors/fastapi-ambassador-leaderboard/templates/index.html @@ -67,6 +67,299 @@ -webkit-backdrop-filter: blur(8px); box-shadow: 0 20px 50px rgba(0,0,0,.35); } + .gamefi-panel{ + position:relative; + overflow:hidden; + background:linear-gradient(135deg, rgba(31,19,53,0.75), rgba(12,12,26,0.85)); + border:1px solid rgba(255,209,102,0.2); + } + .gamefi-panel::before, + .gamefi-panel::after{ + content:""; + position:absolute; + inset:0; + border-radius:inherit; + pointer-events:none; + } + .gamefi-panel::before{ + background:radial-gradient(circle at 20% 20%, rgba(255,59,78,0.25), transparent 60%), + radial-gradient(circle at 80% 80%, rgba(74,163,255,0.2), transparent 55%); + mix-blend-mode:screen; + } + .gamefi-panel::after{ + border:1px solid rgba(255,255,255,0.08); + box-shadow:inset 0 0 35px rgba(0,0,0,0.55); + } + .gamefi-header{ + position:relative; + z-index:1; + display:flex; + flex-wrap:wrap; + justify-content:space-between; + align-items:center; + gap:1rem; + } + .gamefi-chip{ + width:68px; height:68px; + border-radius:50%; + display:flex; align-items:center; justify-content:center; + background:radial-gradient(circle, #ffd166 40%, #ffae34 70%, rgba(255,209,102,0.2) 90%); + color:#1b1730; + font-weight:700; + letter-spacing:1px; + position:relative; + box-shadow:0 0 25px rgba(255,209,102,0.45), inset 0 0 12px rgba(0,0,0,0.35); + } + .gamefi-chip::after{ + content:"IGP"; + } + .gamefi-grid{ + position:relative; + z-index:1; + } + .game-tile{ + position:relative; + width:100%; + padding:1rem 1.1rem; + border-radius:0.9rem; + background:rgba(8,8,20,0.75); + border:1px solid rgba(255,255,255,0.08); + color:#f5f5f5; + text-align:left; + display:flex; + flex-direction:column; + gap:0.35rem; + transition:all .3s ease; + cursor:pointer; + box-shadow:0 18px 30px rgba(0,0,0,0.35); + } + .game-tile:not(.active){ + opacity:0.8; + } + .game-tile .tile-title{ + font-weight:600; + letter-spacing:0.5px; + } + .game-tile .tile-meta{ + font-size:0.8rem; + text-transform:uppercase; + letter-spacing:1.6px; + color:rgba(255,255,255,0.6); + } + .game-tile .tile-cover{ + position:absolute; + inset:0; + display:flex; + align-items:center; + justify-content:center; + border-radius:inherit; + background:linear-gradient(135deg, rgba(255,59,78,0.4), rgba(74,163,255,0.4)); + color:#0d0d19; + font-weight:700; + letter-spacing:1px; + text-transform:uppercase; + backdrop-filter:blur(6px); + transition:all .35s ease; + box-shadow:inset 0 0 35px rgba(0,0,0,0.45); + } + .game-tile.revealed .tile-cover{ + opacity:0; + transform:scale(0.85); + pointer-events:none; + } + .game-tile.active{ + border-color:rgba(255,209,102,0.75); + box-shadow:0 25px 45px rgba(255,209,102,0.25); + transform:translateY(-3px); + } + .game-tile .tile-icon{ + font-size:1.8rem; + } + .gamefi-stage{ + position:relative; + z-index:1; + padding:1.5rem; + border-radius:1rem; + background:rgba(8,8,18,0.75); + border:1px solid rgba(255,255,255,0.06); + box-shadow:inset 0 0 25px rgba(0,0,0,0.35); + } + .game-content{ + display:none; + } + .game-content.show{ + display:block; + animation:fadeInUp .5s ease; + } + @keyframes fadeInUp{ + from{opacity:0; transform:translateY(10px);} + to{opacity:1; transform:translateY(0);} + } + .igp-wheel-layout{ + display:grid; + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); + gap:1.5rem; + align-items:center; + } + .igp-wheel-stage{ + position:relative; + display:flex; + align-items:center; + justify-content:center; + } + .wheel-pointer{ + position:absolute; + top:-18px; + width:0; height:0; + border-left:16px solid transparent; + border-right:16px solid transparent; + border-bottom:24px solid #ffd166; + filter:drop-shadow(0 4px 6px rgba(0,0,0,0.35)); + z-index:3; + } + .igp-wheel{ + --rotation:0deg; + position:relative; + width:min(280px, 65vw); + aspect-ratio:1 / 1; + border-radius:50%; + background:conic-gradient(from -90deg, + #ffd166 0deg 45deg, + #4aa3ff 45deg 90deg, + #ff3b4e 90deg 135deg, + #32ffb3 135deg 180deg, + #be4bff 180deg 225deg, + #ff6f61 225deg 270deg, + #21d4fd 270deg 315deg, + #ff3b81 315deg 360deg + ); + border:8px solid rgba(255,255,255,0.15); + box-shadow:0 25px 50px rgba(0,0,0,0.45), inset 0 0 35px rgba(0,0,0,0.35); + display:flex; + align-items:center; + justify-content:center; + transition:transform 3.2s cubic-bezier(.22,.61,.36,1); + transform:rotate(var(--rotation)); + } + .igp-wheel::after{ + content:""; + position:absolute; + inset:12%; + border-radius:50%; + border:2px dashed rgba(255,255,255,0.25); + box-shadow:inset 0 0 12px rgba(0,0,0,0.45); + } + .wheel-center{ + position:relative; + z-index:2; + width:42%; + aspect-ratio:1/1; + border-radius:50%; + background:radial-gradient(circle at 30% 30%, #ffd166 0%, #f4a537 60%, #8f4d16 100%); + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + gap:0.35rem; + color:#220a16; + font-weight:700; + letter-spacing:1px; + text-transform:uppercase; + box-shadow:0 0 18px rgba(255,209,102,0.55); + } + .wheel-spin-btn{ + font-size:0.75rem; + font-weight:700; + letter-spacing:1.2px; + border-radius:999px; + box-shadow:0 0 15px rgba(255,209,102,0.45); + } + .wheel-spin-btn:disabled{ + opacity:0.65; + cursor:wait; + box-shadow:none; + } + .wheel-ledger{ + background:rgba(14,14,28,0.75); + border-radius:1rem; + border:1px solid rgba(255,255,255,0.08); + padding:1.25rem; + box-shadow:inset 0 0 30px rgba(0,0,0,0.35); + } + .ledger-stat{ + display:flex; + justify-content:space-between; + align-items:center; + font-size:0.9rem; + padding:0.35rem 0; + border-bottom:1px dashed rgba(255,255,255,0.08); + } + .ledger-stat:last-of-type{ + border-bottom:none; + margin-bottom:0.5rem; + } + .ledger-value{ + font-weight:600; + color:#ffd166; + } + .wheel-result{ + font-size:0.95rem; + font-weight:600; + margin-bottom:0.75rem; + color:#fffbe6; + } + .wheel-history li{ + font-size:0.8rem; + padding:0.35rem 0; + border-bottom:1px solid rgba(255,255,255,0.05); + display:flex; + justify-content:space-between; + gap:1rem; + color:rgba(255,255,255,0.75); + } + .wheel-history li:last-child{ + border-bottom:none; + } + .coming-soon-card{ + position:relative; + overflow:hidden; + padding:1.5rem; + border-radius:1rem; + background:linear-gradient(145deg, rgba(18,18,40,0.9), rgba(34,14,46,0.85)); + border:1px solid rgba(255,255,255,0.08); + box-shadow:0 18px 32px rgba(0,0,0,0.45); + } + .glow-ring{ + position:absolute; + inset:-40% -40% auto; + height:60%; + background:radial-gradient(circle, rgba(255,59,129,0.45), transparent 65%); + mix-blend-mode:screen; + pointer-events:none; + } + .coming-soon-body{ + position:relative; + z-index:1; + } + @media (max-width: 991.98px){ + .igp-wheel-layout{ + grid-template-columns: 1fr; + } + .wheel-pointer{ + top: -12px; + } + .wheel-ledger{ + padding:1rem; + } + } + @media (max-width: 575.98px){ + .gamefi-stage{ + padding:1rem; + } + .igp-wheel{ + width:78vw; + } + } .hero-card{ position:relative; overflow:hidden; @@ -230,6 +523,52 @@
Earn, gamble, and unlock fresh crypto-casino experiences tailored for Interchained ambassadors.
+A dealer-versus-house showdown for whales. Stake stacked IGP bundles for a chance at cascading multipliers and leaderboard glory. Unlockable only after clearing Wheel of Fortune milestones.
+Multiple rounds with rising tension. Admins configure deck scripts, risk curves, and when the house flips into high-alert mode.
+Choose a sealed pod and crack it open with IGP keys. Each pod hides layered loot tables: shard fragments, instant IGP boosts, or rare badges that unlock special multipliers across the arcade.
+Pods refresh hourly. Admins pre-load vault tiers and decide how many winning pods spawn in each wave. Weighted RNG keeps the house edge intact while teasing legendary drops.
+Stake a little, spin the neon wheel, and let the Interchained luck decide your haul. Admins tune the entry fee, reward odds, and the pooled IGP anytime.
+Admins can freeze spins, alter the pool size, and rebalance reward slices directly from the control panel.
+