Add multiplayer blackjack table flow
check / local-gate (push) Failing after 51s

This commit is contained in:
JWeBot Contributors
2026-10-16 11:03:32 +08:00
parent afc71e63a7
commit 3fa04755d0
24 changed files with 1959 additions and 14 deletions
+1
View File
@@ -88,6 +88,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/游戏记录 [页码](/gamehistory)
/游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案
+4
View File
@@ -57,6 +57,10 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End the current blackjack round. |
| `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. |
| `/21点 加入` | - | Join the current blackjack table before betting starts. |
| `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. |
| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. |
+6
View File
@@ -90,6 +90,12 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。
多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。
其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始`
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。
所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作;
结算按座位顺序列出每个人的下注、返还、净变化和当前金币。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。
+18
View File
@@ -0,0 +1,18 @@
# coin-taffy
Author: `jwebot`
Taffy金币
Help:
- `/Taffy金币`
Registered commands:
- `/Taffy金币`
- `/taffycoins`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-taffy` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "coin-taffy"
author = "jwebot"
description = "Taffy金币"
help = ["/Taffy金币"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_taffy:plugin"
type = "command"
[capabilities]
commands = ["/Taffy金币", "/taffycoins"]
storage = false
network_profiles = []
service_profiles = ["coin-taffy"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy金币'
HELP_ENTRIES = ('/Taffy金币',)
COMMANDS = ('/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_taffy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'Taffy金币')
self.assertEqual(HELP_ENTRIES, ('/Taffy金币',))
self.assertIn('/Taffy金币', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
@@ -9,8 +9,7 @@ Help:
- `/金币 /wallet;/游戏记录 /gamehistory`
- `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 开始 [1~500];/blackjack start`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200`
- `/游戏帮助;/games`
Registered commands:
@@ -1,7 +1,7 @@
id = "single-player-games"
author = "jwebot"
description = "金币游戏"
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"]
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/games"]
version = "1.0.0"
api_version = "1"
entrypoint = "single_player_games:plugin"
@@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -20,7 +20,7 @@ class PluginTests(unittest.TestCase):
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games'))
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games'))
self.assertIn('/金币', registered)
self.assertEqual(len(registered), 15)
@@ -2,7 +2,7 @@
Author: `official`
提取当前群最近100、200或300条本地文字的上下文
上下文总结
Help:
@@ -1,6 +1,6 @@
id = "context-summary"
author = "official"
description = "提取当前群最近100、200或300条本地文字的上下文"
description = "上下文总结"
help = ["/上下文 [100|200|300];/context [100|200|300]"]
version = "1.0.0"
api_version = "1"
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary'
AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context')
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文')
self.assertEqual(DESCRIPTION, '上下文总结')
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
self.assertIn('/上下文', registered)
self.assertEqual(len(registered), 2)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy金币'
HELP_ENTRIES = ('/Taffy金币',)
COMMANDS = ('/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary'
AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context')
@@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -46,6 +46,8 @@ LOCAL_COMMAND_ALIASES = {
"金币排行榜": "coin_ranking",
"大富翁榜": "coin_ranking",
"richlist": "coin_ranking",
"Taffy金币": "taffy_coins",
"taffycoins": "taffy_coins",
"转账": "transfer",
"transfer": "transfer",
"贷款": "loan",
@@ -143,7 +145,7 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor(
extension_id="context-summary",
author=OFFICIAL_AUTHOR,
description="提取当前群最近100、200或300条本地文字的上下文",
description="上下文总结",
help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
command_kinds=("context_summary",),
),
@@ -155,8 +157,7 @@ LOCAL_EXTENSIONS = (
"/金币 /wallet;/游戏记录 /gamehistory",
"/抽奖 [金额];/draw",
"/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 开始 [1~500];/blackjack start",
"/21点 要牌|停牌|状态|结束;/blackjack",
"/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200",
"/游戏帮助;/games",
),
command_kinds=(
@@ -182,6 +183,13 @@ LOCAL_EXTENSIONS = (
help_entries=("/大富翁榜",),
command_kinds=("coin_ranking",),
),
LocalExtensionDescriptor(
extension_id="coin-taffy",
author=JWEBOT_AUTHOR,
description="Taffy金币",
help_entries=("/Taffy金币",),
command_kinds=("taffy_coins",),
),
LocalExtensionDescriptor(
extension_id="coin-loan",
author=JWEBOT_AUTHOR,
@@ -157,6 +157,7 @@ class LocalCommandEngine:
if kind in {
"wallet",
"coin_ranking",
"taffy_coins",
"transfer",
"loan",
"repay",
@@ -16,6 +16,9 @@ MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500
MIN_BLACKJACK_TABLE_BET = 1
MAX_BLACKJACK_TABLE_BET = 10_000
MAX_BLACKJACK_TABLE_PLAYERS = 8
MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000
MIN_LOAN_AMOUNT = 1
@@ -45,6 +48,10 @@ BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$"
)
BLACKJACK_TABLE_BET = re.compile(
r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
re.IGNORECASE,
)
TRANSFER_MARKED = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*"
r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
@@ -161,6 +168,9 @@ class PlayerGames:
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"多人21点:/21点 多人 启动 创建牌桌;/21点 加入;"
"主持人 /21点 多人 开始 后按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌|停牌。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值或兑换。"
)
@@ -177,6 +187,8 @@ class PlayerGames:
return self.group_lottery(row, arguments)
if kind == "coin_ranking":
return self.coin_ranking(row, arguments)
if kind == "taffy_coins":
return self.taffy_coins(row, arguments)
if kind == "transfer":
return self.transfer(row, arguments)
if kind in {"loan", "repay"}:
@@ -188,6 +200,15 @@ class PlayerGames:
return "用法:/抽奖 [2~100金币](/draw),默认10金币"
bet = int(arguments or 10)
elif kind == "blackjack":
table_command = self.parse_blackjack_table_command(arguments)
if table_command is not None:
table_action, table_bet = table_command
return self.group_blackjack(
row,
table_action,
table_bet,
arguments,
)
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted)
if start is not None:
@@ -209,6 +230,28 @@ class PlayerGames:
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
)
group = str(row["group_fingerprint"])
if (
action in {"hit", "stand", "status"}
and self.ledger.has_active_group_blackjack(group)
):
return self.group_blackjack(row, action, 0, arguments)
if (
action == "start"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前已有Taffy的21点多人牌桌;"
"发送 /21点 状态 查看,或等待本桌结束"
)
if (
action == "end"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前是多人21点牌桌;轮到你时请 /21点 停牌,"
"主持人可 /21点 多人 取消"
)
else:
bet = 0
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
@@ -341,6 +384,451 @@ class PlayerGames:
)
return "\n".join(lines)
def taffy_coins(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value):
return "用法:/Taffy金币 [人数]"
limit = int(value or 8)
if not 1 <= limit <= 20:
return "Taffy金币人数必须为 1~20"
stats = self.ledger.player_taffy_coin_stats(
str(row["group_fingerprint"]),
limit=limit,
)
if int(stats["records"]) == 0:
return "Taffy金币:当前群暂无已结算游戏记录"
net = int(stats["taffy_net"])
result = f"Taffy累计赢 {net} 金币" if net >= 0 else f"Taffy累计倒贴 {-net} 金币"
lines = [
(
f"Taffy金币:已结算 {stats['records']} 局;"
f"玩家总下注 {stats['stake']},总返还 {stats['payout']};"
f"{result}"
)
]
if stats["players"]:
lines.append("最大上贡者排行(含负数):")
else:
lines.append("最大上贡者排行:暂无已结算玩家明细")
for index, record in enumerate(stats["players"], start=1):
player_net = int(record["taffy_net"])
if player_net > 0:
verdict = f"上贡 {player_net}"
elif player_net < 0:
verdict = f"从Taffy赢走 {-player_net}"
else:
verdict = "打平 0"
lines.append(
f"{index}. {record['display_name']}:{verdict};"
f"下注 {record['stake']},返还 {record['payout']},"
f"{record['records']} 局"
)
return "\n".join(lines)
@staticmethod
def parse_blackjack_table_command(
arguments: str,
) -> tuple[str, int] | None:
compacted = re.sub(r"\s+", "", arguments.casefold())
if not compacted:
return None
if compacted in {"多人", "牌桌", "table", "multiplayer"}:
return "help", 0
if compacted in {
"多人启动",
"多人开桌",
"多人创建",
"牌桌启动",
"牌桌开桌",
"tablestart",
"multiplayerstart",
}:
return "start", 0
if compacted in {"加入", "join"}:
return "join", 0
if compacted in {
"多人开始",
"多人发牌",
"开始多人",
"开桌",
"发牌",
"tabledeal",
"multiplayerdeal",
}:
return "begin", 0
if compacted in {
"多人取消",
"牌桌取消",
"取消牌桌",
"tablecancel",
"multiplayercancel",
}:
return "cancel", 0
bet = BLACKJACK_TABLE_BET.fullmatch(compacted)
if bet is not None:
return "bet", int(bet["bet"])
return None
@staticmethod
def blackjack_players_text(players: list[dict[str, Any]]) -> str:
names = [str(player["display_name"]) for player in players]
return "、".join(names) if names else "暂无"
@staticmethod
def blackjack_hand_text(hand: list[int]) -> str:
if not hand:
return "未发牌"
return (
f"{' '.join(card_label(card) for card in hand)}"
f"({hand_value(hand)}点)"
)
@staticmethod
def blackjack_prompt(player: dict[str, Any]) -> str:
return (
f"请{player['display_name']} 放自己的筹码\n"
"输入/21点 放200(1~10000)"
)
def group_blackjack(
self,
row: dict[str, Any],
action: str,
amount: int,
arguments: str,
) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = f"blackjack-table:{arguments}"
if action == "help":
return (
"多人21点用法:/21点 多人 启动;/21点 加入;"
"主持人 /21点 多人 开始;按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌 或 /21点 停牌"
)
if action == "start":
return self.ledger.start_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_start,
)
if action == "join":
return self.ledger.join_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_join,
)
if action == "begin":
return self.ledger.begin_group_blackjack_betting(
group,
sender,
event_id,
command,
self.render_blackjack_table_begin,
)
if action == "cancel":
return self.ledger.cancel_group_blackjack(
group,
sender,
event_id,
command,
self.render_blackjack_table_cancel,
)
if action == "status":
return self.ledger.group_blackjack_status(
group,
sender,
event_id,
command,
self.render_blackjack_table_status,
)
if action == "bet":
if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET:
return "21点多人牌桌筹码必须为 1~10000 的整数金币"
deck = list(range(52))
self.rng.shuffle(deck)
return self.ledger.place_group_blackjack_bet(
group,
sender,
event_id,
command,
amount,
deck,
self.render_blackjack_table_bet,
)
if action in {"hit", "stand"}:
return self.ledger.play_group_blackjack(
group,
sender,
event_id,
command,
action,
self.render_blackjack_table_action,
)
raise ValueError(f"unsupported blackjack table action: {action}")
def render_blackjack_table_start(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
players = data.get("players", [])
prefix = (
"当前已有Taffy 的黑杰克Blackjack 21点牌桌"
if status == "already_open"
else "欢迎来到Taffy 的黑杰克Blackjack 21点牌桌"
)
return (
f"{prefix}\n"
"输入/21点 加入 加入当前游戏!"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"主持人发送 /21点 多人 开始 后,"
"Taffy会按顺序喊每个人放筹码"
)
def render_blackjack_table_join(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "closed":
return "这桌已经停止加入;发送 /21点 状态 查看当前进度"
if status == "full":
return f"这桌已满,最多 {data['max_players']} 人"
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
if status == "already_joined":
return (
f"{data['display_name']} 已经在这桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
f"{data['display_name']} 已加入Taffy的21点牌桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
def render_blackjack_table_begin(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以结束加入"
if status == "not_joining":
return "这桌已经结束加入;发送 /21点 状态 查看当前进度"
if status == "not_enough":
return (
"至少需要 2 名玩家才能开始多人21点;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
"加入结束,Taffy 按顺序收筹码。\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
def render_blackjack_table_status(self, data: dict[str, Any]) -> str:
if data["status"] == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
state = str(data["state"])
table_number = int(data["table_number"])
players = data["players"]
if state == "joining":
return (
f"21点牌桌第 {table_number} 桌正在加入;"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"输入/21点 加入 加入当前游戏;"
"主持人 /21点 多人 开始"
)
if state == "betting":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
lines = [f"21点牌桌第 {table_number} 桌正在下注:"]
for player in players:
bet = int(player["bet"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{bet if bet else '未放'}"
)
if prompt is not None:
lines.append(self.blackjack_prompt(prompt))
return "\n".join(lines)
if state == "playing":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
f"21点牌桌第 {table_number} 桌正在出牌;Taffy {dealer_text}"
]
for player in players:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']};{player['state']}"
)
if prompt is not None:
lines.append(
f"请{prompt['display_name']} 操作:"
"/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
return "当前没有进行中的21点多人牌桌"
def render_blackjack_table_bet(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_betting":
return "现在不是放筹码阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
return self.blackjack_prompt(data["prompt_player"])
if status == "insufficient":
return (
f"{data['display_name']}:金币不足,余额 {data['balance']},"
f"本次需要 {data['bet']}"
)
if status == "bet_accepted":
return (
f"{data['display_name']} 放下 {data['bet']};"
f"金币 {data['balance']}\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
if status == "dealt":
return self.render_blackjack_table_dealt(data)
if status == "settled":
return self.render_blackjack_table_settlement(data)
raise ValueError(f"unsupported blackjack bet status: {status}")
def render_blackjack_table_dealt(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
(
f"{data['display_name']} 放下 {data['bet']};"
"全部筹码已确认。Taffy 发牌。"
),
f"Taffy 明牌 {dealer_text}",
]
for player in data["players"]:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']}"
)
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_action(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_playing":
return "现在不是出牌阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
prompt = data["prompt_player"]
return (
f"现在轮到 {prompt['display_name']};"
"/21点 要牌 或 /21点 停牌"
)
if status == "not_actionable":
return "你这一手已经结束;等待本桌结算"
if status == "settled":
return self.render_blackjack_table_settlement(data)
player = data["player"]
lines: list[str] = []
if status == "hit":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"当前 {self.blackjack_hand_text(player['hand'])}"
)
elif status == "busted":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"{self.blackjack_hand_text(player['hand'])} 爆牌"
)
elif status == "stood":
lines.append(
f"{player['display_name']} 停牌;"
f"{self.blackjack_hand_text(player['hand'])}"
)
else:
raise ValueError(f"unsupported blackjack action status: {status}")
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_settlement(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = self.blackjack_hand_text(dealer)
lines = [
(
"Taffy 的黑杰克Blackjack 21点牌桌"
f"第 {data['table_number']} 桌结算:"
),
f"Taffy 庄家 {dealer_text}",
"这一桌每个人的变化:",
]
for player in data["players"]:
bet = int(player["bet"])
payout = int(player["payout"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{player['outcome']};牌 {self.blackjack_hand_text(player['hand'])};"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};"
f"金币 {player['balance']}"
)
return "\n".join(lines)
def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以取消"
return (
f"21点牌桌第 {data['table_number']} 桌已取消;"
f"{data['refunded']} 人的下注已退回"
)
@staticmethod
def parse_transfer(
arguments: str,
@@ -92,6 +92,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"coin": JWEBOT_AUTHOR,
"coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR,
"coin-taffy": JWEBOT_AUTHOR,
"coin-loan": JWEBOT_AUTHOR,
"random-number": JWEBOT_AUTHOR,
"draw-lot": JWEBOT_AUTHOR,
+73
View File
@@ -176,6 +176,56 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501"))
def test_multiplayer_blackjack_table_guides_turns_and_settles(self):
self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动"))
self.assertIn("目前玩家:alice", self.command("/21点 状态"))
self.assertIn("bob 已加入", self.command("/21点 加入", sender="Bob"))
self.assertIn("请alice 放自己的筹码", self.command("/21点 多人 开始"))
alice_bet = self.command("/21点 放200", event_id="alice-table-bet")
replay = self.command(
"/21点 放200",
sender="a1",
event_id="alice-table-bet",
)
self.assertEqual(replay, alice_bet)
self.assertIn("请bob 放自己的筹码", alice_bet)
self.assertEqual(self.state()["balance"], 800)
def table_deck(cards):
first = [12, 8, 9, 24, 19, 32, 25]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=table_deck,
):
dealt = self.command("/21点 放100", sender="Bob")
self.assertIn("全部筹码已确认", dealt)
self.assertIn("alice #1:K Q(20点);下注 200", dealt)
self.assertIn("bob #1:9 7(16点);下注 100", dealt)
self.assertIn("请alice 操作", dealt)
self.assertEqual(self.state("Bob")["balance"], 900)
alice_stand = self.command("/21点 停牌")
self.assertIn("alice 停牌", alice_stand)
self.assertIn("请bob 操作", alice_stand)
settled = self.command("/21点 要牌", sender="Bob")
self.assertIn("这一桌每个人的变化", settled)
self.assertIn("alice #1:你赢了", settled)
self.assertIn("下注 200,返还 400,净变化 +200;金币 1200", settled)
self.assertIn("bob #1:你输了", settled)
self.assertIn("下注 100,返还 0,净变化 -100;金币 900", settled)
self.assertEqual(self.state()["balance"], 1200)
self.assertEqual(self.state("Bob")["balance"], 900)
self.assertIn("alice #1:21点 你赢了", self.command("/游戏记录"))
self.assertIn(
"bob #1:21点 你输了",
self.command("/游戏记录", sender="Bob"),
)
def test_prize_draw_has_indexed_history(self):
with mock.patch.object(
self.engine.local.players.rng,
@@ -262,6 +312,29 @@ class Release063GameTests(unittest.TestCase):
self.assertNotIn("Dave", ranking)
self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0"))
def test_taffy_coin_stats_sums_all_game_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
self.command("/抽奖10")
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=0,
):
self.command("/抽奖10", sender="Bob")
stats = self.command("/Taffy金币")
self.assertIn("玩家总下注 20,总返还 50", stats)
self.assertIn("Taffy累计倒贴 30 金币", stats)
self.assertIn("最大上贡者排行(含负数):", stats)
self.assertIn("alice:上贡 10", stats)
self.assertIn("bob:从Taffy赢走 40", stats)
self.assertIn("Taffy金币人数必须为 1~20", self.command("/Taffy金币 0"))
def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name)
self.command("/彩票开始")