feat: add multiplayer lottery and game records
check / local-gate (push) Failing after 1m35s

This commit is contained in:
JWeBot Contributors
2026-10-15 13:57:32 +08:00
parent 0731e5cebb
commit f07ae653c7
29 changed files with 1463 additions and 128 deletions
+8 -1
View File
@@ -110,7 +110,14 @@ class LocalExtensionInventoryTests(unittest.TestCase):
self.assertEqual(
combined,
{
"single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
"single-player-games": (
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
),
"person-directory": ("person_directory", "person_info"),
"person-memory": (
"remember",
-4
View File
@@ -125,10 +125,6 @@ class Release061Tests(unittest.TestCase):
self.command("/draw 100")
self.assertIn("金币不足", self.command("/draw 2"))
self.assertEqual(self.state()["balance"], 0)
with mock.patch.object(self.local.players.rng, "randrange", return_value=123):
reply = self.command("/买彩票 123 10", sender="Bob")
self.assertIn("返还 5000", reply)
self.assertEqual(self.state("Bob")["balance"], 5990)
def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
# Player 10+7, dealer 9+8: standing produces a tie.
+4 -4
View File
@@ -56,7 +56,7 @@ class Release062BlackjackTests(unittest.TestCase):
side_effect=self.hit_deck,
):
start = self.command("/21点开始100", event_id="compact-start")
self.assertTrue(start.startswith("alice:"))
self.assertTrue(start.startswith("alice #1:"))
self.assertIn("下注 100", start)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(len(self.state()["game"]["player"]), 2)
@@ -69,7 +69,7 @@ class Release062BlackjackTests(unittest.TestCase):
)
self.assertEqual(replay, first)
self.assertTrue(first.startswith("alice:"))
self.assertTrue(first.startswith("alice #1:"))
self.assertIn("13点", first)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(len(self.state()["game"]["player"]), 3)
@@ -92,7 +92,7 @@ class Release062BlackjackTests(unittest.TestCase):
started = self.command(command, sender=sender)
self.assertTrue(
started.startswith(
"alice:" if sender == "Alice" else "bob:"
"alice #1:" if sender == "Alice" else "bob #1:"
)
)
self.assertIn("下注 50", started)
@@ -109,7 +109,7 @@ class Release062BlackjackTests(unittest.TestCase):
):
reply = self.command("/21点开始")
self.assertTrue(reply.startswith("alice:"))
self.assertTrue(reply.startswith("alice #1:"))
self.assertIn("下注 50", reply)
self.assertEqual(self.state()["balance"], 950)
+252
View File
@@ -0,0 +1,252 @@
"""Behavioral coverage for multiplayer lottery and indexed game records."""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
class Release063GameTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
self.counter = 0
def tearDown(self):
self.engine.ledger.connection.close()
self.temp.cleanup()
def command(
self,
question,
*,
sender="Alice",
group="group-a",
event_id=None,
):
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": group,
"event_id": event_id or f"test-{self.counter}",
}
return self.engine.local.handle(row, {})
def state(self, sender="Alice", group="group-a"):
key = self.engine.ledger.resolve_person(group, sender)["person_key"]
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
(group, key),
).fetchone()
return json.loads(row[0])
def test_lottery_number_forms_sort_and_validate(self):
parse = self.engine.local.players.parse_lottery_entry
self.assertEqual(parse("347"), ((3, 4, 7), 10))
self.assertEqual(parse("743"), ((3, 4, 7), 10))
self.assertEqual(parse("报名 3 4 7 100"), ((3, 4, 7), 100))
self.assertEqual(parse("报名347100"), ((3, 4, 7), 100))
self.assertEqual(parse("743金额500"), ((3, 4, 7), 500))
with self.assertRaisesRegex(ValueError, "互不重复"):
parse("337")
with self.assertRaisesRegex(ValueError, "1~500"):
parse("347501")
def test_multiplayer_lottery_status_draw_and_payouts(self):
self.assertIn("第 1 把已开始", self.command("/彩票开始"))
self.assertIn("alice #1", self.command("/彩票347100"))
self.assertIn(
"bob #1",
self.command("/彩票 报名 9 3 7 200", sender="Bob"),
)
self.assertIn(
"carol #1",
self.command("/彩票 1 2 3 100", sender="Carol"),
)
self.assertIn(
"Dave #1",
self.command("/彩票045100", sender="Dave"),
)
status = self.command("/彩票当前状态", sender="Bob")
self.assertIn("已报名 4 人", status)
self.assertIn("alice #1:3 4 7,下注 100", status)
self.assertIn("bob #1:3 7 9,下注 200", status)
self.assertIn("只有本轮主持人 alice", self.command(
"/彩票开奖", sender="Bob"))
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[7, 3, 9],
):
result = self.command("/彩票开奖", event_id="draw-round-1")
self.assertIn("开奖号:3 7 9", result)
self.assertIn("alice #1:命中 2 个", result)
self.assertIn("返还 120", result)
self.assertIn("bob #1:命中 3 个", result)
self.assertIn("返还 600", result)
self.assertIn("carol #1:命中 1 个", result)
self.assertIn("返还 50", result)
self.assertIn("Dave #1:命中 0 个", result)
self.assertEqual(self.state()["balance"], 1020)
self.assertEqual(self.state("Bob")["balance"], 1400)
self.assertEqual(self.state("Carol")["balance"], 950)
self.assertEqual(self.state("Dave")["balance"], 900)
self.assertIn("命中2个", self.command("/游戏记录"))
self.assertIn("命中3个", self.command(
"/个人记录", sender="Bob"))
self.assertIn("没有进行中", self.command("/彩票当前状态"))
def test_registration_update_keeps_index_and_cancel_refunds(self):
self.command("/彩票 开始")
first = self.command("/彩票 347 100")
updated = self.command("/彩票 743 200", sender="a1")
self.assertIn("alice #1", first)
self.assertIn("alice #1", updated)
self.assertIn("报名已更新", updated)
self.assertEqual(self.state()["balance"], 800)
status = self.command("/彩票 状态")
self.assertIn("已报名 1 人", status)
self.assertIn("3 4 7,下注 200", status)
self.assertIn("只有本轮主持人 alice", self.command(
"/彩票 取消", sender="Bob"))
self.assertIn("已全额退回", self.command("/彩票取消"))
self.assertEqual(self.state()["balance"], 1000)
self.assertIn("净变化 +0", self.command("/游戏记录"))
self.command("/彩票开始")
second = self.command("/彩票347")
self.assertIn("alice #2", second)
def test_concurrent_registration_replay_debits_once(self):
self.command("/彩票开始")
replies = []
errors = []
def register():
try:
replies.append(
self.command("/彩票347500", event_id="same-entry")
)
except BaseException as error:
errors.append(error)
threads = [threading.Thread(target=register) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertFalse(errors)
self.assertEqual(len(set(replies)), 1)
self.assertEqual(self.state()["balance"], 500)
self.assertIn("已报名 1 人", self.command("/彩票状态"))
def test_blackjack_uses_1_to_500_and_indexed_history(self):
def tied_deck(cards):
first = [9, 6, 8, 7]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=tied_deck,
):
started = self.command("/21点开始500")
self.assertTrue(started.startswith("alice #1:"))
self.assertEqual(self.state()["balance"], 500)
settled = self.command("/21点停牌")
self.assertTrue(settled.startswith("alice #1:"))
self.assertIn("平局", settled)
self.assertEqual(self.state()["balance"], 1000)
history = self.command("/游戏记录")
self.assertIn("alice #1:21点 平局", history)
self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501"))
def test_prize_draw_has_indexed_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
first = self.command("/抽奖10")
second = self.command("/抽奖10")
self.assertTrue(first.startswith("alice #1:"))
self.assertTrue(second.startswith("alice #2:"))
history = self.command("/gamehistory")
self.assertIn("alice #2:抽奖 未中奖", history)
self.assertIn("alice #1:抽奖 未中奖", history)
def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name)
self.command("/彩票开始")
self.command("/彩票347100")
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(root))
self.assertIn("alice #1:3 4 7", self.command("/彩票状态"))
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[3, 4, 7],
):
self.command("/彩票开奖")
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(root))
history = self.command("/游戏记录")
self.assertIn("alice #1:彩票 命中3个", history)
self.assertEqual(self.state()["balance"], 1200)
def test_registration_rolls_back_when_response_building_fails(self):
ledger = self.engine.ledger
ledger.start_group_lottery(
"group-a",
"Alice",
"start",
"lottery:start",
lambda _: "started",
)
def fail(_):
raise RuntimeError("render failed")
with self.assertRaisesRegex(RuntimeError, "render failed"):
ledger.register_group_lottery(
"group-a",
"Alice",
"register",
"lottery:347100",
(3, 4, 7),
100,
fail,
)
account = ledger.connection.execute(
"SELECT COUNT(*) FROM player_accounts"
).fetchone()[0]
entries = ledger.connection.execute(
"SELECT COUNT(*) FROM group_lottery_entries"
).fetchone()[0]
actions = ledger.connection.execute(
"SELECT COUNT(*) FROM player_actions WHERE event_id = 'register'"
).fetchone()[0]
self.assertEqual((account, entries, actions), (0, 0, 0))
if __name__ == "__main__":
unittest.main()