Download source/build_site.py from HelloSun/d1demo: direct link, hf CLI and curl.
- Browser
- Download file 39.9 kB
-
https://huggingface.co/spaces/HelloSun/d1demo/resolve/main/source/build_site.py
- Command line
-
hf download hf://spaces/HelloSun/d1demo/source/build_site.py
-
curl -L -o build_site.py https://huggingface.co/spaces/HelloSun/d1demo/resolve/main/source/build_site.py
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() | |