Files
drinking-game/server.py
gongch 6d1dd5d56c Initial commit: 酒桌派对 / 大话骰 multiplayer drinking-game hub
- server.py: zero-dependency stdlib backend (HTTP + WebSocket /ws), 大话骰 rule engine, server-driven bots
- live.html: real MVP frontend wired to the backend over WebSocket
- index.html/app.js/style.css: older static prototype
- deploy/: systemd unit + Nginx reverse proxy + Let's Encrypt setup.sh
- test_*.py: standalone HTTP / in-process test scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:05:10 +08:00

788 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import json, random, string, time, hashlib, base64, struct, threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
ROOT = Path(__file__).resolve().parent
GAMES = {"dice":{"name":"大话骰","max":12},"poker":{"name":"德州扑克","max":9},"six":{"name":"六张牌","max":8},"catch":{"name":"抓二游","max":8}}
rooms = {}
BOT_NAMES = ["阿杰","Mia","老陈","可乐","Luna","大熊","小白","阿豪"]
def now_ms(): return int(time.time()*1000)
def new_id(prefix="p"): return prefix + "".join(random.choice(string.ascii_lowercase+string.digits) for _ in range(10))
def room_code():
while True:
code="".join(random.choice(string.ascii_uppercase+string.digits) for _ in range(4))
if code not in rooms: return code
def make_player(name, host=False, player_id=None, bot=False, ready=None):
if ready is None: ready = True if (host or bot) else False
return {"id":player_id or new_id("b" if bot else "p"),"name":(name or "玩家")[:16],"host":host,"bot":bot,"ready":ready,"cups":0,"dice":[],"stats":{"splits":0,"counters":0,"declines":0,"cupsDrunk":0}}
def find_player(room, player_id): return next((p for p in room["players"] if p["id"]==player_id), None)
def require_player(room, player_id):
p=find_player(room, player_id)
if not p: raise ValueError("当前身份不在房间内")
return p
def require_host(room, player_id):
p=require_player(room, player_id)
if not p.get("host"): raise ValueError("只有房主可以操作")
return p
def current_player(room):
if not room["players"]: return None
room["turnIndex"]%=len(room["players"])
return room["players"][room["turnIndex"]]
def min_count(room): return max(1,len(room["players"]))
def max_count(room): return len(room["players"])*5
def call_mode(room,count,old=None,requested=None,point=None):
count = int(count)
if requested in ["",""]:
if requested == "" and int(point or 0) == 1:
return "" # 先保留请求后续校验会明确拒绝叫1不能飞
return requested
if int(point or 0) == 1:
return "" # 未显式选择时,叫 1 默认只能是斋
if not old:
# 仅首叫按人数阈值判断N、N+1 为斋N+2 起飞
return "" if count >= min_count(room)+2 else ""
if old.get("mode") == "":
# 默认继续飞;若要平斋,由前端显式传 mode=斋
return ""
# 斋局后续:未达到上一手数量 2 倍时仍是斋;达到 2 倍才破斋飞
return "" if count >= int(old["count"])*2 else ""
def point_rank(mode, point):
point = int(point)
if mode == "":
return 7 if point == 1 else point # 斋局里 1 最大2<3<4<5<6<1
return point
def count_actual(room,call):
point=int(call["point"]); flying=call.get("mode")==""
return sum(1 for p in room["players"] for d in p["dice"] if d==point or (flying and point!=1 and d==1))
def is_valid_raise(room, old, count, point, mode):
if not old: return True
old_mode = old.get("mode", "")
if old_mode == "":
if mode == "":
return count >= old["count"] * 2
# 继续斋:数量可增加;同数量时按斋点数顺序 2<3<4<5<6<1
if count > old["count"]: return True
if count == old["count"]: return point_rank("", point) > point_rank("", old["point"])
return False
# 上一手已飞
if mode == "":
# 飞后切斋/平斋:不能降数量;同数量或更高数量都可进入斋
return count >= old["count"]
# 继续飞:数量增加,或同数量更大点数
if count > old["count"]: return True
if count == old["count"]: return int(point) > int(old["point"])
return False
def next_call(room,old):
if not old: return {"count":min_count(room),"point":1}
if old.get("mode") == "":
order=[2,3,4,5,6,1]
idx=order.index(old["point"]) if old["point"] in order else 0
if idx < len(order)-1:
return {"count":old["count"],"point":order[idx+1]}
return {"count":old["count"]+1,"point":2}
if old["point"]<6: return {"count":old["count"],"point":old["point"]+1}
return {"count":old["count"]+1,"point":1}
def recent_call_targets(room, count):
seen=set(); items=[]
for entry in reversed(room.get("roundCalls", [])):
pid=entry["playerId"]
if pid in seen: continue
seen.add(pid)
items.append(entry)
if len(items)>=count: break
return items
def add_drink(player, cups):
player["cups"] += cups
player.setdefault("stats",{}).setdefault("cupsDrunk",0)
player["stats"]["cupsDrunk"] += cups
def visible_dice(room, player, viewer_id):
if room["status"]=="result" and room.get("lastResult",{}).get("revealDice", True): return player["dice"]
if room["status"]=="playing" and viewer_id and player["id"]==viewer_id: return player["dice"]
return []
def public_room(room, viewer_id=None):
cur=current_player(room)
return {"code":room["code"],"game":room["game"],"gameName":GAMES[room["game"]]["name"],"maxPlayers":GAMES[room["game"]]["max"],"status":room["status"],"locked":room["locked"],"round":room["round"],"currentCall":room["currentCall"],"turnIndex":room["turnIndex"],"turnPlayerId":cur["id"] if cur else None,"turnPlayerName":cur["name"] if cur else None,"turnPlayerBot":bool(cur and cur.get("bot")),"pendingAction":room.get("pendingAction"),"lastResult":room["lastResult"],"updatedAt":room["updatedAt"],"history":room.get("history",[])[-16:],"settings":room.get("settings",{"cupsPerLoss":1}),"diceRule":{"minCount":min_count(room),"zhaiCounts":[min_count(room),min_count(room)+1],"flyFrom":min_count(room)+2,"maxCount":max_count(room),"zhaiPointOrder":[2,3,4,5,6,1],"breakZhai":"double"},"players":[{"id":p["id"],"name":p["name"],"host":p["host"],"bot":p["bot"],"ready":p["ready"],"cups":p["cups"],"stats":p.get("stats",{}),"dice":visible_dice(room,p,viewer_id)} for p in room["players"]]}
def send_json(h,data,status=200):
body=json.dumps(data,ensure_ascii=False).encode(); h.send_response(status); h.send_header("Content-Type","application/json; charset=utf-8"); h.send_header("Content-Length",str(len(body))); h.send_header("Access-Control-Allow-Origin","*"); h.end_headers(); h.wfile.write(body)
def read_json(h):
n=int(h.headers.get("Content-Length","0") or 0)
return json.loads(h.rfile.read(n).decode() or "{}") if n else {}
def append_history(room, typ, text, player="系统"):
room.setdefault("history",[]).append({"type":typ,"player":player,"text":text,"at":now_ms()})
def transfer_host_if_needed(room):
if any(p.get("host") for p in room["players"]): return
human=next((p for p in room["players"] if not p.get("bot")), None)
target=human or (room["players"][0] if room["players"] else None)
if target:
target["host"]=True; target["ready"]=True; append_history(room,"host",f"{target['name']} 成为新房主",target["name"])
def cleanup_room(code):
room=rooms.get(code)
if room and not any(not p.get("bot") for p in room["players"]):
rooms.pop(code,None)
return True
return False
def create_room(game,name,player_id=None):
game=game if game in GAMES else "dice"; code=room_code(); player=make_player(name,True,player_id,ready=True)
rooms[code]={"code":code,"game":game,"status":"waiting","locked":False,"round":0,"turnIndex":0,"currentCall":None,"pendingAction":None,"lastResult":None,"history":[],"roundCalls":[],"settings":{"cupsPerLoss":1},"players":[player],"updatedAt":now_ms()}
append_history(rooms[code],"system",f"{player['name']} 创建房间",player["name"])
return rooms[code],player
def join_room(code,name,player_id=None):
code=(code or "").upper().strip(); room=rooms.get(code)
if not room: raise ValueError("房间不存在")
existed=find_player(room,player_id) if player_id else None
if existed:
existed["name"]=(name or existed["name"])[:16]; room["updatedAt"]=now_ms(); return room,existed,True
same_name=next((p for p in room["players"] if not p["bot"] and p["name"]==(name or "玩家")[:16]),None)
if same_name:
room["updatedAt"]=now_ms(); return room,same_name,True
if room["locked"]: raise ValueError("房间已暂停加入")
if room["status"]!="waiting": raise ValueError("游戏已开始,暂不能加入;原玩家可重连回房")
if len(room["players"])>=GAMES[room["game"]]["max"]: raise ValueError("房间已满")
player=make_player(name,False,player_id,ready=False); room["players"].append(player); append_history(room,"join",f"{player['name']} 加入房间",player["name"]); room["updatedAt"]=now_ms(); return room,player,False
def set_ready(code, player_id, ready=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room["status"]=="playing": raise ValueError("游戏中不能切换准备状态")
p=require_player(room,player_id)
p["ready"] = (not p["ready"]) if ready is None else bool(ready)
append_history(room,"ready",f"{p['name']} {'已准备' if p['ready'] else '取消准备'}",p["name"])
room["updatedAt"]=now_ms(); return room
def add_bot(code, player_id=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if player_id: require_host(room,player_id)
if room["status"]=="playing": raise ValueError("游戏中不能添加机器人")
if len(room["players"])>=GAMES[room["game"]]["max"]: raise ValueError("房间已满")
used={p["name"] for p in room["players"]}; name=next((n for n in BOT_NAMES if n not in used), "机器人")
bot=make_player(name,bot=True,ready=True); room["players"].append(bot); append_history(room,"bot",f"机器人 {name} 加入房间",name); room["updatedAt"]=now_ms(); return room
def update_settings(code, player_id, cups_per_loss=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
require_host(room,player_id)
if room["status"]=="playing": raise ValueError("游戏中不能修改规则")
if cups_per_loss is not None:
cups=int(cups_per_loss)
if cups<1 or cups>5: raise ValueError("输家杯数必须是 1-5")
room.setdefault("settings",{})["cupsPerLoss"]=cups
append_history(room,"settings",f"输家惩罚改为 {cups}")
room["updatedAt"]=now_ms(); return room
def start_room(code, player_id=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if player_id: require_host(room,player_id)
if room["status"]=="playing": raise ValueError("游戏进行中,不能重新开始")
if len(room["players"])<2: raise ValueError("至少需要 2 人,房主可先添加机器人或等待玩家加入")
not_ready=[p["name"] for p in room["players"] if not p.get("ready")]
if not_ready: raise ValueError("还有玩家未准备:"+"".join(not_ready))
room["status"]="playing"; room["round"]+=1; start_id=(room.get("lastResult") or {}).get("loserId")
room["turnIndex"]=next((i for i,p in enumerate(room["players"]) if p["id"]==start_id),0)
room["currentCall"]=None; room["pendingAction"]=None; room["lastResult"]=None; room["history"]=[]; room["roundCalls"]=[]
for p in room["players"]: p["dice"]=[random.randint(1,6) for _ in range(5)]
append_history(room,"start",f"{room['round']} 局开始")
room["updatedAt"]=now_ms(); return room
def validate_turn(room,player_id=None):
p=current_player(room)
if not p: raise ValueError("房间没有玩家")
if player_id and p["id"]!=player_id: raise ValueError(f"还没轮到你,当前轮到 {p['name']}")
return p
def call_dice(code,player_id,count,point,requested_mode=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room["game"]!="dice": raise ValueError("当前不是大话骰房间")
if room["status"]!="playing": raise ValueError("游戏未开始或已结算,不能叫骰")
actor=validate_turn(room,player_id); count,point=int(count),int(point)
if point<1 or point>6: raise ValueError("点数必须是 1-6")
if count<min_count(room): raise ValueError(f"{len(room['players'])} 人局最低从 {min_count(room)} 个 1 开始叫")
if count>max_count(room): raise ValueError(f"数量不能超过总骰子数 {max_count(room)}")
if requested_mode == "" and point == 1:
raise ValueError("叫 1 只能是斋,不能叫飞")
if not room["currentCall"] and count == min_count(room) and point != 1:
raise ValueError(f"首叫最低数量时只能叫 {min_count(room)} 个 1")
if not room["currentCall"] and count <= min_count(room)+1 and requested_mode == "":
raise ValueError(f"首轮低数量阶段只能叫斋;{len(room['players'])} 人局前两档 {min_count(room)}{min_count(room)+1} 个都不能直接叫飞")
# 新规则校验见下方:斋局 1 最大,破斋必须翻倍
mode=call_mode(room,count,room["currentCall"],requested_mode,point)
if room["currentCall"] and room["currentCall"].get("mode") == "" and mode == "" and count < room["currentCall"]["count"] * 2:
old = room["currentCall"]
raise ValueError(f"破斋需要翻倍:当前 {old['count']}{old['point']} · 斋,至少要叫 {old['count']*2} 个任意点才能飞")
if not is_valid_raise(room, room["currentCall"],count,point,mode): raise ValueError("叫点必须比上一手大;斋局点数顺序为 2<3<4<5<6<1且破斋必须翻倍")
room["currentCall"]={"count":count,"point":point,"mode":mode,"by":actor["name"],"byId":actor["id"]}
room.setdefault("roundCalls",[]).append({"playerId":actor["id"],"playerName":actor["name"],"count":count,"point":point,"mode":mode,"order":len(room.get("roundCalls",[]))+1})
append_history(room,"call",f"{actor['name']}{count}{point} · {mode}",actor["name"])
room["turnIndex"]=(room["turnIndex"]+1)%len(room["players"]); room["updatedAt"]=now_ms(); return room
def finish_no_reveal(room, loser, cups, text):
loser["cups"] += cups
loser.setdefault("stats",{}).setdefault("cupsDrunk",0)
loser["stats"]["cupsDrunk"] += cups
room["status"] = "result"
room["pendingAction"] = None
room["lastResult"] = {"call": room.get("currentCall"), "actual": None, "opener": None, "openerId": None, "loser": loser["name"], "loserId": loser["id"], "cups": cups, "text": text, "revealDice": False}
append_history(room, "decline", text, loser["name"])
room["updatedAt"] = now_ms()
return room
def resolve_open(room, opener, stake_cups, reason="开骰"):
call = room["currentCall"]
actual = count_actual(room, call)
caller = find_player(room, call.get("byId")) or room["players"][0]
loser = opener if actual >= call["count"] else caller
loser["cups"] += stake_cups
loser.setdefault("stats",{}).setdefault("cupsDrunk",0)
loser["stats"]["cupsDrunk"] += stake_cups
room["status"] = "result"
room["pendingAction"] = None
result_text = f"{opener['name']} {reason}{call['mode']}局实际有 {actual}{call['point']}{loser['name']}{stake_cups}"
room["lastResult"] = {"call": call, "actual": actual, "opener": opener["name"], "openerId": opener["id"], "loser": loser["name"], "loserId": loser["id"], "cups": stake_cups, "text": result_text, "revealDice": True}
append_history(room, "open", result_text, opener["name"])
room["updatedAt"] = now_ms()
return room
def open_dice(code,player_id=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room["game"]!="dice": raise ValueError("当前不是大话骰房间")
if room["status"]!="playing": raise ValueError("游戏未开始或已结算,不能开骰")
if room.get("pendingAction"): raise ValueError("当前有待处理的开骰/劈")
if not room["currentCall"]: raise ValueError("还没有人叫点,不能开骰")
opener=validate_turn(room,player_id)
caller=find_player(room,room["currentCall"].get("byId"))
if not caller: raise ValueError("上一手叫骰玩家不存在")
base=int(room.get("settings",{}).get("cupsPerLoss",1))
room["pendingAction"]={"type":"open","stage":"wait_caller_counter","initiatorId":opener["id"],"initiatorName":opener["name"],"targetId":caller["id"],"targetName":caller["name"],"baseCups":base,"call":room["currentCall"]}
append_history(room,"challenge",f"{opener['name']} 要开骰,等待 {caller['name']} 是否反劈",opener["name"])
room["updatedAt"]=now_ms(); return room
def split_dice(code,player_id=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room["game"]!="dice": raise ValueError("当前不是大话骰房间")
if room["status"]!="playing": raise ValueError("游戏未开始或已结算,不能劈")
if room.get("pendingAction"): raise ValueError("当前有待处理的开骰/劈")
if not room["currentCall"]: raise ValueError("还没有人叫点,不能劈")
opener=validate_turn(room,player_id)
caller=find_player(room,room["currentCall"].get("byId"))
if not caller: raise ValueError("上一手叫骰玩家不存在")
base=int(room.get("settings",{}).get("cupsPerLoss",1))
opener.setdefault("stats",{}).setdefault("splits",0)
opener["stats"]["splits"] += 1
room["pendingAction"]={"type":"split","stage":"wait_caller_accept","initiatorId":opener["id"],"initiatorName":opener["name"],"targetId":caller["id"],"targetName":caller["name"],"baseCups":base,"call":room["currentCall"]}
append_history(room,"split",f"{opener['name']}{caller['name']},等待受/不受/反劈",opener["name"])
room["updatedAt"]=now_ms(); return room
def respond_challenge(code, player_id, response):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
pending=room.get("pendingAction")
if not pending: raise ValueError("当前没有待处理的开骰/劈")
base=int(pending.get("baseCups",1))
initiator=find_player(room,pending["initiatorId"])
target=find_player(room,pending["targetId"])
if not initiator or not target: raise ValueError("挑战玩家不存在")
stage=pending["stage"]
if stage=="wait_caller_counter":
if player_id!=target["id"]: raise ValueError(f"等待 {target['name']} 是否反劈")
if response=="no_counter": return resolve_open(room,initiator,base,"开骰")
if response=="counter":
target.setdefault("stats",{}).setdefault("counters",0)
target["stats"]["counters"] += 1
pending["stage"]="wait_opener_accept"; pending["counterType"]="open"; pending["declineCups"]=base; pending["acceptCups"]=base+1
append_history(room,"counter",f"{target['name']} 反劈,等待 {initiator['name']} 受/不受",target["name"])
room["updatedAt"]=now_ms(); return room
if stage=="wait_caller_accept":
if player_id!=target["id"]: raise ValueError(f"等待 {target['name']} 受/不受/反劈")
if response=="decline":
target.setdefault("stats",{}).setdefault("declines",0)
target["stats"]["declines"] += 1
return finish_no_reveal(room,target,base,f"{target['name']} 不受,喝 {base} 杯,本局结束")
if response=="accept": return resolve_open(room,initiator,base+1,"劈后开骰")
if response=="counter":
target.setdefault("stats",{}).setdefault("counters",0)
target["stats"]["counters"] += 1
pending["stage"]="wait_opener_accept"; pending["counterType"]="split"; pending["declineCups"]=base+1; pending["acceptCups"]=base+2
append_history(room,"counter",f"{target['name']} 反劈,等待 {initiator['name']} 受/不受",target["name"])
room["updatedAt"]=now_ms(); return room
if stage=="wait_opener_accept":
if player_id!=initiator["id"]: raise ValueError(f"等待 {initiator['name']} 受/不受")
if response=="decline":
initiator.setdefault("stats",{}).setdefault("declines",0)
initiator["stats"]["declines"] += 1
return finish_no_reveal(room,initiator,int(pending["declineCups"]),f"{initiator['name']} 不受,喝 {pending['declineCups']} 杯,本局结束")
if response=="accept": return resolve_open(room,initiator,int(pending["acceptCups"]),"受反劈后开骰")
raise ValueError("无效应答")
def steal_split(code, player_id=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room["game"]!="dice": raise ValueError("当前不是大话骰房间")
if room["status"]!="playing": raise ValueError("游戏未开始或已结算,不能抢开")
if room.get("pendingAction"): raise ValueError("当前有待处理的开骰/劈")
if not room["currentCall"]: raise ValueError("还没有人叫点,不能抢开")
actor=require_player(room,player_id); caller=find_player(room,room["currentCall"].get("byId")); nxt=current_player(room)
if not caller: raise ValueError("上一手叫骰玩家不存在")
if actor["id"]==caller["id"]: raise ValueError("叫骰本人不能抢开自己")
if nxt and actor["id"]==nxt["id"]: raise ValueError("下家可直接操作,不能算抢开")
base=int(room.get("settings",{}).get("cupsPerLoss",1))
actor.setdefault("stats",{}).setdefault("splits",0)
actor["stats"]["splits"] += 1
room["pendingAction"]={"type":"steal_split","stage":"wait_caller_accept","initiatorId":actor["id"],"initiatorName":actor["name"],"targetId":caller["id"],"targetName":caller["name"],"baseCups":base,"call":room["currentCall"]}
append_history(room,"split",f"{actor['name']} 抢开 {caller['name']},等待受/不受/反劈",actor["name"])
room["updatedAt"]=now_ms(); return room
def multi_outcome(initiator, target_names, drink_map):
"""统一多家结算展示数据winners=未喝酒的参与者drinks=各人喝杯明细。"""
participants=[initiator['name']]+list(target_names)
winners=[n for n in participants if drink_map.get(n,0)<=0]
drinks=[{"name":n,"cups":c} for n,c in drink_map.items() if c>0]
return winners, drinks
def multi_calls(room, targets):
"""两家结算各参与者的叫点明细:已开骰的带实际数与是否成立,不受的实际数留空。"""
out=[]
for t in targets:
call=t.get('call',{}) or {}
resolved=t.get('status')=='resolve'
actual=count_actual(room,call) if resolved and call else None
win=(actual>=int(call['count'])) if resolved and call else None
out.append({"name":t['playerName'],"count":call.get('count'),"point":call.get('point'),"mode":call.get('mode'),"actual":actual,"win":win})
return out
def finalize_multi_direct(room, initiator, targets, base, label):
outcomes=[]
for t in targets:
actual=count_actual(room,t)
win = actual >= t["count"]
outcomes.append({"target":t,"actual":actual,"win":win})
winners=[o for o in outcomes if o["win"]]
losers=[o for o in outcomes if not o["win"]]
drink_map={}
def _drink(player, cups): add_drink(player, cups); drink_map[player['name']]=drink_map.get(player['name'],0)+cups
if losers and not winners:
per=base*2
for o in losers: _drink(find_player(room,o["target"]["playerId"]), per)
top=losers[0]["target"]
text=f"{initiator['name']} {label}:三家全输,三家各喝 {per}"
room["lastResult"]={"multi":True,"targets":[{"name":o['target']['playerName'],"actual":o['actual'],"win":o['win']} for o in outcomes],"loser":"三家","loserId":top['playerId'],"cups":per,"text":text,"revealDice":True}
else:
for o in losers: _drink(find_player(room,o["target"]["playerId"]), base)
d_cups=base*len(winners)
if d_cups: _drink(initiator,d_cups)
loser_id = initiator['id'] if d_cups >= base and d_cups >= (base if losers else 0) else (losers[0]['target']['playerId'] if losers else initiator['id'])
loser_name = initiator['name'] if loser_id==initiator['id'] else next(o['target']['playerName'] for o in losers if o['target']['playerId']==loser_id)
text=f"{initiator['name']} {label}:三家中 {len(winners)} 家赢,{initiator['name']}{d_cups} 杯;{len(losers)} 家输,各喝 {base}"
room["lastResult"]={"multi":True,"targets":[{"name":o['target']['playerName'],"actual":o['actual'],"win":o['win']} for o in outcomes],"loser":loser_name,"loserId":loser_id,"cups":max(d_cups,base if losers else 0),"text":text,"revealDice":True}
wl,dl=multi_outcome(initiator,[o['target']['playerName'] for o in outcomes],drink_map)
room['lastResult']['winners']=wl; room['lastResult']['drinks']=dl
room['lastResult']['calls']=[{"name":o['target']['playerName'],"count":o['target']['count'],"point":o['target']['point'],"mode":o['target'].get('mode',''),"actual":o['actual'],"win":o['win']} for o in outcomes]
room["status"]='result'; room["pendingAction"]=None; append_history(room,'open',room['lastResult']['text'],initiator['name']); room['updatedAt']=now_ms(); return room
def start_multi_direct(code, player_id, kind='open3'):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room['status']!='playing': raise ValueError('游戏未开始或已结算')
if room.get('pendingAction'): raise ValueError('当前有待处理的开骰/劈')
initiator=validate_turn(room,player_id)
need=3 if kind.endswith('3') else 2
if need==3 and len(room['players'])<4: raise ValueError('开/劈三家仅限4人局或以上')
if need==2 and len(room['players'])<3: raise ValueError('开/劈两家仅限3人局或以上')
targets=recent_call_targets(room, need)
if len(targets)<need: raise ValueError(f'当前不足{need}家可开')
if kind in ['open3','split3']:
base=1 if kind=='open3' else 2
return finalize_multi_direct(room, initiator, targets, base, '开三家' if kind=='open3' else '劈三家')
# 两家:逐家按单家规则累计,但统一收集应答
base=1 if kind=='open2' else 2
room['pendingAction']={"type":kind,"stage":"wait_target","initiatorId":initiator['id'],"initiatorName":initiator['name'],"baseCups":base,"currentIndex":0,"targets":[{"playerId":t['playerId'],"playerName":t['playerName'],"call":t,"status":"pending"} for t in targets]}
append_history(room,'challenge',f"{initiator['name']} {'开两家' if kind=='open2' else '劈两家'},等待应答",initiator['name'])
room['updatedAt']=now_ms(); return room
def respond_multi(code, player_id, response):
room=rooms.get((code or '').upper())
if not room: raise ValueError('房间不存在')
p=room.get('pendingAction')
if not p or p.get('type') not in ['open2','split2']: raise ValueError('当前没有两家应答')
initiator=find_player(room,p['initiatorId']); base=int(p['baseCups']); idx=int(p['currentIndex']); target_info=p['targets'][idx]; target=find_player(room,target_info['playerId'])
if not initiator or not target: raise ValueError('玩家不存在')
if p['stage']=='wait_target':
if player_id!=target['id']: raise ValueError(f"等待 {target['name']} 应答")
if p['type']=='open2':
if response=='no_counter': target_info['status']='resolve'; target_info['stake']=base
elif response=='counter': target.setdefault('stats',{}).setdefault('counters',0); target['stats']['counters']+=1; p['stage']='wait_initiator'; target_info['counterType']='open'; target_info['declineCups']=base; target_info['acceptStake']=base+1; append_history(room,'counter',f"{target['name']} 反劈,等待 {initiator['name']} 受/不受",target['name']); room['updatedAt']=now_ms(); return room
else: raise ValueError('开两家只支持不反/反劈')
else:
if response=='decline': target.setdefault('stats',{}).setdefault('declines',0); target['stats']['declines']+=1; target_info['status']='decline'; target_info['cups']=base
elif response=='accept': target_info['status']='resolve'; target_info['stake']=base+1
elif response=='counter': target.setdefault('stats',{}).setdefault('counters',0); target['stats']['counters']+=1; p['stage']='wait_initiator'; target_info['counterType']='split'; target_info['declineCups']=base+1; target_info['acceptStake']=base+2; append_history(room,'counter',f"{target['name']} 反劈,等待 {initiator['name']} 受/不受",target['name']); room['updatedAt']=now_ms(); return room
else: raise ValueError('无效应答')
idx += 1
if idx >= len(p['targets']):
# 统一结算
reveal=False; summary=[]; top_loser=None; top_cups=-1; drink_map={}
def _drink(player, cups): add_drink(player, cups); drink_map[player['name']]=drink_map.get(player['name'],0)+cups
for t in p['targets']:
if t['status']=='decline':
player=find_player(room,t['playerId']); _drink(player,int(t['cups'])); summary.append(f"{player['name']} 不受,喝 {t['cups']}")
if t['cups']>top_cups: top_loser, top_cups = player, t['cups']
elif t['status']=='resolve':
call=t['call']; actual=count_actual(room,call); caller=find_player(room,t['playerId']); loser=initiator if actual>=call['count'] else caller; _drink(loser,int(t['stake'])); reveal=True; summary.append(f"{caller['name']}:实际 {actual}{call['point']}{loser['name']}{t['stake']}")
if int(t['stake'])>top_cups: top_loser, top_cups = loser, int(t['stake'])
room['status']='result'; room['pendingAction']=None; room['lastResult']={"multi":True,"targets":[{"name":t['playerName'],"status":t['status']} for t in p['targets']],"loser":top_loser['name'] if top_loser else initiator['name'],"loserId":top_loser['id'] if top_loser else initiator['id'],"cups":top_cups if top_cups>0 else 0,"text":""}
wl,dl=multi_outcome(initiator,[t['playerName'] for t in p['targets']],drink_map); room['lastResult']['winners']=wl; room['lastResult']['drinks']=dl
room['lastResult']['calls']=multi_calls(room, p['targets'])
room['lastResult']['text']=('开两家' if p['type']=='open2' else '劈两家')+''+''.join(summary)
room['lastResult']['revealDice']=reveal
append_history(room,'open',room['lastResult']['text'],initiator['name']); room['updatedAt']=now_ms(); return room
p['currentIndex']=idx; room['updatedAt']=now_ms(); return room
if p['stage']=='wait_initiator':
if player_id!=initiator['id']: raise ValueError(f"等待 {initiator['name']} 受/不受")
if response=='decline': initiator.setdefault('stats',{}).setdefault('declines',0); initiator['stats']['declines']+=1; target_info['status']='decline_initiator'; target_info['cups']=int(target_info['declineCups'])
elif response=='accept': target_info['status']='resolve'; target_info['stake']=int(target_info['acceptStake'])
else: raise ValueError('只能选择受/不受')
p['stage']='wait_target'; p['currentIndex']=idx+1
if p['currentIndex'] >= len(p['targets']):
summary=[]; reveal=False; top_loser=None; top_cups=-1; drink_map={}
def _drink(player, cups): add_drink(player, cups); drink_map[player['name']]=drink_map.get(player['name'],0)+cups
for t in p['targets']:
if t['status'] in ['decline','decline_initiator']:
loser=find_player(room,initiator['id'] if t['status']=='decline_initiator' else t['playerId']); _drink(loser,int(t['cups'])); summary.append(f"{loser['name']} 不受,喝 {t['cups']}")
if int(t['cups'])>top_cups: top_loser, top_cups = loser, int(t['cups'])
elif t['status']=='resolve':
call=t['call']; actual=count_actual(room,call); caller=find_player(room,t['playerId']); loser=initiator if actual>=call['count'] else caller; _drink(loser,int(t['stake'])); reveal=True; summary.append(f"{caller['name']}:实际 {actual}{call['point']}{loser['name']}{t['stake']}")
if int(t['stake'])>top_cups: top_loser, top_cups = loser, int(t['stake'])
room['status']='result'; room['pendingAction']=None; room['lastResult']={"multi":True,"targets":[{"name":t['playerName'],"status":t['status']} for t in p['targets']],"loser":top_loser['name'] if top_loser else initiator['name'],"loserId":top_loser['id'] if top_loser else initiator['id'],"cups":top_cups if top_cups>0 else 0,"text":""}
wl,dl=multi_outcome(initiator,[t['playerName'] for t in p['targets']],drink_map); room['lastResult']['winners']=wl; room['lastResult']['drinks']=dl
room['lastResult']['calls']=multi_calls(room, p['targets'])
room['lastResult']['text']=('开两家' if p['type']=='open2' else '劈两家')+''+''.join(summary)
room['lastResult']['revealDice']=reveal
append_history(room,'open',room['lastResult']['text'],initiator['name']); room['updatedAt']=now_ms(); return room
room['updatedAt']=now_ms(); return room
raise ValueError('无效两家应答')
def bot_turn(code):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if room["status"]!="playing": return room
pending=room.get("pendingAction")
if pending:
if pending.get("type") in ["open2","split2"]:
idx=int(pending.get("currentIndex",0)); targets=pending.get("targets",[])
if idx < len(targets):
actor_id = targets[idx]["playerId"] if pending["stage"]=="wait_target" else pending["initiatorId"]
actor=find_player(room,actor_id)
if actor and actor.get("bot"):
if pending["stage"]=="wait_target":
if pending['type']=='open2': return respond_multi(code,actor_id,'no_counter' if random.random()<0.72 else 'counter')
return respond_multi(code,actor_id,'accept' if random.random()<0.55 else ('decline' if random.random()<0.78 else 'counter'))
if pending["stage"]=="wait_initiator":
return respond_multi(code,actor_id,'accept' if random.random()<0.62 else 'decline')
return room
actor_id = pending["targetId"] if pending["stage"] in ["wait_caller_counter","wait_caller_accept"] else pending["initiatorId"]
actor=find_player(room,actor_id)
if actor and actor.get("bot"):
if pending["stage"]=="wait_caller_counter": return respond_challenge(code,actor_id,"no_counter" if random.random()<0.75 else "counter")
if pending["stage"]=="wait_caller_accept": return respond_challenge(code,actor_id,"accept" if random.random()<0.55 else ("decline" if random.random()<0.75 else "counter"))
if pending["stage"]=="wait_opener_accept": return respond_challenge(code,actor_id,"accept" if random.random()<0.65 else "decline")
return room
bot=current_player(room)
if not bot or not bot.get("bot"): return room
if room["currentCall"]:
actual=count_actual(room,room["currentCall"]); pressure=room["currentCall"]["count"]-actual
if room["currentCall"]["count"]>=min_count(room)+2:
if pressure>0 and random.random()<0.55: return open_dice(code,bot["id"])
if random.random()<0.16: return open_dice(code,bot["id"])
if room["currentCall"]["count"]>=max_count(room): return open_dice(code,bot["id"])
n=next_call(room,room["currentCall"])
if n["count"]>max_count(room): return open_dice(code,bot["id"])
return call_dice(code,bot["id"],n["count"],n["point"])
def toggle_lock(code,player_id=None):
room=rooms.get((code or "").upper())
if not room: raise ValueError("房间不存在")
if player_id: require_host(room,player_id)
room["locked"]=not room["locked"]; append_history(room,"lock","房间已暂停加入" if room["locked"] else "房间已开放加入"); room["updatedAt"]=now_ms(); return room
def leave_room(code, player_id):
code=(code or "").upper(); room=rooms.get(code)
if not room: raise ValueError("房间不存在")
if room["status"]=="playing": raise ValueError("游戏中不能退出,请等待本局结算")
p=require_player(room,player_id); room["players"]=[x for x in room["players"] if x["id"]!=player_id]
append_history(room,"leave",f"{p['name']} 离开房间",p["name"]); transfer_host_if_needed(room); room["turnIndex"]=0; room["updatedAt"]=now_ms(); deleted=cleanup_room(code); return None if deleted else room
def kick_player(code, host_id, target_id):
code=(code or "").upper(); room=rooms.get(code)
if not room: raise ValueError("房间不存在")
require_host(room,host_id)
if room["status"]=="playing": raise ValueError("游戏中不能踢人")
if host_id==target_id: raise ValueError("不能踢自己,请使用退出房间")
target=find_player(room,target_id)
if not target: raise ValueError("目标玩家不在房间")
room["players"]=[p for p in room["players"] if p["id"]!=target_id]
append_history(room,"kick",f"{target['name']} 被房主移出房间",target["name"]); transfer_host_if_needed(room); room["turnIndex"]=0; room["updatedAt"]=now_ms(); return room
# ---------------- WebSocket纯标准库实现 RFC6455----------------
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
state_lock = threading.RLock() # 保护 rooms / ws_clients 的并发修改
ws_clients = {} # cid -> {"sock","code","player_id","send_lock"}
def recv_exact(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk: return None
buf += chunk
return buf
def ws_read_frame(sock):
"""读取一帧,返回 (opcode, payload);连接关闭或异常返回 (None, None)。客户端帧带掩码,需解掩。"""
head = recv_exact(sock, 2)
if not head: return None, None
opcode = head[0] & 0x0f
masked = head[1] & 0x80
length = head[1] & 0x7f
if length == 126:
ext = recv_exact(sock, 2)
if not ext: return None, None
length = struct.unpack(">H", ext)[0]
elif length == 127:
ext = recv_exact(sock, 8)
if not ext: return None, None
length = struct.unpack(">Q", ext)[0]
mask = recv_exact(sock, 4) if masked else b"\x00\x00\x00\x00"
if mask is None: return None, None
payload = recv_exact(sock, length) if length else b""
if payload is None: return None, None
if masked:
payload = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
return opcode, payload
def ws_build_frame(payload, opcode=0x1):
data = payload if isinstance(payload, bytes) else payload.encode()
n = len(data)
header = bytes([0x80 | opcode])
if n < 126:
header += bytes([n])
elif n < 65536:
header += bytes([126]) + struct.pack(">H", n)
else:
header += bytes([127]) + struct.pack(">Q", n)
return header + data
def ws_send(client, obj):
frame = ws_build_frame(json.dumps(obj, ensure_ascii=False))
try:
with client["send_lock"]:
client["sock"].sendall(frame)
return True
except Exception:
return False
def ws_handshake(handler):
key = handler.headers.get("Sec-WebSocket-Key")
if not key: return False
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
resp = ("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n")
try:
handler.connection.sendall(resp.encode()); return True
except Exception:
return False
def room_clients(code):
code = (code or "").upper()
with state_lock:
return [c for c in ws_clients.values() if c.get("code") == code]
def lobby_clients():
with state_lock:
return [c for c in ws_clients.values() if not c.get("code")]
def broadcast_room(code):
"""向房内所有连接推送各自视角的房间状态;房间已删除则推 closed。"""
code = (code or "").upper(); room = rooms.get(code)
for c in room_clients(code):
if room: ws_send(c, {"type": "room", "room": public_room(room, c.get("player_id"))})
else: ws_send(c, {"type": "closed"})
def broadcast_rooms():
payload = {"type": "rooms", "rooms": [public_room(r) for r in rooms.values()]}
for c in lobby_clients():
ws_send(c, payload)
def schedule_bots(code):
threading.Timer(0.9, lambda: run_bots(code)).start()
def run_bots(code):
"""服务端驱动机器人:推进一步,若有变化则广播并继续排程,直到轮到真人或本局结束。"""
code = (code or "").upper(); changed = False
with state_lock:
room = rooms.get(code)
if room and room["status"] == "playing":
before = room["updatedAt"]
try: bot_turn(code)
except Exception as e: print("bot error:", e)
changed = rooms.get(code, {}).get("updatedAt") != before
if changed:
broadcast_room(code); schedule_bots(code)
# 推进游戏进程、需触发机器人接续的动作
BOT_TRIGGER = {"start","call","open","split","steal","multi","respond","respondMulti"}
def handle_ws(client, msg):
t = msg.get("type")
try:
acted = None; room = None
with state_lock:
if t == "rooms":
ws_send(client, {"type": "rooms", "rooms": [public_room(r) for r in rooms.values()]}); return
elif t == "create":
room, player = create_room(msg.get("game", "dice"), msg.get("name", "玩家"), msg.get("playerId"))
client["code"] = room["code"]; client["player_id"] = player["id"]; acted = room["code"]
ws_send(client, {"type": "joined", "room": public_room(room, player["id"]), "player": player})
elif t == "join":
room, player, reused = join_room(msg.get("code", ""), msg.get("name", "玩家"), msg.get("playerId"))
client["code"] = room["code"]; client["player_id"] = player["id"]; acted = room["code"]
ws_send(client, {"type": "joined", "room": public_room(room, player["id"]), "player": player, "reused": reused})
else:
code = client.get("code"); pid = client.get("player_id")
if not code: ws_send(client, {"type": "error", "error": "尚未加入房间"}); return
acted = code
if t == "ready": room = set_ready(code, pid, msg.get("ready"))
elif t == "start": room = start_room(code, pid)
elif t == "call": room = call_dice(code, pid, msg.get("count", 1), msg.get("point", 1), msg.get("mode"))
elif t == "open": room = open_dice(code, pid)
elif t == "split": room = split_dice(code, pid)
elif t == "steal": room = steal_split(code, pid)
elif t == "multi": room = start_multi_direct(code, pid, msg.get("kind", "open2"))
elif t == "respond": room = respond_challenge(code, pid, msg.get("response", ""))
elif t == "respondMulti": room = respond_multi(code, pid, msg.get("response", ""))
elif t == "settings": room = update_settings(code, pid, msg.get("cupsPerLoss"))
elif t == "lock": room = toggle_lock(code, pid)
elif t == "kick": room = kick_player(code, pid, msg.get("targetId"))
elif t == "addBot": room = add_bot(code, pid)
elif t == "leave":
room = leave_room(code, pid); client["code"] = None; client["player_id"] = None
ws_send(client, {"type": "left"})
else:
ws_send(client, {"type": "error", "error": "未知消息"}); return
if acted:
broadcast_room(acted); broadcast_rooms()
if t in BOT_TRIGGER: schedule_bots(acted)
except Exception as e:
ws_send(client, {"type": "error", "error": str(e)})
def ws_serve(handler):
sock = handler.connection
cid = new_id("w")
client = {"sock": sock, "code": None, "player_id": None, "send_lock": threading.Lock()}
with state_lock:
ws_clients[cid] = client
ws_send(client, {"type": "rooms", "rooms": [public_room(r) for r in rooms.values()]})
try:
while True:
opcode, payload = ws_read_frame(sock)
if opcode is None or opcode == 0x8: break # 关闭
if opcode == 0x9: # ping -> pong
try:
with client["send_lock"]: sock.sendall(ws_build_frame(payload, 0xA))
except Exception: break
continue
if opcode == 0xA: continue # pong
if opcode != 0x1: continue # 仅处理文本帧
try: data = json.loads(payload.decode() or "{}")
except Exception: continue
handle_ws(client, data)
finally:
with state_lock:
ws_clients.pop(cid, None)
class Handler(SimpleHTTPRequestHandler):
def __init__(self,*a,**kw): super().__init__(*a,directory=str(ROOT),**kw)
def log_message(self,fmt,*a): print("[%s] %s"%(self.log_date_time_string(),fmt%a))
def end_headers(self):
# 禁用静态文件缓存,避免浏览器用旧版 live.html/JS/CSSWS 握手走 sendall不经此处
self.send_header("Cache-Control","no-cache, no-store, must-revalidate")
super().end_headers()
def do_OPTIONS(self): self.send_response(204); self.send_header("Access-Control-Allow-Origin","*"); self.send_header("Access-Control-Allow-Methods","GET, POST, OPTIONS"); self.send_header("Access-Control-Allow-Headers","Content-Type"); self.end_headers()
def do_GET(self):
p=urlparse(self.path); qs=parse_qs(p.query)
if p.path=="/ws" and self.headers.get("Upgrade","").lower()=="websocket":
self.close_connection=True
if ws_handshake(self): ws_serve(self)
return
if p.path=="/api/rooms": send_json(self,{"ok":True,"rooms":[public_room(r) for r in rooms.values()]}); return
if p.path=="/api/room":
code=qs.get("code",[""])[0].upper(); viewer=qs.get("playerId",[None])[0]; room=rooms.get(code)
send_json(self,{"ok":bool(room),"room":public_room(room,viewer) if room else None,"error":None if room else "房间不存在"},200 if room else 404); return
return super().do_GET()
def do_POST(self):
try:
p=urlparse(self.path); d=read_json(self); viewer=d.get("playerId")
if p.path=="/api/create":
room,player=create_room(d.get("game","dice"),d.get("name","玩家"),viewer); send_json(self,{"ok":True,"room":public_room(room,player["id"]),"player":player}); return
if p.path=="/api/join":
room,player,reused=join_room(d.get("code",""),d.get("name","玩家"),viewer); send_json(self,{"ok":True,"room":public_room(room,player["id"]),"player":player,"reused":reused}); return
if p.path=="/api/ready": send_json(self,{"ok":True,"room":public_room(set_ready(d.get("code",""),viewer,d.get("ready",None)),viewer)}); return
if p.path=="/api/add-bot": send_json(self,{"ok":True,"room":public_room(add_bot(d.get("code",""),viewer),viewer)}); return
if p.path=="/api/settings": send_json(self,{"ok":True,"room":public_room(update_settings(d.get("code",""),viewer,d.get("cupsPerLoss",None)),viewer)}); return
if p.path=="/api/leave":
room=leave_room(d.get("code",""),viewer); send_json(self,{"ok":True,"room":public_room(room,viewer) if room else None,"deleted":room is None}); return
if p.path=="/api/kick": send_json(self,{"ok":True,"room":public_room(kick_player(d.get("code",""),viewer,d.get("targetId")),viewer)}); return
if p.path=="/api/start": send_json(self,{"ok":True,"room":public_room(start_room(d.get("code",""),viewer),viewer)}); return
if p.path=="/api/call": send_json(self,{"ok":True,"room":public_room(call_dice(d.get("code",""),viewer,d.get("count",1),d.get("point",1),d.get("mode",None)),viewer)}); return
if p.path=="/api/open": send_json(self,{"ok":True,"room":public_room(open_dice(d.get("code",""),viewer),viewer)}); return
if p.path=="/api/split": send_json(self,{"ok":True,"room":public_room(split_dice(d.get("code",""),viewer),viewer)}); return
if p.path=="/api/steal-split": send_json(self,{"ok":True,"room":public_room(steal_split(d.get("code",""),viewer),viewer)}); return
if p.path=="/api/multi-open2": send_json(self,{"ok":True,"room":public_room(start_multi_direct(d.get("code",""),viewer,'open2'),viewer)}); return
if p.path=="/api/multi-split2": send_json(self,{"ok":True,"room":public_room(start_multi_direct(d.get("code",""),viewer,'split2'),viewer)}); return
if p.path=="/api/multi-open3": send_json(self,{"ok":True,"room":public_room(start_multi_direct(d.get("code",""),viewer,'open3'),viewer)}); return
if p.path=="/api/multi-split3": send_json(self,{"ok":True,"room":public_room(start_multi_direct(d.get("code",""),viewer,'split3'),viewer)}); return
if p.path=="/api/respond-challenge": send_json(self,{"ok":True,"room":public_room(respond_challenge(d.get("code",""),viewer,d.get("response","")),viewer)}); return
if p.path=="/api/respond-multi": send_json(self,{"ok":True,"room":public_room(respond_multi(d.get("code",""),viewer,d.get("response","")),viewer)}); return
if p.path=="/api/bot-turn": send_json(self,{"ok":True,"room":public_room(bot_turn(d.get("code","")),viewer)}); return
if p.path=="/api/toggle-lock": send_json(self,{"ok":True,"room":public_room(toggle_lock(d.get("code",""),viewer),viewer)}); return
send_json(self,{"ok":False,"error":"未知接口"},404)
except Exception as e: send_json(self,{"ok":False,"error":str(e)},400)
if __name__=="__main__":
create_room("dice","演示房主")
port=8765
print(f"酒桌派对开发服务器http://127.0.0.1:{port}/live.html WebSocket: /ws")
server=ThreadingHTTPServer(("0.0.0.0",port),Handler)
server.daemon_threads=True
server.serve_forever()