x / src /streamlit_app.py
valent-p's picture
Debug for show hint
0dd24d3
Raw History Blame Contribute Delete
48.4 kB
import streamlit as st
import random
import time
from datetime import datetime
import pandas as pd
# import numpy as np # Not explicitly used, can be removed if not needed for other things
import altair as alt
# Set page configuration (should be the first Streamlit command)
st.set_page_config(
page_title="Number Master Challenge",
page_icon="🎯",
layout="centered",
initial_sidebar_state="expanded"
)
# Apply custom CSS for better styling
st.markdown("""
<style>
/* --- CSS Variables --- */
:root {
--primary-color: #00aaff;
--primary-color-dark: #0088cc;
--primary-color-darker: #006699;
--primary-color-light: #cceeff;
--primary-color-lighter: #e6f7ff;
--primary-color-lightest: #f0faff;
--text-color: #212529;
--text-color-light: #495057;
--text-color-lighter: #6c757d;
--text-on-primary: #ffffff;
--bg-color: #f8fcff;
--bg-color-alt: #ffffff;
--border-color: #dee2e6;
--border-color-light: #e9ecef;
--border-focus-color: var(--primary-color);
--success-color: #28a745;
--warning-color: #ffc107;
--error-color: #dc3545;
--info-color: #17a2b8;
--border-radius: 8px;
--border-radius-sm: 4px;
--border-radius-lg: 12px;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
--shadow: 0 4px 8px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.07);
--shadow-primary: 0 4px 12px rgba(0, 170, 255, 0.2);
--shadow-primary-inset: inset 0 0 0 2px var(--primary-color); /* For focus */
--font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
/* --- Global Styles --- */
body {
font-family: var(--font-family-sans-serif);
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
font-size: 16px; /* Base font size */
}
/* Main app container (Streamlit >= 1.13.0) */
.main .block-container {
padding-top: 2rem;
padding-bottom: 2rem;
padding-left: 2rem;
padding-right: 2rem;
}
/* Older Streamlit might use this */
.main > div:first-child {
padding: 2rem;
}
/* Remove Streamlit's default top padding if header is hidden */
.main[data-testid="stAppViewContainer"] > section:first-child {
/* padding-top: 0 !important; if you hide the header via config */
}
/* --- Typography --- */
h1, .stHeading h1, .game-title { /* For st.title, and your custom class */
font-size: 2.8rem;
font-weight: 700;
color: var(--primary-color-darker);
margin-bottom: 1rem;
line-height: 1.2;
}
h2, .stHeading h2 { /* For st.header */
font-size: 2.2rem;
font-weight: 600;
color: var(--primary-color-dark);
margin-top: 2rem;
margin-bottom: 0.75rem;
}
h3, .stHeading h3 { /* For st.subheader */
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
p, .stMarkdown p, .stText { /* For st.markdown, st.write */
margin-bottom: 1rem;
color: var(--text-color-light);
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--primary-color-dark);
text-decoration: underline;
}
.stCodeBlock, code {
background-color: var(--primary-color-lighter) !important;
border-radius: var(--border-radius-sm);
padding: 0.2em 0.4em;
font-size: 0.9em;
border: 1px solid var(--primary-color-light);
}
pre code {
padding: 1em;
display: block;
overflow-x: auto;
}
.stCaption {
color: var(--text-color-lighter);
font-size: 0.85em;
}
/* --- Buttons (st.button, st.download_button) --- */
.stButton > button, .stDownloadButton > button {
background-color: var(--primary-color);
color: var(--text-on-primary);
border: none;
padding: 0.65rem 1.25rem;
border-radius: var(--border-radius);
font-weight: 600;
font-size: 0.95rem;
box-shadow: var(--shadow-sm);
transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease;
cursor: pointer;
}
.stButton > button:hover, .stDownloadButton > button:hover {
background-color: var(--primary-color-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-primary);
}
.stButton > button:active, .stDownloadButton > button:active {
background-color: var(--primary-color-darker);
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.stButton > button:disabled, .stDownloadButton > button:disabled {
background-color: var(--border-color-light);
color: var(--text-color-lighter);
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.stButton > button:focus, .stDownloadButton > button:focus {
outline: none;
box-shadow: var(--shadow-primary-inset), var(--shadow-primary);
}
/* --- Input Widgets (Text, Number, Text Area) --- */
.stTextInput > div > div > input,
.stNumberInput > div > div > input,
.stTextArea > div > textarea {
background-color: var(--bg-color-alt);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0.6rem 0.8rem;
color: var(--text-color);
font-size: 0.95rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.stTextInput > div > div > input:focus,
.stNumberInput > div > div > input:focus,
.stTextArea > div > textarea:focus {
border-color: var(--border-focus-color);
box-shadow: var(--shadow-primary-inset);
outline: none;
}
.stTextInput > div > div > input::placeholder,
.stNumberInput > div > div > input::placeholder,
.stTextArea > div > textarea::placeholder {
color: var(--text-color-lighter);
opacity: 0.7;
}
.stTextArea > div > textarea {
min-height: 80px;
}
/* Number input buttons */
.stNumberInput button {
border: 1px solid var(--border-color);
background-color: var(--bg-color-alt);
color: var(--text-color-light);
transition: background-color 0.2s ease, color 0.2s ease;
}
.stNumberInput button:hover {
background-color: var(--primary-color-lighter);
color: var(--primary-color-dark);
}
.stNumberInput button:active {
background-color: var(--primary-color-light);
}
/* --- Slider (st.slider, st.select_slider) --- */
.stSlider [data-baseweb="slider"] {
padding-top: 10px; /* Space for thumb */
padding-bottom: 10px;
}
.stSlider [data-baseweb="slider"] > div:nth-child(2) { /* Track */
background-color: var(--primary-color-light);
height: 6px !important;
}
.stSlider [data-baseweb="slider"] > div:nth-child(3) { /* Filled part of track */
background-color: var(--primary-color);
height: 6px !important;
}
.stSlider [data-baseweb="slider"] > div[role="slider"] { /* Thumb */
background-color: var(--primary-color);
border: 2px solid var(--bg-color-alt);
box-shadow: var(--shadow-sm);
width: 20px !important;
height: 20px !important;
margin-top: -7px; /* Adjust to center on track */
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stSlider [data-baseweb="slider"] > div[role="slider"]:hover {
transform: scale(1.1);
box-shadow: var(--shadow-primary);
}
.stSlider [data-baseweb="slider"] > div[role="slider"]:focus {
box-shadow: var(--shadow-primary-inset), var(--shadow-primary);
}
/* Select Slider specific - may need more specific selectors for values */
.stSelectSlider .stTickBar > div { /* Tick labels */
color: var(--text-color-lighter);
}
/* --- Selectbox (st.selectbox) & Multiselect (st.multiselect) --- */
.stSelectbox > div > div[data-baseweb="select"],
.stMultiSelect > div > div[data-baseweb="multi-select"] {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--bg-color-alt);
font-size: 0.95rem;
color: var(--text-color);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.stSelectbox > div > div[data-baseweb="select"]:focus-within,
.stMultiSelect > div > div[data-baseweb="multi-select"]:focus-within {
border-color: var(--border-focus-color);
box-shadow: var(--shadow-primary-inset);
}
/* Dropdown arrow */
.stSelectbox [data-baseweb="select"] [data-testid="stIcon"],
.stMultiSelect [data-baseweb="multi-select"] [data-testid="stIcon"] {
color: var(--text-color-light);
}
/* Selected value in multiselect */
.stMultiSelect span[data-baseweb="tag"] {
background-color: var(--primary-color-light) !important;
color: var(--primary-color-darker) !important;
border-radius: var(--border-radius-sm) !important;
}
.stMultiSelect span[data-baseweb="tag"] > span[role="button"] { /* The 'x' to remove */
color: var(--primary-color-darker) !important;
}
/* Dropdown menu list styling */
[data-baseweb="popover"] [data-baseweb="menu"] {
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color-light);
background-color: var(--bg-color-alt);
}
[data-baseweb="popover"] [data-baseweb="menu"] li {
color: var(--text-color);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
transition: background-color 0.15s ease;
}
[data-baseweb="popover"] [data-baseweb="menu"] li:hover {
background-color: var(--primary-color-lighter);
}
[data-baseweb="popover"] [data-baseweb="menu"] li[aria-selected="true"] {
background-color: var(--primary-color-light);
color: var(--primary-color-darker);
font-weight: 600;
}
/* --- Radio (st.radio) & Checkbox (st.checkbox) & Toggle (st.toggle) --- */
.stRadio [data-baseweb="radio"] label,
.stCheckbox [data-baseweb="checkbox"] label,
.stToggle [data-baseweb="checkbox"] label /* Toggle uses checkbox base */
{
color: var(--text-color-light);
align-items: center; /* Vertically align text with custom control */
}
/* Customizing the radio circle / checkbox square / toggle switch */
.stRadio [data-baseweb="radio"] > div:first-child > div, /* Radio circle */
.stCheckbox [data-baseweb="checkbox"] > div:first-child > div /* Checkbox square */
{
border-color: var(--border-color) !important;
background-color: var(--bg-color-alt) !important;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.stRadio [data-baseweb="radio"] > div:first-child > div:hover,
.stCheckbox [data-baseweb="checkbox"] > div:first-child > div:hover {
border-color: var(--primary-color-dark) !important;
}
/* Checked state */
.stRadio input:checked + div > div,
.stCheckbox input:checked + div > div {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
/* Radio inner dot / Checkbox checkmark */
.stRadio input:checked + div > div > div { /* Radio inner dot */
background-color: var(--text-on-primary) !important;
}
.stCheckbox input:checked + div > div > svg { /* Checkbox checkmark */
fill: var(--text-on-primary) !important;
}
/* Toggle Switch */
.stToggle [data-baseweb="checkbox"] > div:first-child { /* The toggle track */
background-color: var(--border-color-light) !important;
border-radius: 12px !important; /* Pill shape */
width: 44px !important;
height: 24px !important;
}
.stToggle input:checked + div > div:first-child { /* Track when checked */
background-color: var(--primary-color) !important;
}
.stToggle [data-baseweb="checkbox"] > div:first-child > div { /* The toggle knob */
background-color: var(--bg-color-alt) !important;
box-shadow: var(--shadow-sm);
border: none !important;
width: 20px !important;
height: 20px !important;
top: 2px !important; /* Position knob within track */
left: 2px !important;
transition: transform 0.2s ease-in-out;
}
.stToggle input:checked + div > div:first-child > div { /* Knob when checked */
transform: translateX(20px) !important;
background-color: var(--text-on-primary) !important;
}
/* --- Date & Time Inputs (st.date_input, st.time_input) --- */
.stDateInput input, .stTimeInput input {
background-color: var(--bg-color-alt);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0.6rem 0.8rem;
color: var(--text-color);
font-size: 0.95rem;
}
.stDateInput input:focus, .stTimeInput input:focus {
border-color: var(--border-focus-color);
box-shadow: var(--shadow-primary-inset);
outline: none;
}
/* Calendar/Time popover - usually styled by baseweb, some overrides */
[data-baseweb="popover"] .DayPicker_MonthHeader,
[data-baseweb="popover"] .CalendarMonth_caption {
background-color: var(--primary-color-lighter);
color: var(--primary-color-darker);
padding: 0.5rem;
}
[data-baseweb="popover"] .DayPicker_Day--selected {
background-color: var(--primary-color) !important;
color: var(--text-on-primary) !important;
}
[data-baseweb="popover"] .DayPicker_Day:hover:not(.DayPicker_Day--selected) {
background-color: var(--primary-color-lighter) !important;
}
/* --- File Uploader (st.file_uploader) --- */
.stFileUploader {
border: 2px dashed var(--primary-color-light);
background-color: var(--primary-color-lightest);
border-radius: var(--border-radius-lg);
padding: 2rem;
text-align: center;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.stFileUploader:hover {
border-color: var(--primary-color);
background-color: var(--primary-color-lighter);
}
.stFileUploader button { /* The "Browse files" button inside */
background-color: var(--primary-color);
color: var(--text-on-primary);
border: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
font-weight: 500;
}
.stFileUploader button:hover {
background-color: var(--primary-color-dark);
}
.stFileUploader [data-testid="stFileUploadDropzoneInstructions"] {
color: var(--primary-color-dark);
}
/* Uploaded file item */
.stFileUploader [data-testid="stFileUploaderFile"] {
background-color: var(--bg-color-alt);
border: 1px solid var(--border-color-light);
border-radius: var(--border-radius-sm);
padding: 0.5rem;
}
/* --- Color Picker (st.color_picker) --- */
.stColorPicker button {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.stColorPicker button:hover {
border-color: var(--primary-color-dark);
}
.stColorPicker button:focus-within { /* When picker is open or button focused */
border-color: var(--border-focus-color);
box-shadow: var(--shadow-primary-inset);
}
/* The popover part of the color picker is harder to style without very specific selectors or JS */
/* --- Progress Bar (st.progress) --- */
.stProgress > div > div > div > div {
background-color: var(--primary-color);
border-radius: var(--border-radius-sm);
}
.stProgress > div > div { /* The track */
background-color: var(--primary-color-light);
border-radius: var(--border-radius-sm);
}
/* --- Status Messages (st.error, st.warning, st.info, st.success, st.exception) --- */
.stAlert {
border-radius: var(--border-radius);
border-width: 0px; /* Remove default baseweb border */
border-left-width: 5px !important; /* Prominent left border */
padding: 1rem 1.25rem;
box-shadow: var(--shadow);
font-size: 0.95rem;
}
.stAlert strong { /* Make title bolder */
font-weight: 600;
}
.stAlert [data-testid="stNotificationContentSuccess"] {
border-left-color: var(--success-color) !important;
background-color: #e9f7ef; /* Light success green */
color: #196835;
}
.stAlert [data-testid="stNotificationContentInfo"] {
border-left-color: var(--info-color) !important;
background-color: #e8f6f8; /* Light info blue */
color: #0c5460;
}
.stAlert [data-testid="stNotificationContentWarning"] {
border-left-color: var(--warning-color) !important;
background-color: #fff8e6; /* Light warning orange */
color: #856404;
}
.stAlert [data-testid="stNotificationContentError"] {
border-left-color: var(--error-color) !important;
background-color: #fbe9eb; /* Light error red */
color: #721c24;
}
/* Icon colors */
.stAlert [data-testid="stNotificationContentSuccess"] svg { fill: var(--success-color); }
.stAlert [data-testid="stNotificationContentInfo"] svg { fill: var(--info-color); }
.stAlert [data-testid="stNotificationContentWarning"] svg { fill: var(--warning-color); }
.stAlert [data-testid="stNotificationContentError"] svg { fill: var(--error-color); }
/* --- Expander (st.expander) --- */
.stExpander {
border: 1px solid var(--border-color-light);
border-radius: var(--border-radius-lg); /* Larger radius for container feel */
box-shadow: var(--shadow-sm);
background-color: var(--bg-color-alt); /* Give it a distinct background */
overflow: hidden; /* Ensure border-radius clips content */
}
.stExpander header {
background-color: transparent; /* Default bg is white, so no change needed or var(--bg-color-alt) */
color: var(--primary-color-dark);
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color-light); /* Separator when open */
transition: background-color 0.2s ease;
}
.stExpander header:hover {
background-color: var(--primary-color-lightest);
}
.stExpander > div:nth-child(2) { /* Content area */
padding: 1.25rem;
background-color: var(--bg-color-alt); /* Consistent background */
}
.stExpander summary svg { /* The expand/collapse icon */
fill: var(--primary-color-dark);
}
/* --- Tabs (st.tabs) --- */
div[data-baseweb="tabs"] button[role="tab"] {
background-color: transparent !important;
color: var(--text-color-light) !important;
padding: 0.75rem 1.25rem !important;
border-bottom: 2px solid transparent !important;
transition: color 0.2s ease, border-color 0.2s ease;
font-weight: 500;
}
div[data-baseweb="tabs"] button[role="tab"]:hover {
color: var(--primary-color) !important;
}
div[data-baseweb="tabs"] button[role="tab"][aria-selected="true"] {
color: var(--primary-color-dark) !important;
border-bottom-color: var(--primary-color) !important;
font-weight: 600;
}
div[data-baseweb="tabs"] > div:last-child { /* Tab content panel */
padding-top: 1.5rem;
background-color: var(--bg-color-alt); /* Optional: if you want tab content on a card */
border-radius: 0 0 var(--border-radius) var(--border-radius); /* If above bg is used */
/* box-shadow: var(--shadow-sm); */ /* If above bg is used */
}
/* --- Sidebar --- */
section[data-testid="stSidebar"] {
background-color: var(--primary-color-lightest); /* Slightly different bg */
border-right: 1px solid var(--border-color-light);
box-shadow: var(--shadow);
}
section[data-testid="stSidebar"] .stButton > button {
/* Example: Make sidebar buttons full width and slightly different style */
width: 100%;
background-color: var(--primary-color-light);
color: var(--primary-color-darker);
font-weight: 600;
}
section[data-testid="stSidebar"] .stButton > button:hover {
background-color: var(--primary-color);
color: var(--text-on-primary);
}
/* Styles for widgets *inside* the sidebar can be targeted like: */
/* section[data-testid="stSidebar"] .stSelectbox { ... } */
/* --- Dataframes & Tables (st.dataframe, st.table) --- */
.stDataFrame, .stTable {
border-radius: var(--border-radius);
overflow: hidden; /* To make border-radius work on table */
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color-light);
}
.stDataFrame table, .stTable table {
width: 100%;
border-collapse: collapse;
}
.stDataFrame th, .stTable th {
background-color: var(--primary-color-lighter);
color: var(--primary-color-darker);
font-weight: 600;
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 2px solid var(--primary-color-light);
}
.stDataFrame td, .stTable td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-color-light);
color: var(--text-color-light);
background-color: var(--bg-color-alt);
}
.stDataFrame tr:last-child td, .stTable tr:last-child td {
border-bottom: none;
}
.stDataFrame tr:hover td, .stTable tr:hover td {
background-color: var(--primary-color-lightest); /* Subtle hover */
}
/* --- Metric (st.metric) --- */
.stMetric {
background-color: var(--bg-color-alt);
padding: 1.5rem;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow);
text-align: center; /* Center align metric content */
}
.stMetric > label { /* Metric Label */
color: var(--text-color-lighter);
font-size: 0.9rem;
font-weight: 500;
}
.stMetric > div:nth-of-type(1) { /* Metric Value */
font-size: 2.5rem;
font-weight: 700;
color: var(--primary-color-dark);
line-height: 1;
margin: 0.25rem 0;
}
.stMetric > div:nth-of-type(2) { /* Metric Delta */
font-size: 0.9rem;
font-weight: 500;
}
/* Delta colors - Streamlit uses data-testid for up/down arrow */
.stMetric [data-testid="stMetricDeltaIcon-Up"] + div { color: var(--success-color); }
.stMetric [data-testid="stMetricDeltaIcon-Down"] + div { color: var(--error-color); }
.stMetric [data-testid="stMetricDeltaIcon-Up"] svg { fill: var(--success-color); }
.stMetric [data-testid="stMetricDeltaIcon-Down"] svg { fill: var(--error-color); }
/* --- Chat Elements (st.chat_message, st.chat_input) --- */
.stChatMessage {
border-radius: var(--border-radius-lg);
padding: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
box-shadow: var(--shadow-sm);
max-width: 80%; /* Don't let messages span full width */
}
.stChatMessage[data-testid="stChatMessageContentUser"] { /* User message */
background-color: var(--primary-color);
color: var(--text-on-primary);
margin-left: auto; /* Align to right */
border-bottom-right-radius: var(--border-radius-sm); /* Speech bubble effect */
}
.stChatMessage[data-testid="stChatMessageContentUser"] a { color: var(--primary-color-lighter); }
.stChatMessage[data-testid="stChatMessageContentAssistant"] { /* Assistant message */
background-color: var(--bg-color-alt);
color: var(--text-color);
margin-right: auto; /* Align to left */
border: 1px solid var(--border-color-light);
border-bottom-left-radius: var(--border-radius-sm); /* Speech bubble effect */
}
.stChatMessage .stMarkdown p:last-child {
margin-bottom: 0; /* Remove extra margin from last paragraph in chat */
}
.stChatInput {
background-color: var(--bg-color-alt);
border-top: 1px solid var(--border-color-light);
padding: 0.75rem 1rem;
box-shadow: 0 -2px 5px rgba(0,0,0,0.03);
}
.stChatInput textarea {
background-color: var(--primary-color-lightest);
border: 1px solid var(--primary-color-light);
border-radius: var(--border-radius);
padding: 0.5rem 0.75rem;
color: var(--text-color);
}
.stChatInput textarea:focus {
border-color: var(--primary-color);
box-shadow: var(--shadow-primary-inset);
}
.stChatInput button[kind="secondary"] { /* The send button */
background-color: var(--primary-color);
border-radius: 50%; /* Make it circular */
padding: 0.5rem;
width: 40px;
height: 40px;
transition: background-color 0.2s ease;
}
.stChatInput button[kind="secondary"]:hover {
background-color: var(--primary-color-dark);
}
.stChatInput button[kind="secondary"] svg {
fill: var(--text-on-primary);
}
/* --- Custom Game Specific Styles from previous example --- */
.game-card {
background-color: var(--bg-color-alt);
padding: 2rem;
border-radius: var(--border-radius-lg);
box-shadow: 0 8px 16px rgba(0, 170, 255, 0.1), 0 4px 8px rgba(0,0,0,0.05);
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--primary-color-lighter);
padding: 1.25rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
border: 1px solid var(--primary-color-light);
}
.feedback-text {
font-size: 1.1rem;
text-align: center;
margin: 1.5rem 0;
font-weight: 500;
color: var(--primary-color-dark);
}
.celebration {
text-align: center;
font-size: 2.2rem;
color: var(--warning-color); /* Using warning color for gold */
margin: 1.5rem 0;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 193, 7, 0.5);
}
.hint-text {
font-style: italic;
color: var(--text-color-lighter);
text-align: center;
margin-top: 0.75rem;
font-size: 0.9rem;
}
/* --- Spinner --- */
[data-testid="stSpinner"] > div {
border-top-color: var(--primary-color) !important; /* Color of the spinner arc */
border-right-color: var(--primary-color-light) !important;
border-bottom-color: var(--primary-color-light) !important;
border-left-color: var(--primary-color-light) !important;
}
/* --- Scrollbars (Webkit only for simplicity, add Firefox if needed) --- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--primary-color-lightest);
border-radius: var(--border-radius);
}
::-webkit-scrollbar-thumb {
background: var(--primary-color-light);
border-radius: var(--border-radius);
border: 2px solid var(--primary-color-lightest); /* Creates padding around thumb */
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary-color);
}
</style>
""", unsafe_allow_html=True)
# --- Session State Initialization ---
# Using a helper to make it slightly cleaner
def init_session_state_key(key, value):
if key not in st.session_state:
st.session_state[key] = value
init_session_state_key('number_to_guess', random.randint(1, 100)) # Default to Medium on first ever load
init_session_state_key('attempts', 0)
init_session_state_key('total_games', 0)
init_session_state_key('total_guesses', 0)
init_session_state_key('wins', 0)
init_session_state_key('best_score', float('inf'))
init_session_state_key('game_active', True)
init_session_state_key('difficulty', 'Medium') # Default difficulty
init_session_state_key('max_attempts', 10) # Default for Medium
init_session_state_key('range_min', 1) # Default for Medium
init_session_state_key('range_max', 100) # Default for Medium
init_session_state_key('history', [])
init_session_state_key('game_start_time', time.time())
init_session_state_key('feedback', "Select your difficulty and make your first guess!")
init_session_state_key('hint_used', False)
init_session_state_key('guess_history', [])
# --- Game Logic Functions ---
DIFFICULTY_SETTINGS = {
'Easy': {'min': 1, 'max': 50, 'attempts': 15},
'Medium': {'min': 1, 'max': 100, 'attempts': 10},
'Hard': {'min': 1, 'max': 200, 'attempts': 8},
'Expert': {'min': 1, 'max': 500, 'attempts': 6}
}
def reset_game(difficulty_changed=False):
settings = DIFFICULTY_SETTINGS[st.session_state.difficulty]
st.session_state.range_min = settings['min']
st.session_state.range_max = settings['max']
st.session_state.max_attempts = settings['attempts']
st.session_state.number_to_guess = random.randint(st.session_state.range_min, st.session_state.range_max)
st.session_state.attempts = 0
st.session_state.game_active = True
if difficulty_changed:
st.session_state.feedback = f"Difficulty set to {st.session_state.difficulty}. New game started!"
else:
st.session_state.feedback = "New game started! Make your guess."
st.session_state.hint_used = False
st.session_state.guess_history = []
st.session_state.game_start_time = time.time()
# No need to manage input widget keys explicitly if using st.form and relying on Streamlit's default keying or explicit keys for inputs.
def update_stats(won):
st.session_state.total_games += 1
st.session_state.total_guesses += st.session_state.attempts
if won:
st.session_state.wins += 1
if st.session_state.attempts < st.session_state.best_score:
st.session_state.best_score = st.session_state.attempts
game_duration = round(time.time() - st.session_state.game_start_time, 1)
st.session_state.history.append({
'date': datetime.now().strftime("%Y-%m-%d %H:%M"),
'difficulty': st.session_state.difficulty,
'attempts': st.session_state.attempts,
'max_attempts': st.session_state.max_attempts,
'won': won,
'duration': game_duration,
'number': st.session_state.number_to_guess # Store the number for history
})
def evaluate_guess(guess_value): # Renamed param to avoid conflict
# Ensure guess is an integer
try:
guess_value = int(guess_value)
except (ValueError, TypeError):
st.session_state.feedback = "Invalid guess. Please enter a whole number."
return # Don't proceed with evaluation
st.session_state.attempts += 1
st.session_state.guess_history.append(guess_value)
if guess_value == st.session_state.number_to_guess:
st.session_state.feedback = f"πŸŽ‰ CORRECT! You found the number {st.session_state.number_to_guess} in {st.session_state.attempts} attempts!"
st.session_state.game_active = False
update_stats(True)
st.balloons() # Add some celebration
elif st.session_state.attempts >= st.session_state.max_attempts:
st.session_state.feedback = f"πŸ˜” Game over! You've used all {st.session_state.max_attempts} attempts. The number was {st.session_state.number_to_guess}."
st.session_state.game_active = False
update_stats(False)
elif guess_value < st.session_state.number_to_guess:
st.session_state.feedback = "πŸ“ˆ Too low! Aim higher."
else: # guess_value > st.session_state.number_to_guess
st.session_state.feedback = "πŸ“‰ Too high! Aim lower."
def give_hint():
if not st.session_state.game_active:
return "Game is over. Start a new game to get hints!"
if st.session_state.hint_used:
return "You've already used your hint for this game!"
target = st.session_state.number_to_guess
hints = []
if target % 2 == 0:
hints.append("The number is even.")
else:
hints.append("The number is odd.")
if st.session_state.difficulty in ['Easy', 'Medium'] and len(str(target)) > 1 : # More complex hints for easier levels
digit_sum = sum(int(digit) for digit in str(target))
hints.append(f"The sum of its digits is {digit_sum}.")
if st.session_state.difficulty == 'Easy':
if target > st.session_state.range_min + (st.session_state.range_max - st.session_state.range_min) / 2:
hints.append("It's in the upper half of the range.")
else:
hints.append("It's in the lower half of the range.")
st.session_state.hint_used = True # Mark hint as used
# st.session_state.attempts += 1 # Optional: Penalize hint usage by an attempt
# if st.session_state.attempts >= st.session_state.max_attempts:
# st.session_state.feedback = f"πŸ˜” Game over! You've used all {st.session_state.max_attempts} attempts. The number was {st.session_state.number_to_guess}."
# st.session_state.game_active = False
# update_stats(False)
print(hints) # debug
return " ".join(hints) if hints else "Hmm, I'm feeling shy with hints right now."
@st.cache_data # Cache the fun fact for the session
def get_number_fun_fact_cached():
fun_facts = [
"The fear of numbers is called 'arithmophobia'.",
"Zero (0) was not recognized as a number in Europe until the 13th century.",
"7 is often considered the world's most common lucky number.",
"The Mayans and Babylonians independently developed the concept of zero.",
"142857 is a cyclic number: 142857 Γ— 2 = 285714, 142857 Γ— 3 = 428571, etc.",
"There are 293 ways to make change for a US dollar using standard coins.",
"A googol is 1 followed by 100 zeros. A googolplex is 10 to the power of a googol.",
"In many East Asian cultures, the number 4 is unlucky as it sounds like 'death'.",
"Ancient Greeks considered 10 to be a perfect number (tetractys).",
"A 'perfect number' is a positive integer that is equal to the sum of its proper positive divisors. E.g., 6 (1+2+3).",
"No number from 1 to 999 contains the letter 'a' when spelled out in English.",
"The number 8 is considered very lucky in Chinese culture, sounding like 'wealth' or 'prosper'.",
"The sum of the first 10 Lucas numbers (starting L0=2, L1=1) is 123 + 1 = 124. L10 is 123.", # Corrected fact
"Euler's number (e) is an irrational number approximately equal to 2.71828."
]
return random.choice(fun_facts)
# --- Sidebar ---
with st.sidebar:
st.markdown("## Game Settings")
difficulty_options = list(DIFFICULTY_SETTINGS.keys())
current_difficulty_index = difficulty_options.index(st.session_state.difficulty)
new_difficulty = st.selectbox("Select Difficulty", difficulty_options,
index=current_difficulty_index, key="difficulty_selector")
if new_difficulty != st.session_state.difficulty:
st.session_state.difficulty = new_difficulty
reset_game(difficulty_changed=True)
st.rerun() # Explicitly rerun to update UI based on new difficulty
st.markdown("---")
st.markdown("## Game Statistics")
col1, col2 = st.columns(2)
with col1:
st.metric("Games Played", st.session_state.total_games)
st.metric("Wins", st.session_state.wins)
with col2:
st.metric("Best Score (Attempts)", st.session_state.best_score if st.session_state.best_score != float('inf') else "N/A")
win_rate_val = (st.session_state.wins / st.session_state.total_games * 100) if st.session_state.total_games > 0 else 0
st.metric("Win Rate", f"{win_rate_val:.1f}%")
avg_attempts_val = (st.session_state.total_guesses / st.session_state.total_games) if st.session_state.total_games > 0 else 0
st.metric("Avg. Attempts per Game", f"{avg_attempts_val:.1f}" if st.session_state.total_games > 0 else "N/A",
help="Average attempts across all completed games.")
st.markdown("---")
st.markdown("## Did You Know?")
st.markdown(f"*{get_number_fun_fact_cached()}*") # Use cached version
st.markdown("---")
if st.button("Reset All Stats", type="secondary"):
# Clear specific stats, keep difficulty
init_session_state_key('total_games', 0) # Re-init to 0
st.session_state.total_games = 0
st.session_state.total_guesses = 0
st.session_state.wins = 0
st.session_state.best_score = float('inf')
st.session_state.history = []
# reset_game() # Don't reset the current game, just the stats
st.success("All game statistics have been reset!")
time.sleep(1.5) # Give user time to see message
st.rerun()
# --- Main Game Area ---
st.markdown('<h1 class="game-title">Number Master Challenge</h1>', unsafe_allow_html=True)
st.markdown("<p style='font-size:80%; text-align: center; color: #777;'>Number Master Challenge by Valentino Phiri</p>", unsafe_allow_html=True)
st.markdown(f'<p class="game-subtitle">Guess the number between {st.session_state.range_min} and {st.session_state.range_max}!</p>', unsafe_allow_html=True)
# Game info card
with st.container(border=False): # Use border=True for themed container
st.markdown('<div class="game-card">', unsafe_allow_html=True)
col1, col2 = st.columns(2)
with col1:
st.markdown(f"**🎯 Difficulty:** {st.session_state.difficulty}")
st.markdown(f"**πŸ”’ Range:** {st.session_state.range_min} – {st.session_state.range_max}")
with col2:
st.markdown(f"**⏳ Attempts Left:** {st.session_state.max_attempts - st.session_state.attempts}")
# Progress bar for attempts
progress_value = (st.session_state.attempts / st.session_state.max_attempts) if st.session_state.max_attempts > 0 else 0
st.progress(progress_value, text=f"{st.session_state.attempts}/{st.session_state.max_attempts} Guesses Made")
# Feedback message
feedback_type = "info"
if "CORRECT" in st.session_state.feedback: feedback_type = "success"
elif "Game over" in st.session_state.feedback or "Too low" in st.session_state.feedback or "Too high" in st.session_state.feedback: feedback_type = "warning"
if "Invalid" in st.session_state.feedback: feedback_type = "error"
if feedback_type == "success": st.success(st.session_state.feedback)
elif feedback_type == "warning": st.warning(st.session_state.feedback)
elif feedback_type == "error": st.error(st.session_state.feedback)
else: st.info(st.session_state.feedback)
st.markdown('</div>', unsafe_allow_html=True)
# Guessing input area
if st.session_state.game_active:
st.markdown('<div class="game-card">', unsafe_allow_html=True)
tab1, tab2 = st.tabs(["⌨️ Type Guess", "🎚️ Slider Guess"])
with tab1: # Type Guess is often preferred, so make it first
with st.form(key="typed_form"):
# Default value for number_input could be last guess or middle of remaining range
# For simplicity, let's use a placeholder or the min of the range
default_typed_guess = st.session_state.range_min
if st.session_state.guess_history: # Try to default to something near last guess
last_guess = st.session_state.guess_history[-1]
if st.session_state.feedback.startswith("πŸ“ˆ Too low"):
default_typed_guess = min(st.session_state.range_max, last_guess + 1)
elif st.session_state.feedback.startswith("πŸ“‰ Too high"):
default_typed_guess = max(st.session_state.range_min, last_guess - 1)
typed_guess_value = st.number_input("Enter your guess:",
min_value=st.session_state.range_min,
max_value=st.session_state.range_max,
value=default_typed_guess, # Smarter default
step=1,
key="direct_guess_input") # Unique key for the input widget
typed_submit_button = st.form_submit_button("Guess with Number")
if typed_submit_button:
# st.write(f"DEBUG: Typed form submitted with {typed_guess_value}") # For debugging
evaluate_guess(typed_guess_value)
st.rerun() # Rerun to update UI (feedback, attempts, etc.)
with tab2:
with st.form(key="slider_form"):
# Default slider value to middle of the current range
slider_default_val = (st.session_state.range_min + st.session_state.range_max) // 2
if st.session_state.guess_history: # Try to default to something near last guess
last_guess = st.session_state.guess_history[-1]
if st.session_state.range_min <= last_guess <= st.session_state.range_max:
slider_default_val = last_guess
slider_guess_value = st.slider("Slide to your guess:",
min_value=st.session_state.range_min,
max_value=st.session_state.range_max,
value=slider_default_val, # Smarter default
key="slider_guess_input") # Unique key
slider_submit_button = st.form_submit_button("Guess with Slider")
if slider_submit_button:
# st.write(f"DEBUG: Slider form submitted with {slider_guess_value}") # For debugging
evaluate_guess(slider_guess_value)
st.rerun() # Rerun to update UI
# Hint button (outside forms but within game_active and game-card)
if not st.session_state.hint_used: # Only show hint button if not used
if st.button("πŸ’‘ Give Me a Hint", key="hint_button", help="Get a clue about the number. May cost an attempt in future versions!"):
hint_text = give_hint()
st.markdown(f'<div class="hint-text">{hint_text}</div>', unsafe_allow_html=True)
# If hint costs an attempt and evaluate_guess is not called inside give_hint, rerun might be needed
# For now, give_hint updates session_state.hint_used, rerun helps if UI depends on it.
st.rerun() # Rerun to hide hint button if give_hint sets hint_used=True
else:
st.markdown('<div class="hint-text">Hint has been used for this game.</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# Game over screen / Next game
if not st.session_state.game_active:
st.markdown('<div class="game-card">', unsafe_allow_html=True)
if "CORRECT" in st.session_state.feedback:
st.markdown('<div class="celebration">πŸŽ‰ CONGRATULATIONS! πŸŽ‰</div>', unsafe_allow_html=True)
# Calculate score (optional, based on difficulty and attempts)
# score = int(1000 * (list(DIFFICULTY_SETTINGS.keys()).index(st.session_state.difficulty) + 1) / st.session_state.attempts)
# st.markdown(f"<h3 style='text-align: center;'>Your score: {score} points</h3>", unsafe_allow_html=True)
else: # Game lost
st.markdown('<div class="celebration" style="color: #FF6B6B;">πŸ˜” GAME OVER πŸ˜”</div>', unsafe_allow_html=True)
encouragements = [
"That was a tough one! Try again?", "So close! Next time for sure.", "Don't give up, a new challenge awaits!",
"Practice makes perfect. Ready for another round?", "Every master was once a beginner. New game?"
]
st.markdown(f"<p style='text-align: center; font-style: italic;'>{random.choice(encouragements)}</p>", unsafe_allow_html=True)
if st.button("Start New Game", key="new_game_button", type="primary"):
reset_game()
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
# Guess history chart (shown after at least one guess)
if st.session_state.guess_history:
st.markdown('<div class="game-card">', unsafe_allow_html=True)
st.markdown("#### Your Guess Journey")
guess_df = pd.DataFrame({
'Attempt': range(1, len(st.session_state.guess_history) + 1),
'Guess': st.session_state.guess_history
})
# Determine y-axis scale dynamically but capped by game range
min_guess_val = min(st.session_state.guess_history) if st.session_state.guess_history else st.session_state.range_min
max_guess_val = max(st.session_state.guess_history) if st.session_state.guess_history else st.session_state.range_max
# Add a bit of padding to the y-axis, but don't exceed game range
y_domain_min = max(st.session_state.range_min, min_guess_val - 10)
y_domain_max = min(st.session_state.range_max, max_guess_val + 10)
# Ensure min is less than max if only one guess or guesses are close
if y_domain_min >= y_domain_max:
y_domain_min = st.session_state.range_min
y_domain_max = st.session_state.range_max
base_chart = alt.Chart(guess_df).mark_line(point=True, color='cornflowerblue').encode(
x=alt.X('Attempt:Q', axis=alt.Axis(tickMinStep=1, title='Attempt Number')), # Ensure integer ticks
y=alt.Y('Guess:Q', scale=alt.Scale(domain=[y_domain_min, y_domain_max]), title='Guessed Value'),
tooltip=['Attempt', 'Guess']
).properties(
title='Guesses vs. Attempts'
)
if not st.session_state.game_active: # Show target line only when game is over
target_df = pd.DataFrame({'Target': [st.session_state.number_to_guess]})
target_line = alt.Chart(target_df).mark_rule(color='red', strokeDash=[5,5]).encode(
y='Target:Q',
size=alt.value(2) # Make line thicker
)
label = target_line.mark_text(
align='left',
baseline='middle',
dx=7,
text=f"Target: {st.session_state.number_to_guess}"
).encode(text=alt.value(f"Target: {st.session_state.number_to_guess}"))
st.altair_chart(base_chart + target_line + label, use_container_width=True)
else:
st.altair_chart(base_chart, use_container_width=True)
st.markdown('</div>', unsafe_allow_html=True)
# Game history table
if st.session_state.history:
if st.checkbox("Show Full Game History", value=False, key="show_history_checkbox"):
st.markdown("## Your Past Games")
history_df = pd.DataFrame(st.session_state.history).sort_values(by="date", ascending=False)
if not history_df.empty:
history_df['Result'] = history_df.apply(
lambda x: f"Won ({x['attempts']}/{x['max_attempts']} attempts)" if x['won'] else f"Lost ({x['attempts']}/{x['max_attempts']} attempts)", axis=1
)
history_df['Target Number'] = history_df['number'] # Add target number column
st.dataframe(
history_df[['date', 'difficulty', 'Result', 'Target Number', 'duration']],
column_config={
'date': st.column_config.DatetimeColumn("Date & Time", format="YYYY-MM-DD HH:mm"),
'difficulty': 'Difficulty',
'Result': 'Outcome',
'Target Number': 'Secret Number',
'duration': st.column_config.NumberColumn('Duration (s)', format="%.1fs")
},
use_container_width=True,
hide_index=True
)
st.markdown("---")
st.markdown("<p style='text-align: center; color: #777;'>Number Master Challenge by Valentino Phiri</p>", unsafe_allow_html=True)