Merge pull request 'web deployment changes' (#1) from feature/web-deployment into main
Reviewed-on: #1
This commit is contained in:
commit
4b1f5d9874
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.git
|
||||
.gitignore
|
||||
.venv/
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
README.md
|
||||
web-deployment-plan.md
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
115
api.py
Normal 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
7
docker-compose.yml
Normal 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
2
requirements-web.txt
Normal file
@ -0,0 +1,2 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
177
static/index.html
Normal file
177
static/index.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user