web deployment changes

This commit is contained in:
Maik 2026-06-11 20:10:52 +02:00
parent e6b6b8ce17
commit 564a06ddfa
6 changed files with 322 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.venv
__pycache__
.git
.gitignore
.venv/
**/__pycache__
*.pyc
README.md
web-deployment-plan.md

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]

115
api.py Normal file
View File

@ -0,0 +1,115 @@
import uuid
from typing import Optional
from fastapi import FastAPI, Cookie, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from blackjack_game import BlackjackGame
from game_logic import can_split, get_correct_decision, get_split_decision
app = FastAPI(title="Blackjack Theory Trainer")
sessions: dict[str, BlackjackGame] = {}
def get_or_create_session(session_id: Optional[str] = None) -> tuple[str, BlackjackGame, bool]:
if session_id and session_id in sessions:
return session_id, sessions[session_id], False
new_id = str(uuid.uuid4())
game = BlackjackGame()
sessions[new_id] = game
return new_id, game, True
class DecisionRequest(BaseModel):
action: str
@app.post("/api/start-round")
async def start_round(session_id: Optional[str] = Cookie(None)):
sid, game, is_new = get_or_create_session(session_id)
game.start_new_round()
data = {
"dealer_card": game.dealer_card,
"player_hand": game.player_hand,
"player_total": game.player_total,
"has_blackjack": game.has_blackjack,
"can_split": can_split(game.player_hand),
"current_streak": game.stats["current_streak"],
}
response = JSONResponse(content=data)
if is_new:
response.set_cookie(key="session_id", value=sid, path="/")
return response
@app.post("/api/check-decision")
async def check_decision(req: DecisionRequest, session_id: Optional[str] = Cookie(None)):
if not session_id or session_id not in sessions:
raise HTTPException(status_code=400, detail="No active session")
game = sessions[session_id]
scenario_type = "hard_totals"
if can_split(game.player_hand):
scenario_type = "pairs"
elif "A" in [c.split("_")[-1] for c in game.player_hand]:
scenario_type = "soft_totals"
if can_split(game.player_hand):
decision, feedback = get_split_decision(game.player_hand, game.dealer_card)
correct = req.action == decision
else:
decision, feedback = get_correct_decision(
game.player_hand, game.player_total, game.dealer_card
)
correct = req.action == decision
had_streak = game.stats["current_streak"]
game.update_stats(correct, scenario_type)
stats = game.get_stats()
streak_ended = None
if not correct and had_streak >= 3:
streak_ended = f"Streak ended at {had_streak}!"
return {
"correct": correct,
"decision": decision,
"feedback": feedback,
"scenario_type": scenario_type,
"stats": stats,
"streak_ended": streak_ended,
}
@app.get("/api/stats")
async def get_stats(session_id: Optional[str] = Cookie(None)):
if not session_id or session_id not in sessions:
raise HTTPException(status_code=400, detail="No active session")
return sessions[session_id].get_stats()
@app.get("/api/hint")
async def get_hint(session_id: Optional[str] = Cookie(None)):
if not session_id or session_id not in sessions:
raise HTTPException(status_code=400, detail="No active session")
game = sessions[session_id]
if can_split(game.player_hand):
decision, feedback = get_split_decision(game.player_hand, game.dealer_card)
else:
decision, feedback = get_correct_decision(
game.player_hand, game.player_total, game.dealer_card
)
return {"decision": decision, "feedback": feedback}
@app.get("/")
async def read_index():
return FileResponse("static/index.html")
app.mount("/images", StaticFiles(directory="images"), name="images")

7
docker-compose.yml Normal file
View File

@ -0,0 +1,7 @@
services:
blackjack:
build: .
container_name: blackjack-theory
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000"

2
requirements-web.txt Normal file
View File

@ -0,0 +1,2 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0

