Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions akagi/akagi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@
import re
import sys
import json
import time
import atexit
import random
import pathlib
import traceback
import jsonschema
import subprocess
from pathlib import Path
from sys import executable
from threading import Thread
from functools import partial
from datetime import datetime

Expand All @@ -39,13 +35,15 @@
from mjai_bot.bot import AkagiBot
from mjai_bot.controller import Controller
from autoplay.autoplay import AutoPlay, AUTOPLAY_PRIVATE
from dataserver.controller import DataServerController
from settings import MITMType, Settings, load_settings, get_settings, get_schema, verify_settings, save_settings
from settings.settings import settings

mitm_client: Client = None
mjai_controller: Controller = None
mjai_bot: AkagiBot = None
autoplay: AutoPlay = None
dataserver_controller: DataServerController = DataServerController()

# ============================================= #
# Settings Screen #
Expand Down Expand Up @@ -224,6 +222,7 @@ def settings_save_button_clicked(self) -> None:
global settings, mjai_controller, mitm_client, autoplay
local_settings = self.get_settings()["settings"]
logger.info(f"Verifying settings: {local_settings}")
previous_dataserver_enabled = settings.dataserver.enable
try:
jsonschema.validate(local_settings, get_schema())
if AUTOPLAY_PRIVATE:
Expand All @@ -242,6 +241,10 @@ def settings_save_button_clicked(self) -> None:
update_thinker = local_settings["autoplay_thinker"] != settings.autoplay_thinker
# Reload settings
settings.update(get_settings())
if previous_dataserver_enabled and not settings.dataserver.enable:
dataserver_controller.stop()
elif (not previous_dataserver_enabled) and settings.dataserver.enable:
dataserver_controller.start()
self.app.notify(
"Settings saved successfully, restart is required to apply changes.",
title="Settings Saved",
Expand Down Expand Up @@ -1085,6 +1088,7 @@ def main_loop(self) -> None:
best_action.update_best_action(mjai_response)
recommendation: Recommendations = self.query_one("#recommendation")
recommendation.update_recommendation(mjai_response)
dataserver_controller.push(mjai_response, mjai_bot)
# ============================================= #
# Autoplay and Actions #
# ============================================= #
Expand Down Expand Up @@ -1216,6 +1220,7 @@ def main():
global mitm_client, mjai_controller, mjai_bot, settings, autoplay

logger.info("Starting Akagi...")
dataserver_controller.start()
logger.info(f"MITM Proxy: {settings.mitm.host}:{settings.mitm.port} ({settings.mitm.type})")
mitm_client = Client()
logger.info(f"Starting MJAI controller")
Expand All @@ -1231,6 +1236,10 @@ def main():
app.run()
except KeyboardInterrupt:
logger.info("Stopping Akagi...")
mitm_client.stop()
logger.info("Akagi stopped")
sys.exit(0)
except Exception:
logger.error(f"App crashed: {traceback.format_exc()}")
finally:
mitm_client.stop()
dataserver_controller.stop()
logger.info("Akagi stopped")
sys.exit(0)
197 changes: 197 additions & 0 deletions dataserver/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import traceback

from akagi.libriichi_helper import meta_to_recommend
from mjai_bot.bot import AkagiBot
from settings.settings import settings

from .dataserver import DataServer
from .logger import logger


class DataServerController:
"""Manage dataserver lifecycle and push game state to SSE clients."""

def __init__(self):
self.server: DataServer | None = None

def start(self) -> None:
cfg = settings.dataserver
if not cfg.enable:
return
if self.server and self.server.running:
return

self.server = DataServer(host=cfg.host, port=cfg.port)
try:
self.server.start()
logger.info(f"Dataserver started on {cfg.host}:{cfg.port}")
except Exception:
logger.error(f"Failed to start dataserver: {traceback.format_exc()}")
self.server = None

def stop(self) -> None:
if self.server and self.server.running:
logger.info("Stopping dataserver...")
self.server.stop()
try:
self.server.join(timeout=5)
except Exception:
pass
self.server = None

def push(self, mjai_msg: dict, bot: AkagiBot | None) -> None:
"""
Push game state to dataserver for SSE broadcast.

Payload structure:
{
"recommendations": [ # Top action recommendations from model
{
"action": str, # Action type: discard tile / reach / chi_xxx / pon / ...
"confidence": float, # Model confidence score
"tile": str, # Target tile (mjai format)
"consumed": list[str] | None, # Tiles consumed for chi/pon/kan
},
...
],
"tehai": list[str], # Current hand tiles (mjai format)
"last_kawa_tile": str | None, # Last discarded tile by others
"best_action": { # Model's best action decision
"type": str, # Action type
"pai": str | None, # Target tile
"consumed": list[str] | None, # Consumed tiles
"tsumogiri": bool | None, # Whether it's tsumogiri
"actor": int | None, # Actor seat
},
}
"""
if not (settings.dataserver.enable and bot and self.server and self.server.running):
return

# Parse recommendations from model meta
meta = mjai_msg.get("meta")
recommendations = None
if meta and "q_values" in meta and "mask_bits" in meta:
try:
recommendations = meta_to_recommend(meta, bot.is_3p)
except Exception as e:
logger.debug(f"Failed to parse recommendations: {e}")

# Format recommendations for frontend
formatted = []
for action, confidence in recommendations or []:
rec = self._format_rec(action, confidence, bot)
if rec:
formatted.append(rec)

# Build best action from mjai response
best_action = None
if isinstance(mjai_msg, dict) and isinstance(mjai_msg.get("type"), str):
best_action = {"type": mjai_msg["type"]}
for key in ("pai", "consumed", "tsumogiri", "actor"):
if key in mjai_msg:
best_action[key] = mjai_msg[key]

payload = {
"recommendations": formatted or None,
"tehai": bot.tehai_mjai,
"last_kawa_tile": bot.last_kawa_tile,
"best_action": best_action,
}

try:
# logger.debug(f"Pushing to dataserver: {payload}")
self.server.update(payload)
except Exception:
logger.error(f"Failed to push to dataserver: {traceback.format_exc()}")

def _format_rec(self, action: str, confidence: float, bot: AkagiBot) -> dict | None:
"""
Format a single recommendation for SSE payload.
Maps action to tile and consumed tiles based on current game state.
"""
rec = {"action": action, "confidence": float(confidence)}
last = bot.last_kawa_tile

try:
# Reach - tile unknown until discard
if action == "reach":
if not bot.can_riichi:
return None
rec["tile"] = "?"
return rec

# Chi - find matching meld from candidates
if action in ("chi_low", "chi_mid", "chi_high"):
if not last:
return None
chi = bot.find_chi_candidates_simple()
meld = None
if action == "chi_low" and bot.can_chi_low:
meld = chi.chi_low_meld
elif action == "chi_mid" and bot.can_chi_mid:
meld = chi.chi_mid_meld
elif action == "chi_high" and bot.can_chi_high:
meld = chi.chi_high_meld
if not meld:
return None
rec["tile"], rec["consumed"] = meld
return rec

# Pon
if action == "pon":
if not (bot.can_pon and last):
return None
rec["tile"] = last
rec["consumed"] = [last[:2]] * 2
return rec

# Kan
if action == "kan_select":
if not bot.can_kan:
return None
if bot.can_daiminkan and last:
rec["tile"] = last
rec["consumed"] = [last[:2]] * 3
else:
rec["tile"] = "?"
return rec

# Hora (agari)
if action == "hora":
if not bot.can_agari:
return None
if bot.can_ron_agari and last:
rec["tile"] = last
elif bot.can_tsumo_agari and bot.last_self_tsumo:
rec["tile"] = bot.last_self_tsumo
else:
rec["tile"] = "?"
return rec

# Ryukyoku
if action == "ryukyoku":
if not bot.can_ryukyoku:
return None
rec["tile"] = "?"
return rec

# Nukidora (3p only)
if action == "nukidora":
rec["tile"] = "N"
return rec

# None (pass)
if action == "none":
rec["tile"] = "?"
return rec

# Default: discard tile
if not bot.can_discard:
return None
rec["tile"] = action
return rec

except Exception as e:
logger.debug(f"Failed to format recommendation '{action}': {e}")
return None
Loading