web deployment changes
This commit is contained in:
parent
e6b6b8ce17
commit
564a06ddfa
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