177
static/index.html Normal file
View File

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blackjack Theory Trainer</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:#2b2b2b;color:#fff;font-family:Arial,sans-serif;display:flex;justify-content:center;min-height:100vh;padding:20px}
#app{width:100%;max-width:600px;display:flex;flex-direction:column;align-items:center}
#header{width:100%;display:flex;justify-content:flex-end;padding:8px 10px;min-height:36px}
#streak{color:#FFD700;font-size:18px;font-weight:700;display:none}
.section-label{color:#ccc;font-size:14px;margin-bottom:4px;text-align:center}
.card-row{display:flex;justify-content:center;gap:10px;min-height:140px}
.card-row img{width:125px;height:125px;border-radius:8px;object-fit:contain}
#dealer-section,#player-section{margin-bottom:8px}
#table{background:#333;border-radius:12px;padding:24px 20px;width:100%;display:flex;flex-direction:column;align-items:center}
#actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;width:100%;max-width:340px;margin:16px 0 8px}
.action-btn{padding:12px;font-size:20px;border:none;border-radius:8px;cursor:pointer;background:#fff;color:#000;font-weight:700;transition:background .15s,color .15s}
.action-btn:disabled{background:#555;color:#999;cursor:not-allowed}
#btn-hint{background:#fff;color:#000;font-size:20px;border:none;border-radius:8px;padding:12px 40px;cursor:pointer;margin:8px 0;font-weight:700}
#btn-hint:disabled{background:#555;color:#999;cursor:not-allowed}
#feedback{font-size:20px;text-align:center;min-height:60px;padding:10px;width:100%;max-width:550px;line-height:1.4;word-wrap:break-word}
#btn-next{background:#fff;color:#000;font-size:20px;border:none;border-radius:8px;padding:12px 40px;cursor:pointer;margin:8px 0;font-weight:700}
#btn-next:disabled{background:#555;color:#999;cursor:not-allowed}
#toast{position:fixed;top:20px;right:20px;background:#3a7ebf;color:#fff;padding:12px 24px;border-radius:8px;font-size:16px;font-weight:700;opacity:0;transform:translateY(-20px);transition:opacity .3s,transform .3s;pointer-events:none;z-index:1000}
#toast.show{opacity:1;transform:translateY(0)}
#lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);justify-content:center;align-items:center;z-index:999;cursor:pointer}
#lightbox.open{display:flex}
#lightbox img{max-width:90vw;max-height:90vh;border-radius:8px}
</style>
</head>
<body>
<div id="app">
<div id="header">
<span id="streak"></span>
</div>
<div id="table">
<div id="dealer-section">
<div class="section-label">Dealer</div>
<div class="card-row" id="dealer-cards"></div>
</div>
<div id="player-section">
<div class="section-label">Your Hand</div>
<div class="card-row" id="player-cards"></div>
</div>
<div id="actions">
<button class="action-btn" id="btn-hit">Hit</button>
<button class="action-btn" id="btn-stand">Stand</button>
<button class="action-btn" id="btn-double">Double</button>
<button class="action-btn" id="btn-split">Split</button>
</div>
<button id="btn-hint">Hint</button>
<div id="feedback"></div>
<button id="btn-next" disabled>Next Round</button>
</div>
</div>
<div id="toast"></div>
<div id="lightbox">
<img src="/images/hint.jpg" alt="Strategy hint">
</div>
<script>
const COOKIE = 'session_id';
const btnHit=document.getElementById('btn-hit');
const btnStand=document.getElementById('btn-stand');
const btnDouble=document.getElementById('btn-double');
const btnSplit=document.getElementById('btn-split');
const btnHint=document.getElementById('btn-hint');
const btnNext=document.getElementById('btn-next');
const dealerCards=document.getElementById('dealer-cards');
const playerCards=document.getElementById('player-cards');
const feedback=document.getElementById('feedback');
const streak=document.getElementById('streak');
const toast=document.getElementById('toast');
const lightbox=document.getElementById('lightbox');
const actionBtns=[btnHit,btnStand,btnDouble,btnSplit];
function cardUrl(name){return '/images/cards/'+name+'.png'}
function setStreak(n){
if(n>=3){streak.style.display='inline';streak.textContent='\uD83D\uDD25 '+n}
else{streak.style.display='none'}
}
function showToast(msg){
toast.textContent=msg;toast.classList.add('show');
setTimeout(()=>toast.classList.remove('show'),1500);
}
function enableActions(enabled){
const state=enabled?'normal':'disabled';
actionBtns.forEach(b=>b.disabled=!enabled);
}
function resetButtonColors(){
actionBtns.forEach(b=>{b.style.background='#fff';b.style.color='#000'});
}
function markButton(btn,correct){
btn.style.background=correct?'#2aa44f':'#e74c3c';
btn.style.color='#fff';
}
function renderCards(dc,hc,isNewRound){
dealerCards.innerHTML='';
playerCards.innerHTML='';
var d=document.createElement('img');
d.src=cardUrl(dc);d.alt='Dealer card';
dealerCards.appendChild(d);
hc.forEach(function(c){
var i=document.createElement('img');
i.src=cardUrl(c);i.alt='Player card';
playerCards.appendChild(i);
});
if(isNewRound){
feedback.textContent='';
if(hc===undefined){
btnHint.disabled=false;
btnNext.disabled=true;
enableActions(true);
resetButtonColors();
}
}
}
async function startRound(){
try{
var r=await fetch('/api/start-round',{method:'POST'});
var d=await r.json();
renderCards(d.dealer_card,d.player_hand,true);
setStreak(d.current_streak);
btnHint.disabled=false;
enableActions(true);
resetButtonColors();
if(d.has_blackjack){
feedback.textContent='Blackjack! You win or get a push!';
feedback.style.color='green';
enableActions(false);
btnNext.disabled=false;
}
if(d.can_split){btnSplit.disabled=false}
else{btnSplit.disabled=true}
}catch(e){feedback.textContent='Error connecting to server';feedback.style.color='red'}
}
async function checkDecision(action,btn){
try{
var r=await fetch('/api/check-decision',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:action})
});
if(!r.ok){feedback.textContent='Session expired. Starting new round...';feedback.style.color='red';startRound();return}
var d=await r.json();
feedback.textContent=d.feedback;
feedback.style.color=d.correct?'green':'red';
markButton(btn,d.correct);
enableActions(false);
btnNext.disabled=false;
setStreak(d.stats.current_streak);
if(d.streak_ended){showToast(d.streak_ended)}
}catch(e){feedback.textContent='Error checking decision';feedback.style.color='red'}
}
btnHit.addEventListener('click',function(){checkDecision('Hit',btnHit)});
btnStand.addEventListener('click',function(){checkDecision('Stand',btnStand)});
btnDouble.addEventListener('click',function(){checkDecision('Double',btnDouble)});
btnSplit.addEventListener('click',function(){checkDecision('Split',btnSplit)});
btnNext.addEventListener('click',startRound);
btnHint.addEventListener('click',function(){lightbox.classList.add('open');btnHint.disabled=true});
lightbox.addEventListener('click',function(){lightbox.classList.remove('open')});
startRound();
</script>
</body>
</html>