Spaces:
Running
Running
| import streamlit as st | |
| import gensim.downloader as api | |
| import random | |
| import json | |
| import hashlib | |
| import math | |
| import os | |
| from datetime import date, datetime | |
| st.set_page_config(page_title="Bridge", page_icon="🌉", layout="wide") | |
| # ========================================== | |
| # Model loading | |
| # ========================================== | |
| def load_model(): | |
| return api.load('word2vec-google-news-300') | |
| model = load_model() | |
| # ========================================== | |
| # Scoring: logit transform (0-1) → (0-+inf) | |
| # ========================================== | |
| def to_display_score(raw): | |
| clamped = max(0.01, min(raw, 0.99)) | |
| return max(0, round(100 * math.log(clamped / (1 - clamped)))) | |
| # ========================================== | |
| # User identification & score persistence | |
| # ========================================== | |
| SCORES_FILE = "/tmp/bridge_scores.json" | |
| SECRET_SEED = "bridge_7f3a9e2b" | |
| def get_user_id(): | |
| if 'user_id' not in st.session_state: | |
| try: | |
| headers = st.context.headers | |
| ip = headers.get("x-forwarded-for", headers.get("x-real-ip", "")) | |
| if ip: | |
| ip = ip.split(",")[0].strip() | |
| raw = f"{SECRET_SEED}:{ip}" | |
| h = hashlib.sha256(raw.encode()).hexdigest()[:6] | |
| st.session_state.user_id = f"P-{h.upper()}" | |
| else: | |
| st.session_state.user_id = f"P-{random.randint(100000, 999999)}" | |
| except Exception: | |
| st.session_state.user_id = f"P-{random.randint(100000, 999999)}" | |
| return st.session_state.user_id | |
| def today_str(): | |
| return date.today().strftime('%Y-%m-%d') | |
| def load_scores(): | |
| if os.path.exists(SCORES_FILE): | |
| try: | |
| with open(SCORES_FILE, "r") as f: | |
| data = json.load(f) | |
| if data.get("_day") != today_str(): | |
| return {"_day": today_str()} | |
| return data | |
| except Exception: | |
| return {"_day": today_str()} | |
| return {"_day": today_str()} | |
| def save_score(user_id, raw_score, display_score, steps, chain, start, target): | |
| scores = load_scores() | |
| is_new_best = False | |
| existing = scores.get(user_id) | |
| if existing is None or display_score > existing["display_score"]: | |
| scores[user_id] = { | |
| "raw_score": round(raw_score, 4), | |
| "display_score": display_score, | |
| "steps": steps, | |
| "chain": chain, | |
| "start": start, | |
| "target": target, | |
| "time": datetime.now().strftime("%H:%M"), | |
| } | |
| scores["_day"] = today_str() | |
| with open(SCORES_FILE, "w") as f: | |
| json.dump(scores, f) | |
| is_new_best = True | |
| return is_new_best | |
| def get_leaderboard(limit=10): | |
| scores = load_scores() | |
| entries = {k: v for k, v in scores.items() if not k.startswith("_")} | |
| ranked = sorted(entries.items(), key=lambda x: x[1]["display_score"], reverse=True) | |
| return ranked[:limit] | |
| # ========================================== | |
| # Word list & pair generation | |
| # ========================================== | |
| COMMON_WORDS = [ | |
| 'ocean', 'mountain', 'guitar', 'hospital', 'diamond', 'library', | |
| 'kitchen', 'soldier', 'painting', 'engine', 'garden', 'prison', | |
| 'camera', 'forest', 'bridge', 'castle', 'planet', 'dragon', | |
| 'market', 'island', 'thunder', 'silver', 'robot', 'circus', | |
| 'desert', 'rocket', 'mirror', 'shadow', 'candle', 'sword', | |
| 'winter', 'honey', 'storm', 'angel', 'pirate', 'jungle', | |
| 'volcano', 'trophy', 'ghost', 'crown', 'eagle', 'flame', | |
| 'harvest', 'crystal', 'anchor', 'bullet', 'compass', 'lantern' | |
| ] | |
| def daily_pair(): | |
| seed = int(date.today().strftime('%Y%m%d')) | |
| rng = random.Random(seed) | |
| while True: | |
| start, target = rng.sample(COMMON_WORDS, 2) | |
| if start in model and target in model: | |
| sim = model.similarity(start, target) | |
| if sim < 0.20: | |
| return start, target | |
| def get_proximity(sim_to_target): | |
| if sim_to_target >= 0.50: | |
| return "very close" | |
| elif sim_to_target >= 0.35: | |
| return "close" | |
| elif sim_to_target >= 0.25: | |
| return "getting closer" | |
| elif sim_to_target >= 0.15: | |
| return "far" | |
| else: | |
| return "very far" | |
| # ========================================== | |
| # Session state | |
| # ========================================== | |
| def init_game(start, target): | |
| st.session_state.chain = [start] | |
| st.session_state.target = target | |
| st.session_state.similarities = [] | |
| st.session_state.sim_to_target = [] | |
| st.session_state.lives = 3 | |
| st.session_state.game_over = False | |
| st.session_state.won = False | |
| st.session_state.max_steps = 5 | |
| st.session_state.message = None | |
| st.session_state.result_saved = False | |
| st.session_state.is_new_best = False | |
| if 'chain' not in st.session_state: | |
| st.session_state.history = [] | |
| start, target = daily_pair() | |
| init_game(start, target) | |
| user_id = get_user_id() | |
| # ========================================== | |
| # CSS | |
| # ========================================== | |
| st.markdown(""" | |
| <style> | |
| #MainMenu, footer, header { visibility: hidden; } | |
| @keyframes shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 20% { transform: translateX(-8px); } | |
| 40% { transform: translateX(8px); } | |
| 60% { transform: translateX(-5px); } | |
| 80% { transform: translateX(5px); } | |
| } | |
| @keyframes glow { | |
| 0% { box-shadow: 0 0 5px #6366f1; } | |
| 50% { box-shadow: 0 0 30px #6366f1, 0 0 60px #818cf8; } | |
| 100% { box-shadow: 0 0 5px #6366f1; } | |
| } | |
| @keyframes pop { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.08); } | |
| 100% { transform: scale(1); } | |
| } | |
| @keyframes fadeSlideIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .shake { animation: shake 0.4s ease-in-out; } | |
| .glow { animation: glow 1.2s ease-in-out 2; border-radius: 12px; } | |
| .pop { animation: pop 0.3s ease-out; } | |
| .game-board { | |
| background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(168,85,247,0.08)); | |
| border: 1px solid rgba(99,102,241,0.2); | |
| border-radius: 16px; | |
| padding: 24px; | |
| margin: 16px 0; | |
| } | |
| .endpoints { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 32px; | |
| margin-bottom: 20px; | |
| } | |
| .endpoint { text-align: center; } | |
| .endpoint-label { | |
| font-size: 0.7rem; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| color: #94a3b8; | |
| margin-bottom: 4px; | |
| } | |
| .endpoint-word { | |
| font-size: 1.8rem; | |
| font-weight: 800; | |
| letter-spacing: -0.5px; | |
| } | |
| .endpoint-start { color: #818cf8; } | |
| .endpoint-target { color: #f472b6; } | |
| .endpoint-dots { | |
| color: #475569; | |
| font-size: 1.5rem; | |
| letter-spacing: 4px; | |
| } | |
| .chain-container { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 4px; | |
| margin: 16px 0; | |
| } | |
| .chain-word { | |
| display: inline-flex; | |
| align-items: center; | |
| padding: 8px 16px; | |
| border-radius: 24px; | |
| font-weight: 700; | |
| font-size: 0.95rem; | |
| animation: fadeSlideIn 0.3s ease-out; | |
| letter-spacing: 0.3px; | |
| } | |
| .chain-start { | |
| background: linear-gradient(135deg, #6366f1, #818cf8); | |
| color: white; | |
| box-shadow: 0 2px 8px rgba(99,102,241,0.3); | |
| } | |
| .chain-step { | |
| background: linear-gradient(135deg, #334155, #475569); | |
| color: #e2e8f0; | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.2); | |
| } | |
| .chain-target { | |
| background: linear-gradient(135deg, #ec4899, #f472b6); | |
| color: white; | |
| box-shadow: 0 2px 8px rgba(236,72,153,0.3); | |
| } | |
| .chain-arrow { | |
| color: #64748b; | |
| font-size: 1rem; | |
| margin: 0 2px; | |
| } | |
| .chain-sim { | |
| font-size: 0.7rem; | |
| color: #94a3b8; | |
| margin: 0 2px; | |
| } | |
| .stats-bar { | |
| display: flex; | |
| justify-content: center; | |
| gap: 32px; | |
| margin: 12px 0 4px 0; | |
| } | |
| .stat { text-align: center; } | |
| .stat-label { | |
| font-size: 0.7rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: #94a3b8; | |
| } | |
| .stat-value { | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| color: #e2e8f0; | |
| } | |
| .lives-full { color: #f472b6; } | |
| .lives-empty { color: #334155; } | |
| .msg { | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| text-align: center; | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| margin: 8px 0; | |
| } | |
| .msg-error { | |
| background: rgba(239,68,68,0.12); | |
| color: #fca5a5; | |
| border: 1px solid rgba(239,68,68,0.25); | |
| } | |
| .msg-great { | |
| background: rgba(99,102,241,0.12); | |
| color: #a5b4fc; | |
| border: 1px solid rgba(99,102,241,0.25); | |
| } | |
| .msg-good { | |
| background: rgba(34,197,94,0.1); | |
| color: #86efac; | |
| border: 1px solid rgba(34,197,94,0.2); | |
| } | |
| .result-box { | |
| text-align: center; | |
| padding: 24px; | |
| border-radius: 16px; | |
| margin: 16px 0; | |
| } | |
| .result-win { | |
| background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(236,72,153,0.15)); | |
| border: 1px solid rgba(99,102,241,0.3); | |
| } | |
| .result-lose { | |
| background: rgba(239,68,68,0.1); | |
| border: 1px solid rgba(239,68,68,0.2); | |
| } | |
| .result-title { | |
| font-size: 1.8rem; | |
| font-weight: 800; | |
| margin-bottom: 12px; | |
| } | |
| .result-stats { | |
| display: flex; | |
| justify-content: center; | |
| gap: 40px; | |
| margin-top: 12px; | |
| } | |
| .result-stat-value { | |
| font-size: 1.6rem; | |
| font-weight: 800; | |
| color: #818cf8; | |
| } | |
| .result-stat-label { | |
| font-size: 0.75rem; | |
| color: #94a3b8; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .speed-bonus { | |
| display: inline-block; | |
| margin-top: 12px; | |
| padding: 6px 16px; | |
| border-radius: 20px; | |
| background: linear-gradient(135deg, #6366f1, #ec4899); | |
| color: white; | |
| font-weight: 700; | |
| font-size: 0.9rem; | |
| } | |
| .stTextInput > div > div > input { | |
| border-radius: 12px !important; | |
| border: 2px solid rgba(99,102,241,0.3) !important; | |
| padding: 12px 16px !important; | |
| font-size: 1rem !important; | |
| transition: border-color 0.2s !important; | |
| } | |
| .stTextInput > div > div > input:focus { | |
| border-color: #6366f1 !important; | |
| box-shadow: 0 0 0 3px rgba(99,102,241,0.15) !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ========================================== | |
| # Layout: game (left) + leaderboard (right) | |
| # ========================================== | |
| game_col, lb_col = st.columns([3, 1], gap="large") | |
| with game_col: | |
| # ── Header ── | |
| st.markdown(f""" | |
| <div style="text-align:center; margin-bottom: 4px;"> | |
| <span style="font-size: 2.5rem;">🌉</span> | |
| <span style="font-size: 2.2rem; font-weight: 800; letter-spacing: -1px; | |
| background: linear-gradient(135deg, #818cf8, #f472b6); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent;"> | |
| Bridge | |
| </span> | |
| </div> | |
| <p style="text-align:center; color:#64748b; margin-top:0; font-size:0.9rem;"> | |
| Daily word chain · <span style="color:#818cf8; font-weight:600;">{user_id}</span> | |
| </p> | |
| """, unsafe_allow_html=True) | |
| # ── Game board ── | |
| target = st.session_state.target | |
| steps_used = len(st.session_state.chain) - 1 | |
| steps_left = st.session_state.max_steps - steps_used | |
| lives_html = '<span class="lives-full">♥</span> ' * st.session_state.lives + '<span class="lives-empty">♥</span> ' * (3 - st.session_state.lives) | |
| st.markdown(f""" | |
| <div class="game-board"> | |
| <div class="endpoints"> | |
| <div class="endpoint"> | |
| <div class="endpoint-label">Start</div> | |
| <div class="endpoint-word endpoint-start">{st.session_state.chain[0]}</div> | |
| </div> | |
| <div class="endpoint-dots">· · · · ·</div> | |
| <div class="endpoint"> | |
| <div class="endpoint-label">Target</div> | |
| <div class="endpoint-word endpoint-target">{target}</div> | |
| </div> | |
| </div> | |
| <div class="stats-bar"> | |
| <div class="stat"> | |
| <div class="stat-value">{steps_left}</div> | |
| <div class="stat-label">Steps left</div> | |
| </div> | |
| <div class="stat"> | |
| <div class="stat-value">{lives_html}</div> | |
| <div class="stat-label">Lives</div> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ── Chain display ── | |
| if len(st.session_state.chain) > 1 or st.session_state.game_over or st.session_state.won: | |
| chain_html = "" | |
| for i, word in enumerate(st.session_state.chain): | |
| if i == 0: | |
| css = "chain-start" | |
| elif word == target: | |
| css = "chain-target" | |
| else: | |
| css = "chain-step" | |
| chain_html += f'<span class="chain-word {css}">{word}</span>' | |
| if i < len(st.session_state.chain) - 1: | |
| sim_val = st.session_state.similarities[i] if i < len(st.session_state.similarities) else 0 | |
| chain_html += f'<span class="chain-sim">{sim_val:.2f}</span>' | |
| chain_html += '<span class="chain-arrow">→</span>' | |
| st.markdown(f'<div class="chain-container">{chain_html}</div>', unsafe_allow_html=True) | |
| # ── Message ── | |
| if st.session_state.message: | |
| msg_type, msg_text = st.session_state.message | |
| css_map = {"error": "msg-error shake", "great": "msg-great glow", "good": "msg-good pop"} | |
| css = css_map.get(msg_type, "msg-good") | |
| st.markdown(f'<div class="msg {css}">{msg_text}</div>', unsafe_allow_html=True) | |
| st.session_state.message = None | |
| # ── Game logic ── | |
| min_sim = 0.15 | |
| def submit_guess(): | |
| guess = st.session_state.guess_input.strip().lower() | |
| st.session_state.guess_input = "" | |
| target = st.session_state.target | |
| if not guess: | |
| return | |
| if guess in st.session_state.chain: | |
| st.session_state.message = ("error", "Already used that word") | |
| return | |
| if guess not in model: | |
| st.session_state.message = ("error", "Word not in vocabulary") | |
| return | |
| prev = st.session_state.chain[-1] | |
| sim = float(model.similarity(prev, guess)) | |
| sim_target = float(model.similarity(guess, target)) | |
| proximity = get_proximity(sim_target) | |
| if sim < min_sim: | |
| st.session_state.lives -= 1 | |
| if st.session_state.lives == 0: | |
| st.session_state.game_over = True | |
| st.session_state.message = ("error", f"{prev} → {guess}: similarity {sim:.3f} — too weak, no lives left") | |
| else: | |
| st.session_state.message = ("error", f"{prev} → {guess}: similarity {sim:.3f} — too weak (min {min_sim})") | |
| return | |
| st.session_state.chain.append(guess) | |
| st.session_state.similarities.append(sim) | |
| st.session_state.sim_to_target.append(sim_target) | |
| if sim >= 0.45: | |
| st.session_state.message = ("great", f"{prev} → {guess}: {sim:.3f} — strong link, {proximity} to target") | |
| elif sim >= 0.30: | |
| st.session_state.message = ("good", f"{prev} → {guess}: {sim:.3f} — good link, {proximity} to target") | |
| else: | |
| st.session_state.message = ("good", f"{prev} → {guess}: {sim:.3f} — weak link, {proximity} to target") | |
| if guess == target: | |
| st.session_state.won = True | |
| elif len(st.session_state.chain) - 1 >= st.session_state.max_steps: | |
| st.session_state.game_over = True | |
| st.session_state.message = ("error", f"Didn't reach '{target}' in {st.session_state.max_steps} steps") | |
| if not st.session_state.game_over and not st.session_state.won: | |
| st.text_input("guess", key="guess_input", placeholder="Type a word and press Enter...", on_change=submit_guess, label_visibility="collapsed") | |
| # ── Win screen ── | |
| if st.session_state.won: | |
| steps_used = len(st.session_state.chain) - 1 | |
| steps_saved = st.session_state.max_steps - steps_used | |
| avg_sim = sum(st.session_state.similarities) / len(st.session_state.similarities) | |
| bonus = 0.15 * steps_saved | |
| raw_score = avg_sim + bonus | |
| display_score = to_display_score(raw_score) | |
| bonus_html = "" | |
| if steps_saved > 0: | |
| bonus_html = f'<div class="speed-bonus">+{to_display_score(avg_sim + bonus) - to_display_score(avg_sim)} pts speed bonus — {steps_saved} step{"s" if steps_saved > 1 else ""} saved</div>' | |
| st.markdown(f""" | |
| <div class="result-box result-win"> | |
| <div class="result-title">Bridge Complete</div> | |
| <div class="result-stats"> | |
| <div> | |
| <div class="result-stat-value">{steps_used}/{st.session_state.max_steps}</div> | |
| <div class="result-stat-label">Steps</div> | |
| </div> | |
| <div> | |
| <div class="result-stat-value">{avg_sim:.3f}</div> | |
| <div class="result-stat-label">Avg Similarity</div> | |
| </div> | |
| <div> | |
| <div class="result-stat-value">{display_score}</div> | |
| <div class="result-stat-label">Score</div> | |
| </div> | |
| </div> | |
| {bonus_html} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if not st.session_state.result_saved: | |
| st.session_state.history.append({ | |
| 'start': st.session_state.chain[0], | |
| 'target': target, | |
| 'chain': list(st.session_state.chain), | |
| 'display_score': display_score, | |
| 'steps': steps_used | |
| }) | |
| is_new_best = save_score( | |
| user_id, raw_score, display_score, steps_used, | |
| list(st.session_state.chain), | |
| st.session_state.chain[0], target | |
| ) | |
| st.session_state.is_new_best = is_new_best | |
| st.session_state.result_saved = True | |
| if st.session_state.get('is_new_best'): | |
| st.markdown(f""" | |
| <div class="msg msg-great glow" style="margin-top:8px;"> | |
| New personal best! Score saved as <strong>{user_id}</strong> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| best = load_scores().get(user_id) | |
| if best: | |
| st.markdown(f""" | |
| <div style="text-align:center; margin-top:8px; font-size:0.85rem; color:#94a3b8;"> | |
| Your best today: <strong style="color:#818cf8;">{best['display_score']}</strong> — play again to beat it | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ── Game over screen ── | |
| if st.session_state.game_over and not st.session_state.won: | |
| st.markdown(""" | |
| <div class="result-box result-lose"> | |
| <div class="result-title">Game Over</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if not st.session_state.result_saved: | |
| st.session_state.history.append({ | |
| 'start': st.session_state.chain[0], | |
| 'target': target, | |
| 'chain': list(st.session_state.chain), | |
| 'display_score': 0, | |
| 'steps': len(st.session_state.chain) - 1 | |
| }) | |
| st.session_state.result_saved = True | |
| best = load_scores().get(user_id) | |
| if best: | |
| st.markdown(f""" | |
| <div style="text-align:center; margin-top:8px; font-size:0.85rem; color:#94a3b8;"> | |
| Your best today: <strong style="color:#818cf8;">{best['display_score']}</strong> — try again | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ── Play again ── | |
| if st.session_state.game_over or st.session_state.won: | |
| if st.button("Play again"): | |
| start, target = daily_pair() | |
| init_game(start, target) | |
| st.rerun() | |
| # ── Past rounds ── | |
| if st.session_state.history: | |
| with st.expander(f"Past rounds ({len(st.session_state.history)})", expanded=False): | |
| for h in reversed(st.session_state.history): | |
| chain_str = " → ".join(h['chain']) | |
| result = "won" if h['display_score'] > 0 else "lost" | |
| st.markdown(f"**{h['start']} → {h['target']}**: {chain_str} — {result}, score {h['display_score']} ({h['steps']} steps)") | |
| # ========================================== | |
| # Leaderboard (right column, always visible) | |
| # ========================================== | |
| with lb_col: | |
| st.markdown(f"**Leaderboard** · {date.today().strftime('%d %b %Y')}") | |
| st.markdown("---") | |
| leaderboard = get_leaderboard(10) | |
| if leaderboard: | |
| for i, (uid, data) in enumerate(leaderboard): | |
| rank = i + 1 | |
| if uid == user_id: | |
| st.markdown(f"**#{rank} {uid} (you) · {data['display_score']} pts**") | |
| else: | |
| st.markdown(f"#{rank} {uid} · {data['display_score']} pts") | |
| else: | |
| st.caption("No scores yet — be the first!") | |