d1demo / source /build_site.py
HelloSun's picture
copy: clarify bust round label
ca82eb0 verified
Raw History Blame Contribute Delete
39.9 kB
#!/usr/bin/env python3
"""Turn a battle.json produced by blackjack.py into a static website.
Usage:
python3 build_site.py --battle battle.json --out site
"""
import argparse
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
HTML = r"""<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<meta name="description" content="__SUBTITLE__">
<style>
:root{
--bg:#07120e; --bg2:#0b1c16; --panel:#0e241c; --panel2:#123026;
--line:rgba(255,255,255,.09); --text:#e8f3ed; --muted:#8fae9f;
--gold:#f6c453; --green:#39d98a; --red:#ff6b6b; --blue:#5cc8ff; --amber:#ffb020;
--hit:#ff6b6b; --stand:#5cc8ff; --double:#f6c453;
--shadow:0 18px 50px rgba(0,0,0,.45);
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:
radial-gradient(1100px 700px at 12% -10%, rgba(57,217,138,.13), transparent 60%),
radial-gradient(900px 600px at 92% 0%, rgba(246,196,83,.10), transparent 55%),
var(--bg);
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans TC","PingFang TC","Microsoft JhengHei",sans-serif;
line-height:1.6; min-height:100vh;
}
a{color:var(--gold)}
button{font:inherit;color:inherit;cursor:pointer}
.topbar{
position:sticky;top:0;z-index:50;backdrop-filter:blur(14px);
background:rgba(7,18,14,.8);border-bottom:1px solid var(--line);
}
.topbar-inner{max-width:1180px;margin:0 auto;padding:12px 20px;display:flex;gap:18px;align-items:center;flex-wrap:wrap}
.brand{display:flex;gap:10px;align-items:center;font-weight:800;letter-spacing:.02em}
.brand .logo{
width:30px;height:30px;border-radius:9px;display:grid;place-items:center;
background:linear-gradient(145deg,#f6c453,#b8860b);color:#1b1200;font-weight:900;
}
nav{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap}
nav button{
border:1px solid transparent;background:transparent;padding:8px 14px;border-radius:999px;
color:var(--muted);font-weight:600;
}
nav button:hover{color:var(--text);background:rgba(255,255,255,.05)}
nav button.active{color:#1b1200;background:var(--gold);border-color:var(--gold)}
main{max-width:1180px;margin:0 auto;padding:28px 20px 60px}
.view{display:none}
.view.active{display:block;animation:fade .25s ease}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.hero{padding:18px 0 6px}
.hero h1{font-size:clamp(30px,4vw,50px);line-height:1.15;margin:0 0 10px;letter-spacing:-.01em}
.hero h1 em{font-style:normal;color:var(--gold)}
.hero p{color:var(--muted);margin:0;max-width:760px;font-size:16px}
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
.chip{
font-size:12.5px;color:#cfe4d9;background:rgba(255,255,255,.045);
border:1px solid var(--line);border-radius:999px;padding:5px 11px;
}
.chip b{color:var(--gold)}
.scoreboard{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:22px 0 24px}
.score{
position:relative;overflow:hidden;border:1px solid var(--line);border-radius:18px;
padding:18px 18px 16px;background:linear-gradient(160deg,var(--panel2),var(--panel));
box-shadow:var(--shadow);
}
.score:before{content:"";position:absolute;inset:0 auto 0 0;width:4px;background:var(--accent,var(--gold))}
.score .id{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.score .name{font-size:22px;font-weight:800;margin:2px 0 0}
.score .style{font-size:13px;color:var(--accent,var(--gold))}
.score .bank{font-size:34px;font-weight:900;letter-spacing:-.02em;margin-top:10px;font-variant-numeric:tabular-nums}
.score .sub{font-size:13px;color:var(--muted)}
.big-delta{font-weight:800}
.pos{color:var(--green)}.neg{color:var(--red)}.neutral{color:var(--muted)}
.panel{
border:1px solid var(--line);border-radius:20px;background:linear-gradient(180deg,rgba(255,255,255,.025),rgba(0,0,0,.12));
box-shadow:var(--shadow);
}
.table-panel{padding:16px}
.round-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
.icon-btn{
border:1px solid var(--line);background:rgba(255,255,255,.05);border-radius:10px;
padding:8px 13px;font-weight:700;color:var(--text);
}
.icon-btn:hover{background:rgba(255,255,255,.1)}
.icon-btn:disabled{opacity:.35;cursor:not-allowed}
.round-pills{display:flex;gap:6px;overflow-x:auto;padding:2px;max-width:100%;scrollbar-width:thin}
.pill{
flex:0 0 auto;min-width:38px;height:34px;border-radius:9px;border:1px solid var(--line);
background:rgba(255,255,255,.04);font-weight:800;font-size:13px;color:var(--muted);
display:grid;place-items:center;padding:0 8px;
}
.pill:hover{border-color:rgba(246,196,83,.5);color:var(--text)}
.pill.active{background:var(--gold);border-color:var(--gold);color:#1b1200}
.pill .dot{width:6px;height:6px;border-radius:50%;display:block;margin:0 auto 2px}
.pill.win .dot{background:var(--green)}
.pill.lose .dot{background:var(--red)}
.pill.mixed .dot{background:var(--amber)}
.pill.push .dot{background:#93a89d}
.round-label{margin-left:auto;color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums}
.round-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.round-head h2{margin:0;font-size:20px}
.round-head .shoe{color:var(--muted);font-size:12.5px}
.dealer{
border:1px solid rgba(246,196,83,.25);border-radius:16px;padding:14px 16px;margin-bottom:14px;
background:radial-gradient(700px 200px at 20% 0%,rgba(246,196,83,.1),transparent 70%),rgba(0,0,0,.18);
display:flex;gap:16px;align-items:center;flex-wrap:wrap;
}
.dealer .label{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);font-weight:800}
.dealer .total-badge{font-size:26px;font-weight:900;font-variant-numeric:tabular-nums}
.dealer .note{color:var(--muted);font-size:13px;flex:1;min-width:180px}
.players{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.player{
border:1px solid var(--line);border-radius:16px;padding:15px;background:rgba(255,255,255,.025);
}
.player.win{border-color:rgba(57,217,138,.45);box-shadow:inset 0 0 0 1px rgba(57,217,138,.12)}
.player.lose{border-color:rgba(255,107,107,.35)}
.player.blackjack{border-color:rgba(246,196,83,.6)}
.p-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;flex-wrap:wrap}
.p-head h3{margin:0;font-size:18px}
.p-head .style{font-size:12.5px;color:var(--accent,var(--gold))}
.p-head .bet{
font-size:12.5px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:4px 10px;
background:rgba(0,0,0,.2);white-space:nowrap;
}
.p-head .bet b{color:var(--gold);font-size:15px}
.hand{display:flex;gap:14px;align-items:center;margin:12px 0 8px;flex-wrap:wrap}
.cards{display:flex;gap:6px;flex-wrap:wrap}
.card{
width:52px;height:72px;border-radius:8px;background:#f7f7f2;color:#141414;
display:flex;flex-direction:column;align-items:center;justify-content:center;
font-weight:900;box-shadow:0 6px 14px rgba(0,0,0,.35);border:1px solid rgba(0,0,0,.15);
line-height:1;user-select:none;
}
.card .r{font-size:21px}
.card .s{font-size:17px;margin-top:2px}
.card.red{color:#c0182b}
.card.down{background:repeating-linear-gradient(45deg,#123c6b,#123c6b 5px,#0b2a4d 5px,#0b2a4d 10px);color:transparent;border-color:#0a2340}
.total{
font-size:30px;font-weight:900;font-variant-numeric:tabular-nums;display:flex;align-items:baseline;gap:6px;
}
.total small{font-size:11px;font-weight:700;color:var(--muted);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
.result{
display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:800;
padding:3px 10px;border-radius:999px;border:1px solid var(--line);
}
.result.win{color:var(--green);border-color:rgba(57,217,138,.4);background:rgba(57,217,138,.1)}
.result.lose{color:var(--red);border-color:rgba(255,107,107,.4);background:rgba(255,107,107,.08)}
.result.push{color:#b9c9c0;background:rgba(255,255,255,.05)}
.result.blackjack{color:var(--gold);border-color:rgba(246,196,83,.5);background:rgba(246,196,83,.1)}
.bet-line{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--muted);margin:6px 0 2px}
.bet-line .tag{border:1px solid var(--line);border-radius:6px;padding:1px 7px;color:#cfe4d9}
details{margin-top:8px}
summary{cursor:pointer;color:var(--muted);font-size:12.5px;font-weight:700}
summary:hover{color:var(--text)}
details .body{padding:8px 0 0;color:var(--muted);font-size:13px}
.steps{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:8px}
.step{
border:1px solid var(--line);border-radius:11px;padding:9px 11px;background:rgba(0,0,0,.2);
}
.step.enter{animation:slide .3s ease}
@keyframes slide{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}}
.step-head{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}
.step-no{color:var(--muted);font-weight:800;font-variant-numeric:tabular-nums}
.mini{font-weight:800;font-variant-numeric:tabular-nums}
.action{
font-weight:900;font-size:12px;letter-spacing:.04em;padding:2px 9px;border-radius:6px;color:#0b0f0d;
}
.action.hit{background:var(--hit)}.action.stand{background:var(--stand)}.action.double{background:var(--double)}
.action.unknown{background:#7a8a81}
.step .meta{margin-left:auto;color:var(--muted);font-size:11.5px;display:flex;gap:8px;flex-wrap:wrap}
.probs{display:grid;grid-template-columns:64px 1fr 46px;gap:6px;align-items:center;margin-top:7px;font-size:11.5px;color:var(--muted)}
.bar{height:7px;border-radius:4px;background:rgba(255,255,255,.07);overflow:hidden}
.bar>i{display:block;height:100%;border-radius:4px;background:var(--c,var(--gold));width:0;transition:width .5s ease}
.probs .pct{text-align:right;font-variant-numeric:tabular-nums;color:#d6e7de}
.step-foot{display:flex;gap:10px;flex-wrap:wrap;color:var(--muted);font-size:11.5px;margin-top:7px}
.step-foot b{color:#d6e7de}
.net{margin-top:11px;padding-top:10px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;font-size:13px}
.net .amt{font-size:19px;font-weight:900;font-variant-numeric:tabular-nums}
.events{margin-top:14px;border-top:1px dashed var(--line);padding-top:10px;color:var(--muted);font-size:12.5px}
.events ul{margin:6px 0 0;padding-left:18px}
.events li{margin:2px 0}
.arena-note{margin-top:14px;color:var(--muted);font-size:12px}
.section-title{font-size:22px;margin:26px 0 12px}
.stats-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.stat-card{border:1px solid var(--line);border-radius:16px;padding:16px;background:rgba(255,255,255,.025)}
.stat-card h3{margin:0 0 4px;font-size:17px}
.stat-card .style{font-size:12.5px;color:var(--accent,var(--gold))}
.kv{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:12px}
.kv .k{font-size:11.5px;color:var(--muted)}
.kv .v{font-size:19px;font-weight:800;font-variant-numeric:tabular-nums}
.dist{margin-top:12px}
.dist .row{display:grid;grid-template-columns:66px 1fr 44px;gap:7px;align-items:center;font-size:12px;margin:5px 0;color:var(--muted)}
.mini-title{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-top:14px}
.chart-wrap{margin-top:14px;border:1px solid var(--line);border-radius:16px;padding:14px;background:rgba(0,0,0,.18)}
.chart-legend{display:flex;gap:14px;font-size:12px;color:var(--muted);flex-wrap:wrap;margin-bottom:6px}
.chart-legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}
.h2h{margin-top:14px;border:1px solid var(--line);border-radius:16px;padding:16px;background:rgba(255,255,255,.025)}
.h2h .big{font-size:30px;font-weight:900;color:var(--gold)}
.code-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.code-tabs button{border:1px solid var(--line);background:rgba(255,255,255,.04);border-radius:8px;padding:6px 12px;font-size:12.5px;color:var(--muted)}
.code-tabs button.active{background:var(--gold);color:#1b1200;border-color:var(--gold);font-weight:800}
.code-bar{display:flex;gap:10px;align-items:center;margin-bottom:8px;color:var(--muted);font-size:12.5px;flex-wrap:wrap}
pre.code{
margin:0;border:1px solid var(--line);border-radius:14px;background:#04100c;padding:16px;
overflow:auto;max-height:70vh;font-size:12.5px;line-height:1.55;
}
pre.code code{font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace}
.data-box{border:1px solid var(--line);border-radius:16px;padding:16px;background:rgba(255,255,255,.025);margin-bottom:14px}
.data-box h3{margin:0 0 8px;font-size:17px}
.data-box p{color:var(--muted);margin:6px 0;font-size:14px}
.btn{
display:inline-flex;align-items:center;gap:7px;border:1px solid rgba(246,196,83,.45);
background:rgba(246,196,83,.1);color:var(--gold);padding:7px 13px;border-radius:9px;
text-decoration:none;font-weight:700;font-size:13px;
}
.btn:hover{background:rgba(246,196,83,.18)}
.btn.ghost{border-color:var(--line);background:rgba(255,255,255,.04);color:var(--text)}
footer{border-top:1px solid var(--line);margin-top:30px;padding:22px 20px 40px;color:var(--muted);font-size:12.5px;text-align:center}
.loading{padding:40px;text-align:center;color:var(--muted)}
.err{border:1px solid rgba(255,107,107,.4);background:rgba(255,107,107,.08);padding:16px;border-radius:12px;color:#ffd4d4}
@media (max-width:760px){
.scoreboard,.stats-grid,.players{grid-template-columns:1fr}
.card{width:44px;height:62px}
.card .r{font-size:18px}
.total{font-size:25px}
.round-label{margin-left:0}
}
</style>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<div class="brand"><span class="logo">21</span><span>d1 × d1 對戰實驗室</span></div>
<nav>
<button data-view="battle" class="active">對戰</button>
<button data-view="stats">統計</button>
<button data-view="code">整理程式</button>
<button data-view="data">原始資料</button>
</nav>
</div>
</header>
<main>
<div id="app"><div class="loading">載入對戰資料中…</div></div>
</main>
<footer>
<div>由 llama.cpp 上的兩個 <b>LiquidAI d1-3B</b> 決策模型自動產生 · 頁面由 build_site.py 靜態編譯</div>
<div style="margin-top:6px">Hosted on Hugging Face Static Space · <a href="https://huggingface.co/spaces/HelloSun/d1demo">HelloSun/d1demo</a></div>
</footer>
<script>
const SUIT_RED = new Set(["♥","♦"]);
const LABEL = {hit:"補牌", stand:"停牌", double:"加倍", min:"小注", standard:"大注", max:"全押"};
const VALUE = {A:11,"2":2,"3":3,"4":4,"5":5,"6":6,"7":7,"8":8,"9":9,"10":10,J:10,Q:10,K:10};
const RESULT_LABEL = {win:"勝", lose:"負", push:"和局", blackjack:"Blackjack"};
const $ = (s,el=document)=>el.querySelector(s);
let DATA=null, roundIdx=0, autoTimer=null, dealerOpen=true;
function money(n){ return (n>0?"+":"") + n.toLocaleString("en-US"); }
function cls(n){ return n>0?"pos":(n<0?"neg":"neutral"); }
function pct(x){ return (Math.max(0,Math.min(1,x||0))*100).toFixed(0)+"%"; }
function cardHTML(card, hidden){
if(hidden) return '<div class="card down">?</div>';
const r=card.slice(0,-1), s=card.slice(-1);
return '<div class="card '+(SUIT_RED.has(s)?"red":"")+'"><span class="r">'+r+'</span><span class="s">'+s+'</span></div>';
}
function cardsHTML(cards, opts={}){
return '<div class="cards">'+cards.map((c,i)=>cardHTML(c, opts.hideIndex===i)).join("")+'</div>';
}
function agentColor(id){ return id==="a" ? "var(--green)" : "var(--blue)"; }
function agentMeta(id){ return DATA.meta.agents.find(a=>a.id===id) || {name:id,style:""}; }
function totalsFor(round){
const out={};
for(const p of round.players){
out[p.id]=out[p.id]||{bet:0,net:0};
out[p.id].bet+=p.bet; out[p.id].net+=p.net;
}
return out;
}
function init(){
DATA = window.BATTLE_DATA;
if(!DATA){ $("#app").innerHTML='<div class="err">找不到 battle.json,請用 HTTP 伺服器開啟此頁。</div>'; return; }
document.title = DATA.meta.title + " · d1demo";
$("#app").innerHTML = `
<section class="view active" id="view-battle">
<div class="hero">
<h1>${DATA.meta.title.split(" ")[0]} <em>×</em> d1:21 點決策對戰</h1>
<p>${DATA.meta.subtitle}。每一注、每一次補牌/停牌/加倍,都是模型即時輸出的機率分佈,不是寫死的策略表。</p>
<div class="chips" id="metaChips"></div>
</div>
<div class="scoreboard" id="scoreboard"></div>
<div class="panel table-panel">
<div class="round-toolbar">
<button class="icon-btn" id="prevRound">← 上一局</button>
<div class="round-pills" id="roundPills"></div>
<button class="icon-btn" id="nextRound">下一局 →</button>
<button class="icon-btn" id="playBtn">▶ 自動播放</button>
<span class="round-label" id="roundLabel"></span>
</div>
<div id="roundView"></div>
<div class="arena-note">莊家由固定規則操作(S17),兩個玩家各自連到一個獨立的 llama.cpp server;模型只知道牌面、剩餘牌組成、真數與資金,不知道對手的手牌。</div>
</div>
</section>
<section class="view" id="view-stats"></section>
<section class="view" id="view-code">
<h2 class="section-title">整理程式</h2>
<div class="data-box">
<p>這一頁不是手寫的靜態稿:<b>blackjack.py</b> 負責發牌、呼叫兩個 d1 server 做決策並記錄每一步;<b>build_site.py</b> 把 battle.json 編譯成你現在看到的網站。原始碼同步放在這個 Space 的 <code>source/</code> 目錄。</p>
</div>
<div class="code-tabs" id="codeTabs"></div>
<div class="code-bar">
<span id="codeName">blackjack.py</span>
<a class="btn ghost" id="codeDownload" href="source/blackjack.py" download>下載此檔</a>
<button class="icon-btn" id="copyCode">複製</button>
</div>
<pre class="code"><code id="codeBody">載入中…</code></pre>
</section>
<section class="view" id="view-data">
<h2 class="section-title">原始資料</h2>
<div class="data-box">
<h3>battle.json</h3>
<p>完整對戰紀錄:每一局的牌、注碼、決策機率、信心度、風險評分、延遲、莊家補牌與資金變化。</p>
<p><a class="btn" href="battle.json" download>下載 battle.json</a>
<a class="btn ghost" href="battle.json" target="_blank" rel="noopener">在瀏覽器開啟</a></p>
</div>
<div class="data-box">
<h3>重現方式</h3>
<p>需要 llama.cpp b11786 以上版本、LiquidAI/d1-3B-GGUF(Q8_0)與 Python 3。</p>
<pre class="code"><code id="repro"></code></pre>
</div>
<div class="data-box">
<h3>本次執行摘要</h3>
<pre class="code"><code id="metaJson"></code></pre>
</div>
</section>
<footer style="border:0;padding-bottom:0"></footer>
`;
renderChips();
renderScoreboard();
renderPills();
renderRound();
renderStats();
renderCodeTabs();
bind();
}
function renderChips(){
const m=DATA.meta;
const chips=[
["模型",m.model],["量化",m.quant],["llama.cpp",m.llama_build],
["局數",m.rounds_played],["隨機種子",m.seed],["耗時",m.duration_sec+"s"],
["賽制","打到歸零"],["莊家","S17"],["BJ 賠率","3:2"]
];
$("#metaChips").innerHTML=chips.map(([k,v])=>`<span class="chip">${k} <b>${v}</b></span>`).join("");
}
function renderScoreboard(){
const agents=DATA.meta.agents;
$("#scoreboard").innerHTML=agents.map(a=>{
const profit=a.final_bankroll-DATA.meta.start_bankroll;
const color=agentColor(a.id);
const status=a.bust_round?`第 ${a.bust_round} 局破產`:`出賽 ${a.rounds_played} 局`;
return `<div class="score" style="--accent:${color}">
<div class="id">PLAYER ${a.id.toUpperCase()}</div>
<div class="name">${a.name}</div>
<div class="style">${a.style}</div>
<div class="bank" style="color:${color}">${a.final_bankroll.toLocaleString("en-US")}</div>
<div class="sub">起始 ${DATA.meta.start_bankroll} · 峰值 ${a.peak_bankroll} · 損益 <span class="big-delta ${cls(profit)}">${money(profit)}</span></div>
<div class="sub">${status} · ${a.bust_round?"資金歸零,破產出局":"仍在牌桌上"}</div>
</div>`;
}).join("");
}
function roundTone(r){
const res=r.players.map(p=>p.result);
const wins=res.filter(x=>x==="win"||x==="blackjack").length;
const loses=res.filter(x=>x==="lose").length;
if(wins===res.length) return "win";
if(loses===res.length) return "lose";
if(res.every(x=>x==="push")) return "push";
return "mixed";
}
function renderPills(){
$("#roundPills").innerHTML=DATA.rounds.map((r,i)=>{
return `<button class="pill ${roundTone(r)} ${i===roundIdx?"active":""}" data-round="${i}" title="第 ${r.n} 局">
<span class="dot"></span>${r.n}</button>`;
}).join("");
$("#roundPills").querySelectorAll(".pill").forEach(b=>b.onclick=()=>{stopAuto();roundIdx=+b.dataset.round;dealerOpen=true;renderRound();renderPills();});
}
function stepHTML(step, i){
const d=step.decision||{};
const probs=d.probabilities||{};
const keys=Object.keys(probs).sort((a,b)=>probs[b]-probs[a]);
const risk=step.risk;
const riskText=risk ? `風險評分 <b>${risk.score.toFixed(2)}</b>/3 · 信心 ${pct(risk.confidence)}` : "";
const drawn=step.drawn ? `補到 <b>${step.drawn}</b> → <b>${step.total_after}</b> 點` : (d.choice==="stand"?"維持 <b>"+step.total+"</b> 點":`→ <b>${step.total_after}</b> 點`);
return `<li class="step">
<div class="step-head">
<span class="step-no">#${i+1}</span>
<span class="mini">${step.cards.join(" ")}</span>
<span class="mini" style="color:var(--muted)">(${step.total}${step.soft?" 軟":""})</span>
<span class="action ${LABEL[d.choice]?d.choice:"unknown"}">${LABEL[d.choice]||d.choice||"?"}</span>
<span class="meta">信心 ${pct(d.confidence)} · ${((step.latency_ms||0)/1000).toFixed(2)}s</span>
</div>
<div class="probs">
${keys.map(k=>`<span>${LABEL[k]||k}</span><span class="bar"><i style="--c:var(--${k==="hit"?"hit":k==="stand"?"stand":"double"});width:${(probs[k]*100).toFixed(1)}%"></i></span><span class="pct">${pct(probs[k])}</span>`).join("")}
</div>
<div class="step-foot"><span>${drawn}</span>${riskText?`<span>${riskText}</span>`:""}${d.fallback?`<span style="color:var(--red)">伺服器無回應,改用規則備援</span>`:""}</div>
</li>`;
}
function playerHTML(p){
const steps=(p.steps||[]).map(stepHTML).join("");
const bd=p.bet_decision||{};
const betDecision=bd.decision||{};
const betProbs=betDecision.probabilities||{};
const betLabel=LABEL[betDecision.choice]||betDecision.choice||"";
return `<article class="player ${p.result} ${p.blackjack?"blackjack":""}" style="--accent:${agentColor(p.id)}">
<div class="p-head">
<div><h3>${p.name}</h3><div class="style">${p.style||""}</div></div>
<div class="bet">注碼 <b>${p.bet}</b>${p.doubled?" · 已加倍":""}</div>
</div>
<div class="hand">
${cardsHTML(p.cards)}
<div class="total">${p.total}<small>${p.bust?"BUST":p.soft?"軟牌":p.blackjack?"BJ":"硬牌"}</small></div>
<span class="result ${p.result}">${RESULT_LABEL[p.result]||p.result}</span>
</div>
<div class="bet-line">
<span class="tag">注碼決策 ${betLabel}</span>
${p.rebuy?`<span class="tag" style="color:var(--amber)">補充籌碼 +${p.rebuy}</span>`:""}
<span>信心 ${pct(betDecision.confidence)}</span>
${Object.keys(betProbs).length?`<span>${Object.keys(betProbs).sort((a,b)=>betProbs[b]-betProbs[a]).map(k=>`${LABEL[k]||k} ${pct(betProbs[k])}`).join(" · ")}</span>`:""}
</div>
<details><summary>看模型怎麼決定注碼</summary>
<div class="body">狀態包含資金、剩余牌、Hi-Lo 真數與規則;模型在 min / standard / max 三種注碼中挑一個,並另外給出信心評分。</div>
</details>
${steps?`<ol class="steps">${steps}</ol>`:`<div class="step-foot" style="margin-top:12px">莊家直接 Blackjack,玩家沒有動作機會。</div>`}
<div class="net"><span>本局結果</span><span class="amt ${cls(p.net)}">${money(p.net)} <small style="font-size:12px;color:var(--muted)">→ ${p.bankroll_after}</small></span></div>
</article>`;
}
function brokeHTML(a){
const m=agentMeta(a.id);
const round=a.bust_round?`第 ${a.bust_round} 局`:"本局";
return `<article class="player lose" style="--accent:${agentColor(a.id)}">
<div class="p-head">
<div><h3>${a.name}</h3><div class="style">${a.style||""}</div></div>
<div class="bet">注碼 <b>0</b></div>
</div>
<div class="hand">
<div class="total">0<small>BROKE</small></div>
<span class="result lose">已破產</span>
</div>
<div class="step-foot" style="margin-top:12px">${round}資金歸零,之後無法再下注;只能在桌邊看對手打完。</div>
<div class="net"><span>本局結果</span><span class="amt neutral">—</span></div>
</article>`;
}
function renderRound(){
const r=DATA.rounds[roundIdx];
if(!r){ return; }
const d=r.dealer||{};
const hideHole=!dealerOpen && r.dealer_revealed;
const dealerCards=(d.cards||[]).map((c,i)=>cardHTML(c, hideHole && i===1)).join("");
const totals=totalsFor(r);
const pillTone=roundTone(r);
$("#roundLabel").textContent=`第 ${r.n} / ${DATA.rounds.length} 局 · 剩餘 ${r.shoe_before.remaining_cards} 張 · 真數 ${r.shoe_before.true_count}`;
$("#roundView").innerHTML=`
<div class="round-head">
<h2>第 ${r.n} 局</h2>
<span class="shoe">洗牌前剩餘 ${r.shoe_before.remaining_cards} 張(穿透率 ${r.shoe_before.penetration_pct}%) · Hi-Lo 真數 ${r.shoe_before.true_count}</span>
</div>
<div class="dealer">
<div>
<div class="label">DEALER · 莊家</div>
<div class="total-badge">${hideHole?"?":d.total}${d.bust?' <span style="font-size:12px;color:var(--red)">BUST</span>':""}${d.blackjack?' <span style="font-size:12px;color:var(--gold)">BJ</span>':""}</div>
</div>
<div class="cards">${dealerCards}</div>
<div class="note">${r.events.join(" · ")||"莊家依規則補牌"}</div>
${r.dealer_revealed?`<button class="icon-btn" id="revealBtn">${dealerOpen?"蓋回暗牌":"揭開暗牌"}</button>`:""}
</div>
<div class="players">${DATA.meta.agents.map(a=>{
const p=r.players.find(x=>x.id===a.id);
return p?playerHTML(p):brokeHTML(a);
}).join("")}</div>
<div class="events"><b>牌桌事件</b><ul>${r.events.map(e=>`<li>${e}</li>`).join("")}</ul></div>
`;
const rb=$("#revealBtn");
if(rb) rb.onclick=()=>{dealerOpen=!dealerOpen;renderRound();};
$("#prevRound").disabled=roundIdx===0;
$("#nextRound").disabled=roundIdx===DATA.rounds.length-1;
document.querySelectorAll(".pill").forEach((b,i)=>b.classList.toggle("active",i===roundIdx));
}
function bind(){
$("#prevRound").onclick=()=>{stopAuto(); if(roundIdx>0){roundIdx--;dealerOpen=true;renderRound();}};
$("#nextRound").onclick=()=>{stopAuto(); if(roundIdx<DATA.rounds.length-1){roundIdx++;dealerOpen=true;renderRound();}};
$("#playBtn").onclick=()=>{
if(autoTimer){stopAuto();return;}
roundIdx=0;dealerOpen=true;renderRound();
$("#playBtn").textContent="⏸ 暫停";
autoTimer=setInterval(()=>{
if(roundIdx>=DATA.rounds.length-1){stopAuto();return;}
roundIdx++;dealerOpen=true;renderRound();
},4000);
};
document.querySelectorAll("nav button").forEach(b=>{
b.onclick=()=>{
document.querySelectorAll("nav button").forEach(x=>x.classList.toggle("active",x===b));
document.querySelectorAll(".view").forEach(v=>v.classList.toggle("active",v.id==="view-"+b.dataset.view));
window.scrollTo({top:0,behavior:"smooth"});
};
});
}
function stopAuto(){ if(autoTimer){clearInterval(autoTimer);autoTimer=null;} const b=$("#playBtn"); if(b)b.textContent="▶ 自動播放"; }
function statsFor(id){
const s={rounds:0,win:0,lose:0,push:0,bj:0,bust:0,double:0,net:0,wagered:0,steps:0,conf:0,lat:0,actions:{hit:0,stand:0,double:0},firstActions:[]};
for(const r of DATA.rounds){
const p=r.players.find(x=>x.id===id); if(!p) continue;
s.rounds++;
if(p.result==="win")s.win++;
else if(p.result==="lose")s.lose++;
else if(p.result==="push")s.push++;
else if(p.result==="blackjack")s.win++;
if(p.blackjack)s.bj++;
if(p.bust)s.bust++;
if(p.doubled)s.double++;
s.net+=p.net; s.wagered+=p.bet;
(p.steps||[]).forEach(st=>{
s.steps++; s.conf+=st.decision.confidence||0; s.lat+=st.latency_ms||0;
s.actions[st.decision.choice]=(s.actions[st.decision.choice]||0)+1;
});
const p1=(p.steps||[])[0];
if(p1) s.firstActions.push({round:r.n,action:p1.decision.choice});
}
s.avgConf=s.steps?s.conf/s.steps:0;
s.avgLat=s.steps?s.lat/s.steps:0;
return s;
}
function renderStats(){
const a=statsFor("a"), b=statsFor("b");
const agree=a.firstActions.filter(x=>{
const y=b.firstActions.find(z=>z.round===x.round);
return y && y.action===x.action;
}).length;
const compared=a.firstActions.filter(x=>b.firstActions.some(z=>z.round===x.round)).length;
const dealerBust=DATA.rounds.filter(r=>r.dealer&&r.dealer.bust).length;
const dealerBj=DATA.rounds.filter(r=>r.dealer&&r.dealer.blackjack).length;
const dealerAvg=DATA.rounds.reduce((sum,r)=>sum+((r.dealer||{}).total||0),0)/Math.max(DATA.rounds.length,1);
const totalNet=a.net+b.net;
const houseEdge=totalNet;
function statCard(id,s){
const m=agentMeta(id);
const totalActions=Math.max(s.steps,1);
return `<div class="stat-card" style="--accent:${agentColor(id)}">
<h3>${m.name}</h3><div class="style">${m.style}</div>
<div class="kv">
<div><div class="k">損益</div><div class="v ${cls(s.net)}">${money(s.net)}</div></div>
<div><div class="k">勝 / 負 / 和</div><div class="v">${s.win} / ${s.lose} / ${s.push}</div></div>
<div><div class="k">Blackjack</div><div class="v">${s.bj}</div></div>
<div><div class="k">爆牌</div><div class="v">${s.bust}</div></div>
<div><div class="k">加倍次數</div><div class="v">${s.double}</div></div>
<div><div class="k">總下注</div><div class="v">${s.wagered}</div></div>
<div><div class="k">出賽局數</div><div class="v">${m.rounds_played}</div></div>
<div><div class="k">峰值資金</div><div class="v">${m.peak_bankroll}</div></div>
<div><div class="k">破產局數</div><div class="v">${m.bust_round?"第 "+m.bust_round+" 局":"未破產"}</div></div>
<div><div class="k">平均信心</div><div class="v">${pct(s.avgConf)}</div></div>
<div><div class="k">平均推理</div><div class="v">${(s.avgLat/1000).toFixed(2)}s</div></div>
</div>
<div class="dist">
<div class="mini-title">動作分佈(${s.steps} 次)</div>
${["hit","stand","double"].map(k=>`<div class="row"><span>${LABEL[k]}</span><span class="bar"><i style="--c:var(--${k});width:${(100*s.actions[k]/totalActions).toFixed(1)}%"></i></span><span class="pct">${s.actions[k]}</span></div>`).join("")}
</div>
</div>`;
}
const chart=bankrollChart();
$("#view-stats").innerHTML=`
<h2 class="section-title">決策統計</h2>
<div class="stats-grid">${statCard("a",a)}${statCard("b",b)}</div>
<div class="chart-wrap">
<div class="chart-legend">
<span><i style="background:var(--green)"></i>${agentMeta("a").name}</span>
<span><i style="background:var(--blue)"></i>${agentMeta("b").name}</span>
<span>起始資金 ${DATA.meta.start_bankroll}</span>
</div>
${chart}
</div>
<div class="h2h">
<h3 style="margin:0 0 6px">兩個 d1 有多像?</h3>
<p style="color:var(--muted);margin:4px 0 10px">比較每一局雙方第一次動作(同一副牌、同一個莊家明牌、相同的剩餘牌資訊)。</p>
<div class="big">${compared?Math.round(100*agree/compared):0}%</div>
<div style="color:var(--muted);font-size:13px">首個動作一致率 · 共比較 ${compared} 局</div>
<div class="kv" style="grid-template-columns:repeat(3,1fr);margin-top:16px">
<div><div class="k">莊家爆牌率</div><div class="v">${Math.round(100*dealerBust/DATA.rounds.length)}%</div></div>
<div><div class="k">莊家 Blackjack</div><div class="v">${dealerBj} 局</div></div>
<div><div class="k">莊家平均點數</div><div class="v">${dealerAvg.toFixed(1)}</div></div>
</div>
</div>
<div class="data-box" style="margin-top:14px">
<h3>終局:牌桌關閉</h3>
<p style="font-size:15px">${DATA.meta.end_reason==="both_broke"?"兩邊資金都歸零,遊戲在第 "+DATA.meta.rounds_played+" 局結束。": "達到局數上限,遊戲結束。"}
${DATA.meta.agents.map(a=>`${a.name} 在第 ${a.bust_round||"—"} 局破產(峰值 ${a.peak_bankroll})`).join(" · ")}</p>
</div>
<div class="data-box" style="margin-top:14px">
<h3>兩個模型合計對抗莊家</h3>
<p style="font-size:15px">總損益 <b class="${cls(totalNet)}" style="font-size:24px">${money(totalNet)}</b> · 每位玩家起始 ${DATA.meta.start_bankroll},合計起始 ${DATA.meta.start_bankroll*2}</p>
</div>
`;
}
function bankrollChart(){
const W=760,H=230,pad={l:52,r:14,t:14,b:26};
const agents=DATA.meta.agents;
const series=agents.map(a=>{
const vals=[DATA.meta.start_bankroll];
for(const r of DATA.rounds){
const p=r.players.find(x=>x.id===a.id);
vals.push(p?p.bankroll_after:vals[vals.length-1]);
}
return {a,vals,color:a.id==="a"?"#39d98a":"#5cc8ff"};
});
const all=series.flatMap(s=>s.vals);
let min=Math.min(...all),max=Math.max(...all);
const span=Math.max(max-min,100);
min=Math.max(0,Math.floor((min-span*0.1)/50)*50); max=Math.ceil((max+span*0.1)/50)*50;
const X=i=>pad.l+(W-pad.l-pad.r)*(i/(DATA.rounds.length||1));
const Y=v=>H-pad.b-(H-pad.t-pad.b)*((v-min)/(max-min||1));
const step=Math.max(50,Math.ceil((max-min)/4/50)*50);
min=Math.floor(min/step)*step; max=Math.ceil(max/step)*step;
const ticks=Math.round((max-min)/step);
let grid="";
for(let i=0;i<=ticks;i++){
const v=min+(max-min)*i/ticks, y=Y(v);
grid+=`<line x1="${pad.l}" y1="${y}" x2="${W-pad.r}" y2="${y}" stroke="rgba(255,255,255,.07)"/>`;
grid+=`<text x="${pad.l-8}" y="${y+4}" fill="#8fae9f" font-size="10" text-anchor="end">${Math.round(v)}</text>`;
}
const paths=series.map(s=>{
const d=s.vals.map((v,i)=>`${i?"L":"M"}${X(i).toFixed(1)},${Y(v).toFixed(1)}`).join(" ");
const dots=s.vals.map((v,i)=>`<circle cx="${X(i)}" cy="${Y(v)}" r="2.6" fill="${s.color}"><title>第 ${i} 局後:${v}</title></circle>`).join("");
return `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2.4" stroke-linejoin="round"/>${dots}`;
}).join("");
const stepX=Math.max(1,Math.ceil(DATA.rounds.length/8));
let xlabels="";
for(let i=0;i<=DATA.rounds.length;i+=stepX){
xlabels+=`<text x="${X(i)}" y="${H-6}" fill="#8fae9f" font-size="10" text-anchor="middle">${i}</text>`;
}
return `<svg viewBox="0 0 ${W} ${H}" width="100%" role="img" aria-label="資金曲線">${grid}${paths}${xlabels}</svg>`;
}
const CODE_FILES=[
{file:"blackjack.py",label:"blackjack.py · 對戰與記錄"},
{file:"build_site.py",label:"build_site.py · 網頁編譯"},
{file:"run_demo.sh",label:"run_demo.sh · 一鍵執行"},
];
function renderCodeTabs(){
$("#codeTabs").innerHTML=CODE_FILES.map((f,i)=>`<button data-file="${f.file}" class="${i===0?"active":""}">${f.label}</button>`).join("");
$("#codeTabs").querySelectorAll("button").forEach(b=>b.onclick=()=>{
$("#codeTabs").querySelectorAll("button").forEach(x=>x.classList.toggle("active",x===b));
loadCode(b.dataset.file);
});
loadCode("blackjack.py");
$("#copyCode").onclick=async()=>{
try{await navigator.clipboard.writeText($("#codeBody").textContent);$("#copyCode").textContent="已複製";setTimeout(()=>$("#copyCode").textContent="複製",1200);}catch(e){}
};
$("#repro").textContent=`# 1) 下載模型與 llama.cpp(CPU 版)
llama-server -m d1-3B-Q8_0.gguf --port 8081 -c 2048 -np 1 --no-webui &
llama-server -m d1-3B-Q8_0.gguf --port 8082 -c 2048 -np 1 --no-webui &
# 2) 讓兩個 d1 打 12 局,並把每一步寫成 JSON
python3 blackjack.py --rounds 12 --seed ${DATA.meta.seed} --out battle.json
# 3) 把 JSON 編譯成這個靜態網站
python3 build_site.py --battle battle.json --out site
# 4) 上傳 static Space
python3 - <<'PY'
from huggingface_hub import HfApi
HfApi().upload_folder(folder_path="site", repo_id="HelloSun/d1demo", repo_type="space")
PY`;
$("#metaJson").textContent=JSON.stringify(DATA.meta,null,2);
}
function loadCode(file){
$("#codeName").textContent=file;
$("#codeDownload").href="source/"+file;
$("#codeBody").textContent="載入中…";
fetch("source/"+file).then(r=>{if(!r.ok)throw new Error(r.status);return r.text();})
.then(t=>{$("#codeBody").textContent=t;})
.catch(()=>{$("#codeBody").textContent="(無法載入 "+file+",請確認 source/ 目錄存在)";});
}
init();
</script>
</body>
</html>
"""
README = """---
title: d1demo
emoji: 🃏
colorFrom: green
colorTo: yellow
sdk: static
pinned: false
license: mit
---
# d1 × d1 21 點對戰實錄
兩個 **LiquidAI d1-3B** 決策模型,各自跑在一個 llama.cpp server 上,在同一張 21 點牌桌上對打。每一次下注、補牌、停牌、加倍,都是模型輸出的機率分佈;這頁把每一步整理成可回放的視覺化紀錄。
- 模型:`LiquidAI/d1-3B-GGUF` (Q8_0),llama.cpp b11786
- API:`POST /v1/systemone`(d1 是 decision model,不是文字生成模型)
- 賽制:破產對決,注碼為當前資金的 50% / 70% / 100%,無補充籌碼,直到兩邊都歸零
- 莊家:規則操作,S17,6 副牌,BJ 3:2
- 內容:對戰時間軸、決策機率、風險評分、信心度、延遲、資金曲線、破產統計
- 原始碼:`source/blackjack.py`(對戰 + 記錄)、`source/build_site.py`(靜態網頁編譯)
- 原始資料:`battle.json`
頁面是靜態的,所有資料都在 `battle.json`;`build_site.py` 可重新編譯整站。
"""
def build(battle_path, out_dir):
data = json.loads(Path(battle_path).read_text(encoding="utf-8"))
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
(out / "source").mkdir(exist_ok=True)
(out / "battle.json").write_text(
json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8"
)
shutil.copy(battle_path, out / "battle.json")
for name in ("blackjack.py", "build_site.py", "run_demo.sh"):
src = ROOT / name
if src.exists():
shutil.copy(src, out / "source" / name)
inline = "window.BATTLE_DATA = " + json.dumps(data, ensure_ascii=False) + ";"
html = HTML.replace("__TITLE__", data["meta"]["title"]).replace(
"__SUBTITLE__", data["meta"]["subtitle"]
)
html = html.replace('<script>\nconst SUIT_RED', '<script>\n' + inline + '\nconst SUIT_RED')
(out / "index.html").write_text(html, encoding="utf-8")
(out / "README.md").write_text(README, encoding="utf-8")
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
print(f"[{stamp}] built {out/'index.html'} ({len(html)/1024:.1f} KiB), "
f"battle.json {len(json.dumps(data, ensure_ascii=False).encode())/1024:.1f} KiB")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--battle", default="battle.json")
ap.add_argument("--out", default="site")
args = ap.parse_args()
build(args.battle, args.out)
if __name__ == "__main__":
main()