crack_the_code / app.py
bsant576's picture
Update app.py
547ee4a verified
Raw History Blame Contribute Delete
14 kB
import random
import threading
import time
import gradio as gr
# ZeroGPU Spaces require at least one @spaces.GPU function at startup. This game needs no
# GPU, so this is a do-nothing placeholder. It is skipped when `spaces` isn't installed
# (local runs and regular CPU hardware).
try:
import spaces
@spaces.GPU
def _gpu_placeholder():
return None
except ImportError:
pass
MAX_MOVES = 10
ROOM_TTL_SECONDS = 24 * 60 * 60
# No I, O, 0 or 1 so room codes are easy to read aloud and type on a phone.
ROOM_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
DIGITS = list("123456789")
# ---------- shared room store (one per running server, shared by every visitor) ----------
_LOCK = threading.Lock()
_ROOMS = {} # code -> {"secret": str, "created": float}
def create_room(secret: str) -> str:
with _LOCK:
now = time.time()
for code in [c for c, r in _ROOMS.items() if now - r["created"] > ROOM_TTL_SECONDS]:
del _ROOMS[code]
while True:
code = "".join(random.choices(ROOM_ALPHABET, k=4))
if code not in _ROOMS:
break
_ROOMS[code] = {"secret": secret, "created": now}
return code
def lookup_room(code: str):
with _LOCK:
room = _ROOMS.get(code.strip().upper())
if room and time.time() - room["created"] <= ROOM_TTL_SECONDS:
return room["secret"]
return None
# ---------- game logic ----------
def new_code() -> str:
"""4 unique digits, 1-9 (no zeroes, no repeats)."""
return "".join(random.sample("123456789", 4))
def score(secret: str, guess: str):
"""Return (right_place, right_number). Digits are unique, so a set overlap works."""
right_place = sum(s == g for s, g in zip(secret, guess))
right_number = len(set(secret) & set(guess))
return right_place, right_number
def validate(guess: str, previous=()):
if len(guess) != 4:
return "You do not have the correct character amount (need exactly 4)."
if not guess.isdigit() or "0" in guess:
return "There are no letters or zeroes in the code."
if len(set(guess)) != 4:
return "There are no repeats in the code."
if guess in previous:
return "You already guessed that."
return None
def win_message(move: int) -> str:
if move == 1:
return "Wow, first try, that was really lucky."
if move < 5:
return "Wow, you actually guessed it. Good job!!!"
if move < 8:
return "Cutting it a little close, but good job."
return "Wow, nice guess but almost too late. Good job!"
def hint_message(right_number: int) -> str:
if right_number == 0:
return "Man, that wasn't even close."
if right_number <= 2:
return "You are doing well, nice guess. JK loser. HAHAHAHAHA"
return "You are almost there. Don't let me win. HAHAHAHAHAHA"
# ---------- per-visitor state and rendering ----------
def fresh_state():
return {"mode": "menu", "secret": "", "room_code": "", "history": [], "status": "playing"}
def game_state(mode, secret, room_code=""):
return {"mode": mode, "secret": secret, "room_code": room_code, "history": [], "status": "playing"}
def history_table(history) -> str:
"""Guess history as a Markdown table (fixed layout, so it can't stretch on phones)."""
if not history:
return ""
lines = [
"**Previous guesses**",
"",
"| Move | Guess | Right place | Right number |",
"|:---:|:---:|:---:|:---:|",
]
for i, (g, rp, rn) in enumerate(history):
lines.append(f"| {i + 1} | `{g}` | {rp} | {rn} |")
return "\n".join(lines)
def view(s, msg="", reset_marks=False):
"""Updates for VIEW_OUTPUTS, in order."""
in_game = s["mode"] != "menu"
over = s["status"] != "playing"
used = len(s["history"])
if s["mode"] == "room":
badge = f"Room **{s['room_code']}** Β· a friend set this code"
else:
badge = "Solo game Β· random code"
if over:
header = "### Game over"
else:
header = f"### Move {used + 1} of {MAX_MOVES}\n{MAX_MOVES - used} left"
marks = gr.update(visible=in_game and not over)
if reset_marks:
marks = gr.update(visible=in_game and not over, value=DIGITS)
history_md = history_table(s["history"])
again_label = "πŸ”„ Try this code again" if s["mode"] == "room" else "πŸ”„ New game"
return [
s,
gr.update(visible=not in_game), # menu
gr.update(visible=in_game), # game
badge,
header,
marks,
gr.update(visible=in_game and not over), # guess row
msg,
gr.update(value=history_md, visible=used > 0),
gr.update(visible=over or used > 0, value=again_label),
"", # clear the guess box
]
# ---------- event handlers ----------
def on_start_solo():
return view(game_state("solo", new_code()), reset_marks=True)
def on_create_room(secret_in):
secret_in = (secret_in or "").strip()
error = validate(secret_in)
if error:
return f"**{error}**", ""
code = create_room(secret_in)
return (
f"Room created. Send this room code to your friend:\n\n## `{code}`\n\n"
"They open this app, choose **Join a game**, and enter it. "
"Rooms are cleared after 24 hours or when the app restarts."
), ""
def on_join(room_in):
code = (room_in or "").strip().upper()
if len(code) != 4:
return view(fresh_state()) + ["**Room codes have 4 characters.**", ""]
secret = lookup_room(code)
if secret is None:
return view(fresh_state()) + [
"**Room not found.** Check the code, or ask your friend to set a new one "
"(rooms are cleared when the app restarts).",
"",
]
return view(game_state("room", secret, code), reset_marks=True) + ["", ""]
def on_guess(s, guess_in):
if s["mode"] == "menu" or s["status"] != "playing":
return view(s)
guess = (guess_in or "").strip()
error = validate(guess, [h[0] for h in s["history"]])
if error:
return view(s, f"**{error}**")
right_place, right_number = score(s["secret"], guess)
s = {**s, "history": s["history"] + [(guess, right_place, right_number)]}
used = len(s["history"])
if guess == s["secret"]:
s["status"] = "won"
msg = (
f"πŸŽ‰ **{win_message(used)}**\n\nThe code was **{s['secret']}**. "
f"You cracked it in {used} move(s)."
)
elif used >= MAX_MOVES:
s["status"] = "lost"
msg = f"**Out of moves!** The code was **{s['secret']}**. Better luck next time."
else:
msg = hint_message(right_number)
return view(s, msg)
def on_again(s):
if s["mode"] == "room":
return view(game_state("room", s["secret"], s["room_code"]), reset_marks=True)
return view(game_state("solo", new_code()), reset_marks=True)
def on_menu(s):
return view(fresh_state())
# ---------- UI ----------
# Mobile first: everything is a single narrow column. On wider screens the page grows and the
# game splits into two panes (controls on the left, feedback and history on the right).
CSS = """
.gradio-container {
max-width: 32rem !important;
margin: 0 auto !important;
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
#menu { max-width: 32rem; margin: 0 auto; }
#history table { width: 100%; table-layout: fixed; }
#history th, #history td { text-align: center; word-break: break-word; }
#marks label { min-width: 2.6rem; justify-content: center; }
/* Inputs at 16px+ so iOS Safari does not zoom the page when a field is focused. */
#guess_in input, #guess_in textarea,
#room_in input, #room_in textarea,
#secret_in input, #secret_in textarea {
font-size: 1.25rem !important;
letter-spacing: 0.25em;
text-align: center;
}
#room_in input, #room_in textarea { text-transform: uppercase; }
/* Touch screens: bigger tap targets. */
@media (pointer: coarse) {
button { min-height: 3rem; font-size: 1.05rem !important; }
#marks label { min-width: 3rem; min-height: 3rem; font-size: 1.1rem; }
#marks .wrap { gap: 0.4rem; }
}
/* Small phones: tighter spacing, smaller table text. */
@media (max-width: 480px) {
.gradio-container { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
h1 { font-size: 1.6rem !important; }
#history table { font-size: 0.8rem; }
#history th, #history td { padding: 0.3rem 0.2rem; }
}
/* Laptops and desktops: use the extra width. */
@media (min-width: 900px) {
.gradio-container { max-width: 60rem !important; }
#game_panes { gap: 1.5rem; }
#marks label { min-width: 2.4rem; }
}
"""
# Show the numeric keypad on phones for the digit and room-code fields, and turn off
# autocorrect/autocomplete so the browser does not interfere with guesses.
HEAD = """
<script>
(function () {
const FIELDS = {
guess_in: { inputmode: "numeric", pattern: "[1-9]*" },
secret_in: { inputmode: "numeric", pattern: "[1-9]*" },
room_in: { inputmode: "text", autocapitalize: "characters" },
};
function apply() {
for (const [id, attrs] of Object.entries(FIELDS)) {
const el = document.querySelector("#" + id + " input, #" + id + " textarea");
if (!el) continue;
el.setAttribute("autocomplete", "off");
el.setAttribute("autocorrect", "off");
el.setAttribute("spellcheck", "false");
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
}
}
new MutationObserver(apply).observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener("load", apply);
})();
</script>
"""
with gr.Blocks(title="Crack the Code") as demo:
state = gr.State(fresh_state())
gr.Markdown("# πŸ” Crack the Code")
# ----- menu -----
with gr.Column(visible=True, elem_id="menu") as menu_col:
gr.Markdown(
"Guess a secret 4-digit code in **10 moves**. No repeats and no zeroes. "
"Play alone, or set a code for a friend."
)
with gr.Tabs():
with gr.Tab("Play solo"):
gr.Markdown("I'll make up a random code.")
solo_btn = gr.Button("Start", variant="primary")
with gr.Tab("Set a code"):
gr.Markdown(
"Pick a secret code for a friend to guess. You'll get a room code to send them. "
"They can join from any device."
)
secret_in = gr.Textbox(
label="Your secret code",
type="password",
max_length=4,
placeholder="4 digits, 1-9, no repeats",
elem_id="secret_in",
)
create_btn = gr.Button("Create room", variant="primary")
set_out = gr.Markdown()
with gr.Tab("Join a game"):
gr.Markdown("Enter the room code your friend sent you.")
room_in = gr.Textbox(
label="Room code", max_length=4, placeholder="e.g. KQ7M", elem_id="room_in"
)
join_btn = gr.Button("Join", variant="primary")
join_out = gr.Markdown()
# ----- game -----
with gr.Column(visible=False) as game_col:
badge = gr.Markdown()
gr.Markdown(
"The code has 4 digits: **no repeats and no zeroes**. "
f"Guess it in **{MAX_MOVES} moves** or you lose. "
"Good luck, HAHAHAHAHA!!!"
)
header = gr.Markdown()
# Two panes side by side on wide screens; they stack automatically on narrow ones.
with gr.Row(equal_height=False, elem_id="game_panes"):
with gr.Column(scale=1, min_width=280):
marks = gr.CheckboxGroup(
choices=DIGITS,
value=DIGITS,
label="Still possible",
info="Untick a digit to cross it off. This is only a note for you.",
elem_id="marks",
interactive=True, # outputs-only components default to read-only; this one is a personal notepad
)
with gr.Column(visible=True) as guess_row:
guess_in = gr.Textbox(
label="Your guess",
max_length=4,
placeholder="e.g. 1234",
elem_id="guess_in",
)
guess_btn = gr.Button("Guess", variant="primary")
with gr.Column(scale=1, min_width=280):
msg = gr.Markdown()
history = gr.Markdown(visible=False, elem_id="history")
again_btn = gr.Button("πŸ”„ New game", visible=False)
menu_btn = gr.Button("Back to menu")
with gr.Accordion("How hints work", open=False):
gr.Markdown(
"- **Right place**: digits that are correct *and* in the correct position.\n"
"- **Right number**: digits that are in the code at all (in any position).\n"
"- Right place is always ≀ right number."
)
VIEW_OUTPUTS = [
state, menu_col, game_col, badge, header, marks, guess_row, msg, history, again_btn, guess_in,
]
solo_btn.click(on_start_solo, None, VIEW_OUTPUTS)
create_btn.click(on_create_room, secret_in, [set_out, secret_in])
secret_in.submit(on_create_room, secret_in, [set_out, secret_in])
join_btn.click(on_join, room_in, VIEW_OUTPUTS + [join_out, room_in])
room_in.submit(on_join, room_in, VIEW_OUTPUTS + [join_out, room_in])
guess_btn.click(on_guess, [state, guess_in], VIEW_OUTPUTS)
guess_in.submit(on_guess, [state, guess_in], VIEW_OUTPUTS)
again_btn.click(on_again, state, VIEW_OUTPUTS)
menu_btn.click(on_menu, state, VIEW_OUTPUTS)
if __name__ == "__main__":
demo.launch(css=CSS, head=HEAD)