This commit is contained in:
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "0.6.3a0+1015"
|
||||
version = "0.6.4a0+1015"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==0.6.3a0+1015",
|
||||
"jwebot-sdk==0.6.3a0+1015",
|
||||
"jwebot-official-extensions==0.6.3a0+1015",
|
||||
"jwebot-config==0.6.4a0+1015",
|
||||
"jwebot-sdk==0.6.4a0+1015",
|
||||
"jwebot-official-extensions==0.6.4a0+1015",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "0.6.3a0+1015"
|
||||
__version__ = "0.6.4a0+1015"
|
||||
|
||||
@@ -1070,6 +1070,71 @@ class Ledger:
|
||||
ON group_lottery_entries(
|
||||
group_fingerprint, round_number, created_at, person_key
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autochess_runs (
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
season_number INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
host_person_key TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_fingerprint, run_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS autochess_one_active_run
|
||||
ON autochess_runs(group_fingerprint)
|
||||
WHERE state IN ('registration', 'active');
|
||||
CREATE TABLE IF NOT EXISTS autochess_action_replay (
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
person_key TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
response_text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_fingerprint, event_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autochess_story_logs (
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
story_id TEXT NOT NULL,
|
||||
round_number INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
story_type TEXT NOT NULL,
|
||||
story_text TEXT NOT NULL,
|
||||
facts_json TEXT NOT NULL,
|
||||
source_event_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_fingerprint, run_id, story_id),
|
||||
FOREIGN KEY (group_fingerprint, run_id)
|
||||
REFERENCES autochess_runs(group_fingerprint, run_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS autochess_battles (
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
battle_id TEXT NOT NULL,
|
||||
round_number INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
battle_type TEXT NOT NULL,
|
||||
player_a TEXT NOT NULL,
|
||||
player_b TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
source_event_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_fingerprint, run_id, battle_id),
|
||||
FOREIGN KEY (group_fingerprint, run_id)
|
||||
REFERENCES autochess_runs(group_fingerprint, run_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS autochess_story_run_round
|
||||
ON autochess_story_logs(
|
||||
group_fingerprint, run_id, round_number, story_id
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS autochess_battle_run_round
|
||||
ON autochess_battles(
|
||||
group_fingerprint, run_id, round_number, battle_id
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_group_created
|
||||
ON events(group_fingerprint, created_at);
|
||||
CREATE INDEX IF NOT EXISTS outbox_due
|
||||
@@ -3634,6 +3699,250 @@ class Ledger:
|
||||
self.connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def _autochess_action_replay(
|
||||
self,
|
||||
group: str,
|
||||
event_id: str,
|
||||
person_key: str,
|
||||
command: str,
|
||||
) -> str | None:
|
||||
previous = self.connection.execute(
|
||||
"""
|
||||
SELECT person_key, command, response_text
|
||||
FROM autochess_action_replay
|
||||
WHERE group_fingerprint = ? AND event_id = ?
|
||||
""",
|
||||
(group, event_id),
|
||||
).fetchone()
|
||||
if previous is None:
|
||||
return None
|
||||
if previous["person_key"] != person_key or previous["command"] != command:
|
||||
raise LocalCommandError("同一事件不能变更自走棋玩家或指令")
|
||||
return str(previous["response_text"])
|
||||
|
||||
def _load_autochess_run(
|
||||
self,
|
||||
group: str,
|
||||
) -> tuple[sqlite3.Row | None, dict[str, Any] | None]:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT * FROM autochess_runs
|
||||
WHERE group_fingerprint = ?
|
||||
ORDER BY
|
||||
CASE WHEN state IN ('registration', 'active') THEN 0 ELSE 1 END,
|
||||
season_number DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(group,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None, None
|
||||
try:
|
||||
state = json.loads(str(row["state_json"]))
|
||||
except json.JSONDecodeError as error:
|
||||
raise LocalCommandError("自走棋状态无效") from error
|
||||
if not isinstance(state, dict):
|
||||
raise LocalCommandError("自走棋状态无效")
|
||||
return row, state
|
||||
|
||||
@staticmethod
|
||||
def _validate_autochess_state(state: dict[str, Any]) -> None:
|
||||
status = state.get("status")
|
||||
if status not in {"registration", "active", "completed", "cancelled"}:
|
||||
raise LocalCommandError("自走棋赛事状态无效")
|
||||
if (
|
||||
not isinstance(state.get("run_id"), str)
|
||||
or not state["run_id"]
|
||||
or type(state.get("season_number")) is not int
|
||||
or int(state["season_number"]) < 1
|
||||
or not isinstance(state.get("host_person_key"), str)
|
||||
or not state["host_person_key"]
|
||||
or not isinstance(state.get("players"), dict)
|
||||
):
|
||||
raise LocalCommandError("自走棋赛事结构无效")
|
||||
if status in {"active", "completed"}:
|
||||
players = state["players"].values()
|
||||
if any(
|
||||
not isinstance(player, dict)
|
||||
or not isinstance(player.get("board"), list)
|
||||
or len(player["board"]) != 5
|
||||
for player in players
|
||||
):
|
||||
raise LocalCommandError("自走棋比赛必须保持五人首发")
|
||||
|
||||
def apply_autochess_action(
|
||||
self,
|
||||
group: str,
|
||||
sender: str,
|
||||
event_id: str,
|
||||
command: str,
|
||||
action: Callable[
|
||||
[dict[str, Any] | None, dict[str, str], int],
|
||||
dict[str, Any],
|
||||
],
|
||||
) -> str:
|
||||
"""Commit autochess state, replay, stories and battles atomically."""
|
||||
person = self._player_identity(group, sender)
|
||||
person_key = str(person["person_key"])
|
||||
with self.lock:
|
||||
self.connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
replay = self._autochess_action_replay(
|
||||
group,
|
||||
event_id,
|
||||
person_key,
|
||||
command,
|
||||
)
|
||||
if replay is not None:
|
||||
self.connection.execute("COMMIT")
|
||||
return replay
|
||||
stored, state = self._load_autochess_run(group)
|
||||
next_season = int(
|
||||
self.connection.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(season_number), 0) + 1
|
||||
FROM autochess_runs
|
||||
WHERE group_fingerprint = ?
|
||||
""",
|
||||
(group,),
|
||||
).fetchone()[0]
|
||||
)
|
||||
result = action(state, person, next_season)
|
||||
if not isinstance(result, dict):
|
||||
raise LocalCommandError("自走棋动作结果无效")
|
||||
response = result.get("response")
|
||||
new_state = result.get("state")
|
||||
stories = result.get("stories", [])
|
||||
battles = result.get("battles", [])
|
||||
if not isinstance(response, str) or not response.strip():
|
||||
raise LocalCommandError("自走棋响应无效")
|
||||
if not isinstance(stories, list) or not isinstance(battles, list):
|
||||
raise LocalCommandError("自走棋归档无效")
|
||||
timestamp = utc_now()
|
||||
run_id = str(stored["run_id"]) if stored is not None else ""
|
||||
if new_state is not None:
|
||||
if not isinstance(new_state, dict):
|
||||
raise LocalCommandError("自走棋状态无效")
|
||||
self._validate_autochess_state(new_state)
|
||||
run_id = str(new_state["run_id"])
|
||||
season_number = int(new_state["season_number"])
|
||||
existing = self.connection.execute(
|
||||
"""
|
||||
SELECT created_at FROM autochess_runs
|
||||
WHERE group_fingerprint = ? AND run_id = ?
|
||||
""",
|
||||
(group, run_id),
|
||||
).fetchone()
|
||||
created_at = (
|
||||
str(existing["created_at"])
|
||||
if existing is not None
|
||||
else timestamp
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO autochess_runs (
|
||||
group_fingerprint, run_id, season_number, state,
|
||||
host_person_key, state_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(group_fingerprint, run_id) DO UPDATE SET
|
||||
season_number = excluded.season_number,
|
||||
state = excluded.state,
|
||||
host_person_key = excluded.host_person_key,
|
||||
state_json = excluded.state_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
group,
|
||||
run_id,
|
||||
season_number,
|
||||
str(new_state["status"]),
|
||||
str(new_state["host_person_key"]),
|
||||
canonical_json(new_state),
|
||||
created_at,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
for item in stories:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("story_id"), str)
|
||||
or not isinstance(item.get("text"), str)
|
||||
or not isinstance(item.get("facts", {}), dict)
|
||||
):
|
||||
raise LocalCommandError("自走棋剧情归档无效")
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO autochess_story_logs (
|
||||
group_fingerprint, run_id, story_id,
|
||||
round_number, phase, story_type, story_text,
|
||||
facts_json, source_event_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
group,
|
||||
run_id,
|
||||
str(item["story_id"]),
|
||||
int(item.get("round_number", 0)),
|
||||
str(item.get("phase", "")),
|
||||
str(item.get("story_type", "")),
|
||||
str(item["text"]),
|
||||
canonical_json(item.get("facts", {})),
|
||||
event_id,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
for item in battles:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("battle_id"), str)
|
||||
or not isinstance(item.get("result"), dict)
|
||||
):
|
||||
raise LocalCommandError("自走棋战斗归档无效")
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO autochess_battles (
|
||||
group_fingerprint, run_id, battle_id,
|
||||
round_number, phase, battle_type, player_a,
|
||||
player_b, result_json, source_event_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
group,
|
||||
run_id,
|
||||
str(item["battle_id"]),
|
||||
int(item.get("round_number", 0)),
|
||||
str(item.get("phase", "")),
|
||||
str(item.get("battle_type", "")),
|
||||
str(item.get("player_a", "")),
|
||||
str(item.get("player_b", "")),
|
||||
canonical_json(item["result"]),
|
||||
event_id,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO autochess_action_replay (
|
||||
group_fingerprint, event_id, run_id, person_key,
|
||||
command, response_text, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
group,
|
||||
event_id,
|
||||
run_id,
|
||||
person_key,
|
||||
command,
|
||||
response,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
self.connection.execute("COMMIT")
|
||||
return response
|
||||
except BaseException:
|
||||
self.connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self.lock:
|
||||
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
|
||||
Reference in New Issue
Block a user