d1demo / source /blackjack.py
HelloSun's picture
bankruptcy mode: play until both d1 players hit zero
e9b16be verified
Raw History Blame Contribute Delete
21 kB
#!/usr/bin/env python3
"""d1 vs d1 blackjack battle.
Two llama.cpp servers, each hosting a LiquidAI d1-3B decision model, play
blackjack against a rule-based dealer. Every decision (bet size + hit / stand /
double) is a /v1/systemone query. The complete battle is written as JSON and
rendered into a static web page by build_site.py.
Usage:
python3 blackjack.py --rounds 12 --out battle.json
"""
import argparse
import json
import random
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
SUITS = ["♠", "♥", "♦", "♣"]
RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
RANK_VALUE = {r: (11 if r == "A" else 10 if r in ("10", "J", "Q", "K") else int(r)) for r in RANKS}
DECKS = 6
BASE_BET = 10
START_BANKROLL = 1000
BET_FRACTIONS = {"min": 0.50, "standard": 0.70, "max": 1.00}
AGENTS = [
{
"id": "a",
"name": "阿保",
"style": "保守算牌派",
"description": "以保住本金為第一優先,只有在牌面明顯有利時才加注,遇到風險寧可停牌。",
"url": "http://127.0.0.1:8081",
},
{
"id": "b",
"name": "阿衝",
"style": "激進壓迫派",
"description": "相信進攻是最好的防守,喜歡加倍與大注,願意為了期望值承擔波動。",
"url": "http://127.0.0.1:8082",
},
]
RULES = {
"decks": DECKS,
"dealer": "莊家軟 17 停牌 (S17)",
"blackjack_pays": "3:2",
"double": "僅限硬 9–11 或軟 13–18 時加倍,加倍後只補一張",
"split": "本局不開放分牌",
"insurance": "不開放保險",
"reshuffle": "剩餘牌數低於 25% 時重洗",
"start_bankroll": START_BANKROLL,
"betting": "注碼依當前資金比例:小注 50%、大注 70%、全押 100%(取整到 10)",
"rebuy": "無補充籌碼,兩邊都歸零才結束",
}
def make_shoe(rng):
shoe = [(r, s) for _ in range(DECKS) for s in SUITS for r in RANKS]
rng.shuffle(shoe)
return shoe
def card_str(card):
return f"{card[0]}{card[1]}"
def hand_value(cards):
total = sum(RANK_VALUE[r] for r, _ in cards)
soft_aces = sum(1 for r, _ in cards if r == "A")
while total > 21 and soft_aces:
total -= 10
soft_aces -= 1
return total, soft_aces > 0
def is_blackjack(cards):
return len(cards) == 2 and hand_value(cards)[0] == 21
def hi_lo(card):
r = card[0]
if r in ("2", "3", "4", "5", "6"):
return 1
if r in ("10", "J", "Q", "K", "A"):
return -1
return 0
def shoe_state(shoe, running_count):
remaining = len(shoe)
decks_left = max(remaining / 52.0, 0.15)
counts = {r: 0 for r in RANKS}
for r, _ in shoe:
counts[r] += 1
return {
"decks": DECKS,
"remaining_cards": remaining,
"penetration_pct": round(100 * (1 - remaining / (DECKS * 52))),
"cards_left_by_rank": counts,
"hi_lo_running_count": running_count,
"true_count": round(running_count / decks_left, 2),
}
class D1Client:
def __init__(self, agent):
self.agent = agent
self.url = agent["url"].rstrip("/")
self.calls = 0
self.total_ms = 0
def ask(self, state, questions, retries=2):
payload = json.dumps({"state": state, "questions": questions}).encode()
last_err = None
for _ in range(retries + 1):
started = time.time()
req = urllib.request.Request(
f"{self.url}/v1/systemone",
data=payload,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
body = json.loads(resp.read())
elapsed = int((time.time() - started) * 1000)
self.calls += 1
self.total_ms += elapsed
return body["answers"], elapsed, None
except Exception as exc: # noqa: BLE001
last_err = f"{type(exc).__name__}: {exc}"
time.sleep(0.4)
return None, 0, last_err
def pick(answers, key, valid):
ans = (answers or {}).get(key) or {}
choice = ans.get("choice")
if choice not in valid:
return None
return ans
def draw(shoe, count):
cards = [shoe.pop() for _ in range(min(count, len(shoe)))]
return cards
def fallback_action(total, soft, allowed):
if "double" in allowed and total in (10, 11):
return "double"
if soft and total >= 19:
return "stand"
if total >= 17:
return "stand"
return "hit"
def play_hand(client, shoe, player, hand, dealer_up, running_count, rng):
steps = []
while True:
total, soft = hand_value(hand["cards"])
if total >= 21:
break
allowed = ["hit", "stand"]
double_spot = (not soft and 9 <= total <= 11) or (soft and 13 <= total <= 18)
if len(hand["cards"]) == 2 and player["bankroll"] >= hand["bet"] and double_spot:
allowed.append("double")
state = {
"game": "blackjack",
"you": {
"name": client.agent["name"],
"style": client.agent["style"],
"personality": client.agent["description"],
"bankroll": player["bankroll"],
},
"hand": {
"cards": [card_str(c) for c in hand["cards"]],
"total": total,
"soft": soft,
"is_pair": len(hand["cards"]) == 2 and hand["cards"][0][0] == hand["cards"][1][0],
},
"dealer_upcard": card_str(dealer_up),
"bet": hand["bet"],
"allowed_actions": allowed,
"shoe": shoe_state(shoe, running_count),
"rules": RULES,
}
criteria = {
"hit": "再補一張牌:點數偏低、需要往 17 點以上推進時使用",
"stand": "停牌:點數已接近 21,或再補牌爆牌風險過高時使用",
}
if "double" in allowed:
criteria["double"] = "加倍下注:再多押一倍且只補一張,通常用在 9、10、11 點這種有把握的局面"
cards_text = " ".join(card_str(c) for c in hand["cards"])
questions = {
"action": {
"type": "choice",
"instructions": (
f"你是 {client.agent['name']},風格「{client.agent['style']}」:"
f"{client.agent['description']} "
"你在玩 21 點,目標是讓手牌點數盡量接近 21 但不超過,並贏過莊家。"
f"你的手牌是 {cards_text},合計 {total} 點"
f"({'軟牌,A 可算 11 或 1' if soft else '硬牌'})。"
f"莊家明牌是 {card_str(dealer_up)}。現在只能選一個動作,請選長期期望值最高的。"
),
"criteria": criteria,
},
"risk": {
"type": "score",
"instructions": "以你的風格判斷,現在這個局面對你有多不利?",
"criteria": ["非常有利", "稍微有利", "有點不利", "非常不利"],
},
}
answers, latency, err = client.ask(state, questions)
decision = pick(answers, "action", allowed)
if decision is None:
if not allowed:
break
action = fallback_action(total, soft, allowed)
decision = {
"type": "choice",
"choice": action,
"probabilities": {a: (1.0 if a == action else 0.0) for a in allowed},
"confidence": 0.0,
"fallback": True,
"error": err,
}
action = decision["choice"]
step = {
"type": "action",
"cards": [card_str(c) for c in hand["cards"]],
"total": total,
"soft": soft,
"dealer_up": card_str(dealer_up),
"allowed": allowed,
"decision": decision,
"risk": (answers or {}).get("risk"),
"latency_ms": latency,
"drawn": None,
"total_after": total,
}
steps.append(step)
if action == "stand":
break
if action == "double":
player["bankroll"] -= hand["bet"]
hand["bet"] *= 2
hand["doubled"] = True
new_card = draw(shoe, 1)[0]
running_count += hi_lo(new_card)
step["drawn"] = card_str(new_card)
hand["cards"].append(new_card)
hand["total"], hand["soft"] = hand_value(hand["cards"])
step["total_after"] = hand["total"]
break
new_card = draw(shoe, 1)[0]
running_count += hi_lo(new_card)
step["drawn"] = card_str(new_card)
hand["cards"].append(new_card)
hand["total"], hand["soft"] = hand_value(hand["cards"])
step["total_after"] = hand["total"]
if hand["total"] > 21:
break
hand["total"], hand["soft"] = hand_value(hand["cards"])
hand["bust"] = hand["total"] > 21
hand["blackjack"] = is_blackjack(hand["cards"]) and not hand["doubled"]
return running_count, steps
def settle(hand, dealer_total, dealer_blackjack, dealer_bust):
if hand["bust"]:
return "lose", -hand["bet"]
if hand["blackjack"] and not dealer_blackjack:
return "blackjack", int(hand["bet"] * 1.5)
if dealer_blackjack and not hand["blackjack"]:
return "lose", -hand["bet"]
if dealer_bust:
return "win", hand["bet"]
if hand["total"] > dealer_total:
return "win", hand["bet"]
if hand["total"] < dealer_total:
return "lose", -hand["bet"]
return "push", 0
def ask_bet(client, player, dealer_up_hint, shoe, running_count):
state = {
"game": "blackjack",
"you": {
"name": client.agent["name"],
"style": client.agent["style"],
"personality": client.agent["description"],
"bankroll": player["bankroll"],
},
"phase": "betting",
"base_bet": BASE_BET,
"shoe": shoe_state(shoe, running_count),
"rules": RULES,
}
questions = {
"bet": {
"type": "choice",
"instructions": (
f"你是 {client.agent['name']},風格是「{client.agent['style']}」:"
f"{client.agent['description']} 新的一局要下注了,"
"請依資金、剩餘牌組成與真數決定注碼。"
),
"criteria": {
"min": "小注:押上目前資金的一半",
"standard": "大注:押上目前資金的 70%",
"max": "全押:把目前資金全部押上",
},
},
"confidence": {
"type": "score",
"instructions": "你對這一注的信心有多高?",
"criteria": ["沒信心", "普通", "有信心", "非常有信心"],
},
}
answers, latency, err = client.ask(state, questions)
decision = pick(answers, "bet", ["min", "standard", "max"])
if decision is None:
decision = {
"choice": "min",
"probabilities": {"min": 1.0, "standard": 0.0, "max": 0.0},
"confidence": 0.0,
"fallback": True,
"error": err,
}
bankroll = player["bankroll"]
fraction = BET_FRACTIONS.get(decision["choice"], 0.50)
amount = int(bankroll * fraction)
if amount >= BASE_BET:
amount = max(BASE_BET, int(round(amount / 10.0)) * 10)
bet = min(bankroll, max(amount, BASE_BET if bankroll >= BASE_BET else bankroll))
if bet < BASE_BET:
bet = bankroll
if bet <= 0:
bet = bankroll
return {
"decision": decision,
"bet": max(bet, 0),
"confidence": (answers or {}).get("confidence"),
"latency_ms": latency,
}
def run(rounds, seed, out_path, until_broke=True, max_rounds=500, start_bankroll=START_BANKROLL):
rng = random.Random(seed)
shoe = make_shoe(rng)
running_count = 0
players = [
{"id": a["id"], "name": a["name"], "style": a["style"], "bankroll": start_bankroll}
for a in AGENTS
]
peaks = {p["id"]: start_bankroll for p in players}
bust_round = {}
clients = {a["id"]: D1Client(a) for a in AGENTS}
started = time.time()
history = []
limit = max_rounds if until_broke else rounds
n = 0
while True:
if until_broke and all(p["bankroll"] <= 0 for p in players):
break
if n >= limit:
break
n += 1
if len(shoe) < DECKS * 52 * 0.25:
shoe = make_shoe(rng)
running_count = 0
active = [a for a in AGENTS if next(p for p in players if p["id"] == a["id"])["bankroll"] > 0]
events = []
for a in AGENTS:
if a not in active:
player = next(p for p in players if p["id"] == a["id"])
events.append(f"{player['name']} 資金為 0,本局無法下注")
bets = {}
for a in active:
player = next(p for p in players if p["id"] == a["id"])
bets[a["id"]] = ask_bet(clients[a["id"]], player, None, shoe, running_count)
hands = {}
for a in active:
player = next(p for p in players if p["id"] == a["id"])
hands[a["id"]] = {
"cards": draw(shoe, 2),
"bet": bets[a["id"]]["bet"],
"doubled": False,
}
running_count += sum(hi_lo(c) for c in hands[a["id"]]["cards"])
player["bankroll"] -= hands[a["id"]]["bet"]
dealer_cards = draw(shoe, 2)
running_count += sum(hi_lo(c) for c in dealer_cards)
dealer_up, dealer_hole = dealer_cards[0], dealer_cards[1]
dealer_blackjack = is_blackjack(dealer_cards)
peek = RANK_VALUE[dealer_up[0]] in (10, 11)
if peek:
events.append("莊家明牌為 A 或 10 點,進行暗牌確認")
round_rec = {
"n": n,
"shoe_before": shoe_state(shoe, running_count),
"dealer_up": card_str(dealer_up),
"dealer_hole": card_str(dealer_hole),
"dealer_revealed": False,
"dealer": None,
"events": events,
"players": [],
}
if dealer_blackjack:
events.append("莊家直接拿到 Blackjack,本局立即結束")
round_rec["dealer_revealed"] = True
round_rec["dealer"] = {
"cards": [card_str(c) for c in dealer_cards],
"total": 21,
"soft": hand_value(dealer_cards)[1],
"blackjack": True,
"bust": False,
}
else:
for a in active:
hand = hands[a["id"]]
player = next(p for p in players if p["id"] == a["id"])
running_count, steps = play_hand(
clients[a["id"]], shoe, player, hand, dealer_up, running_count, rng
)
hand["steps"] = steps
hand["player_id"] = a["id"]
if not dealer_blackjack:
dealer_total, dealer_soft = hand_value(dealer_cards)
if any(not h["bust"] for h in hands.values()):
if peek:
events.append("確認結果:莊家沒有 Blackjack")
while dealer_total < 17:
card = draw(shoe, 1)[0]
running_count += hi_lo(card)
dealer_cards.append(card)
dealer_total, dealer_soft = hand_value(dealer_cards)
events.append(f"莊家補牌 {card_str(card)} → {dealer_total} 點")
events.append(f"莊家最終 {dealer_total} 點" + (",爆牌" if dealer_total > 21 else ""))
else:
events.append("所有玩家都爆牌,莊家不補牌")
round_rec["dealer_revealed"] = True
round_rec["dealer"] = {
"cards": [card_str(c) for c in dealer_cards],
"total": dealer_total,
"soft": dealer_soft,
"blackjack": False,
"bust": dealer_total > 21,
}
dealer_total = round_rec["dealer"]["total"]
dealer_bust = round_rec["dealer"]["bust"]
for a in active:
player = next(p for p in players if p["id"] == a["id"])
hand = hands[a["id"]]
hand["blackjack"] = is_blackjack(hand["cards"]) and not hand["doubled"]
hand["bust"] = hand_value(hand["cards"])[0] > 21
result, net = settle(hand, dealer_total, dealer_blackjack, dealer_bust)
hand.update(
{
"cards": [card_str(c) for c in hand["cards"]],
"total": hand_value(hand["cards"])[0],
"soft": hand_value(hand["cards"])[1],
"blackjack": is_blackjack(hand["cards"]) and not hand["doubled"],
"bust": hand_value(hand["cards"])[0] > 21,
"result": result,
"net": net,
"bet_decision": bets[a["id"]],
}
)
player["bankroll"] += hand["bet"] + net
if player["bankroll"] < 0:
player["bankroll"] = 0
peaks[a["id"]] = max(peaks[a["id"]], player["bankroll"])
hand["bankroll_after"] = player["bankroll"]
hand["id"] = a["id"]
hand["name"] = a["name"]
hand["style"] = a["style"]
hand["peak_bankroll"] = peaks[a["id"]]
if player["bankroll"] == 0 and a["id"] not in bust_round:
bust_round[a["id"]] = n
events.append(f"{player['name']} 資金歸零,破產出局")
round_rec["players"].append(hand)
round_rec["shoe_after"] = shoe_state(shoe, running_count)
history.append(round_rec)
print(
f"round {n:02d} | dealer {dealer_total} | "
+ " | ".join(
f"{h['name']} {h['total']} {h['result']} net {h['net']:+d} bank {h['bankroll_after']}"
for h in round_rec["players"]
),
flush=True,
)
meta = {
"title": "d1 × d1 21 點:破產對決",
"subtitle": "兩個 LiquidAI d1-3B 決策模型,用 llama.cpp 一路打到雙方資金歸零",
"created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"seed": seed,
"model": "LiquidAI/d1-3B-GGUF",
"quant": "Q8_0",
"llama_build": "b11786 (0.7.0-dev)",
"rules": RULES,
"start_bankroll": start_bankroll,
"bet_fractions": BET_FRACTIONS,
"end_reason": "both_broke" if all(p["bankroll"] <= 0 for p in players) else "round_limit",
"agents": [
{
**a,
"final_bankroll": next(p for p in players if p["id"] == a["id"])["bankroll"],
"peak_bankroll": peaks[a["id"]],
"bust_round": bust_round.get(a["id"]),
"rounds_played": sum(1 for r in history if any(x["id"] == a["id"] for x in r["players"])),
}
for a in AGENTS
],
"rounds_played": len(history),
"duration_sec": round(time.time() - started, 2),
"inference": {
a["id"]: {
"calls": clients[a["id"]].calls,
"avg_latency_ms": round(clients[a["id"]].total_ms / max(clients[a["id"]].calls, 1)),
}
for a in AGENTS
},
}
data = {"meta": meta, "rounds": history}
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"wrote {out} ({out.stat().st_size / 1024:.1f} KiB) | end: {meta['end_reason']} | rounds: {len(history)}")
return data
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--rounds", type=int, default=12)
parser.add_argument("--seed", type=int, default=20261021)
parser.add_argument("--out", default="battle.json")
parser.add_argument("--max-rounds", type=int, default=500)
parser.add_argument("--start-bankroll", type=int, default=START_BANKROLL)
parser.add_argument("--fixed-rounds", action="store_true", help="stop after --rounds instead of playing until both are broke")
args = parser.parse_args()
run(args.rounds, args.seed, args.out, until_broke=not args.fixed_rounds,
max_rounds=args.max_rounds, start_bankroll=args.start_bankroll)
if __name__ == "__main__":
main()