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 # ========================================== @st.cache_resource 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(""" """, 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"""
๐ŸŒ‰ Bridge

Daily word chain ยท {user_id}

""", 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 = 'โ™ฅ ' * st.session_state.lives + 'โ™ฅ ' * (3 - st.session_state.lives) st.markdown(f"""
Start
{st.session_state.chain[0]}
ยท ยท ยท ยท ยท
Target
{target}
{steps_left}
Steps left
{lives_html}
Lives
""", 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'{word}' 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'{sim_val:.2f}' chain_html += 'โ†’' st.markdown(f'
{chain_html}
', 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'
{msg_text}
', 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'
+{to_display_score(avg_sim + bonus) - to_display_score(avg_sim)} pts speed bonus โ€” {steps_saved} step{"s" if steps_saved > 1 else ""} saved
' st.markdown(f"""
Bridge Complete
{steps_used}/{st.session_state.max_steps}
Steps
{avg_sim:.3f}
Avg Similarity
{display_score}
Score
{bonus_html}
""", 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"""
New personal best! Score saved as {user_id}
""", unsafe_allow_html=True) else: best = load_scores().get(user_id) if best: st.markdown(f"""
Your best today: {best['display_score']} โ€” play again to beat it
""", unsafe_allow_html=True) # โ”€โ”€ Game over screen โ”€โ”€ if st.session_state.game_over and not st.session_state.won: st.markdown("""
Game Over
""", 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"""
Your best today: {best['display_score']} โ€” try again
""", 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!")