ludotastic 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ludotastic/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """Where humans and AI come to play."""
2
+
3
+ __all__ = [
4
+ "ENTRY_POINT_GROUP",
5
+ "AIPlayer",
6
+ "ActionMode",
7
+ "Decision",
8
+ "Game",
9
+ "GameInfo",
10
+ "GameRegistry",
11
+ "HumanPlayer",
12
+ "Match",
13
+ "MatchStatus",
14
+ "MoveRecord",
15
+ "Outcome",
16
+ "Player",
17
+ "PlayerKind",
18
+ "RandomBot",
19
+ "RenderContext",
20
+ "Renderer",
21
+ "SeatInfo",
22
+ "Settings",
23
+ "Timing",
24
+ "TurnContext",
25
+ "View",
26
+ "get_settings",
27
+ "registry",
28
+ ]
29
+
30
+ from .engine import (
31
+ ENTRY_POINT_GROUP,
32
+ ActionMode,
33
+ AIPlayer,
34
+ Decision,
35
+ Game,
36
+ GameInfo,
37
+ GameRegistry,
38
+ HumanPlayer,
39
+ Match,
40
+ MatchStatus,
41
+ MoveRecord,
42
+ Outcome,
43
+ Player,
44
+ PlayerKind,
45
+ RandomBot,
46
+ RenderContext,
47
+ Renderer,
48
+ SeatInfo,
49
+ Timing,
50
+ TurnContext,
51
+ View,
52
+ registry,
53
+ )
54
+ from .settings import Settings, get_settings
ludotastic/api.py ADDED
@@ -0,0 +1,191 @@
1
+ """The REST API."""
2
+
3
+ from typing import Annotated, Any, cast
4
+
5
+ from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, status
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ludotastic.engine.game import Game, GameInfo
9
+ from ludotastic.engine.match import Match, MatchStatus, MoveRecord, SeatInfo
10
+ from ludotastic.service import MatchService, PlayerSpec, bot_names
11
+ from ludotastic.storage import MatchRecord, PlayerStats
12
+
13
+ __all__ = ["create_router"]
14
+
15
+
16
+ class GameDetails(BaseModel):
17
+ """A game, as the API describes it."""
18
+
19
+ info: GameInfo
20
+ rules: str
21
+ bots: list[str]
22
+ state_schema: dict[str, Any]
23
+ action_schema: dict[str, Any]
24
+
25
+
26
+ class NewMatch(BaseModel):
27
+ """What it takes to start a match."""
28
+
29
+ game_id: str
30
+ players: list[PlayerSpec] = Field(min_length=1)
31
+ seed: int | None = None
32
+
33
+
34
+ class MatchView(BaseModel):
35
+ """A live match."""
36
+
37
+ id: str
38
+ game_id: str
39
+ status: MatchStatus
40
+ seed: int
41
+ turn: int
42
+ seats: list[SeatInfo]
43
+ to_move: list[int]
44
+ """Seats being waited on right now."""
45
+ state: dict[str, Any]
46
+ legal_actions: dict[int, list[dict[str, Any]]]
47
+ """Legal actions for every seat being waited on."""
48
+ history: list[MoveRecord]
49
+ scores: list[float] | None
50
+ reason: str | None
51
+
52
+ @classmethod
53
+ def of(cls, match: Match) -> MatchView:
54
+ """Describe a match."""
55
+ game: Game[Any, Any] = match.game
56
+ thinking = sorted(match.thinking)
57
+ return cls(
58
+ id=match.id,
59
+ game_id=game.info.id,
60
+ status=match.status,
61
+ seed=match.seed,
62
+ turn=match.turn,
63
+ seats=match.seats,
64
+ to_move=thinking,
65
+ state=match.state.model_dump(mode="json"),
66
+ legal_actions={
67
+ seat: [
68
+ a.model_dump(mode="json")
69
+ for a in game.legal_actions(match.state, seat)
70
+ ]
71
+ for seat in thinking
72
+ },
73
+ history=match.history,
74
+ scores=list(match.outcome.scores) if match.outcome else None,
75
+ reason=match.outcome.reason if match.outcome else match.error,
76
+ )
77
+
78
+
79
+ def _service(request: Request) -> MatchService:
80
+ return cast("MatchService", request.app.state.service)
81
+
82
+
83
+ type Service = Annotated[MatchService, Depends(_service)]
84
+
85
+
86
+ def _live(service: MatchService, match_id: str) -> Match:
87
+ match = service.get(match_id)
88
+ if match is None:
89
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "No live match with that id")
90
+ return match
91
+
92
+
93
+ def create_router() -> APIRouter: # noqa: C901
94
+ """Build the API routes; they expect `app.state.service` to be set."""
95
+ router = APIRouter(prefix="/api", tags=["ludotastic"])
96
+
97
+ @router.get("/games")
98
+ def list_games(service: Service) -> list[GameInfo]:
99
+ """Every installed game."""
100
+ return [service.games[game_id].info for game_id in service.games]
101
+
102
+ @router.get("/games/{game_id}")
103
+ def get_game(service: Service, game_id: str) -> GameDetails:
104
+ """One game in detail, including the JSON schemas of its state and actions."""
105
+ try:
106
+ game_type = service.games[game_id]
107
+ except KeyError:
108
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Unknown game") from None
109
+ game = game_type()
110
+ return GameDetails(
111
+ info=game.info,
112
+ rules=game.rules,
113
+ bots=bot_names(game),
114
+ state_schema=game.state_type.model_json_schema(),
115
+ action_schema=game.action_type.model_json_schema(),
116
+ )
117
+
118
+ @router.post("/matches", status_code=status.HTTP_201_CREATED)
119
+ async def create_match(service: Service, body: NewMatch) -> MatchView:
120
+ """Start a match; it runs in the background."""
121
+ try:
122
+ match = service.create(body.game_id, body.players, seed=body.seed)
123
+ except KeyError:
124
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Unknown game") from None
125
+ except ValueError as e:
126
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(e)) from None
127
+ return MatchView.of(match)
128
+
129
+ @router.get("/matches")
130
+ async def list_live_matches(service: Service) -> list[MatchView]:
131
+ """Matches being played right now."""
132
+ return [MatchView.of(m) for m in service.live()]
133
+
134
+ @router.get("/matches/{match_id}")
135
+ async def get_match(service: Service, match_id: str) -> MatchView:
136
+ """A live match."""
137
+ return MatchView.of(_live(service, match_id))
138
+
139
+ @router.post("/matches/{match_id}/seats/{seat}/action")
140
+ async def submit_action(
141
+ service: Service,
142
+ match_id: str,
143
+ seat: int,
144
+ action: Annotated[dict[str, Any], Body()],
145
+ ) -> MatchView:
146
+ """Act for a human seat."""
147
+ match = _live(service, match_id)
148
+ if not 0 <= seat < len(match.players):
149
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "No such seat")
150
+ try:
151
+ match.submit(seat, action)
152
+ except TypeError as e:
153
+ raise HTTPException(status.HTTP_403_FORBIDDEN, str(e)) from None
154
+ except ValueError as e:
155
+ raise HTTPException(status.HTTP_409_CONFLICT, str(e)) from None
156
+ return MatchView.of(match)
157
+
158
+ @router.delete("/matches/{match_id}", status_code=status.HTTP_204_NO_CONTENT)
159
+ async def abort_match(service: Service, match_id: str) -> None:
160
+ """Abort a live match."""
161
+ _live(service, match_id).abort("Aborted through the API")
162
+
163
+ @router.get("/history")
164
+ def history(
165
+ service: Service, game_id: str | None = None, limit: int = 50
166
+ ) -> list[MatchRecord]:
167
+ """Recent matches, newest first."""
168
+ return service.store.recent(game_id=game_id, limit=limit)
169
+
170
+ @router.get("/history/{match_id}")
171
+ def history_match(service: Service, match_id: str) -> MatchRecord:
172
+ """A stored match, with its full move history."""
173
+ record = service.store.get(match_id)
174
+ if record is None:
175
+ raise HTTPException(
176
+ status.HTTP_404_NOT_FOUND, "No stored match with that id"
177
+ )
178
+ return record
179
+
180
+ @router.get("/stats")
181
+ def stats(service: Service, game_id: str | None = None) -> list[PlayerStats]:
182
+ """Results per game and player group."""
183
+ return service.store.stats(game_id=game_id)
184
+
185
+ return router
186
+
187
+
188
+ def mount_api(app: FastAPI, service: MatchService) -> None:
189
+ """Add the API to an app."""
190
+ app.state.service = service
191
+ app.include_router(create_router())
ludotastic/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """The `ludotastic` command."""
2
+
3
+ import asyncio
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+ from dotenv import load_dotenv
8
+ from pydantic_ai import prices
9
+
10
+ from ludotastic.engine.game import ActionMode, View
11
+ from ludotastic.engine.registry import registry
12
+ from ludotastic.engine.usage import Usage, format_cost, format_tokens
13
+ from ludotastic.service import AISpec, BotSpec, MatchService, bot_names
14
+ from ludotastic.settings import get_settings
15
+
16
+ __all__ = ["app"]
17
+
18
+ app = typer.Typer(
19
+ name="ludotastic",
20
+ help="Where humans and AI come to play.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+
25
+ @app.command()
26
+ def serve(
27
+ host: Annotated[str | None, typer.Option(help="Address to listen on.")] = None,
28
+ port: Annotated[int | None, typer.Option(help="Port to listen on.")] = None,
29
+ ) -> None:
30
+ """Run the playground and the API."""
31
+ import uvicorn # noqa: PLC0415
32
+
33
+ from ludotastic.web import create_app # noqa: PLC0415
34
+
35
+ settings = get_settings()
36
+ uvicorn.run(
37
+ create_app(),
38
+ host=host or settings.host,
39
+ port=port or settings.port,
40
+ )
41
+
42
+
43
+ @app.command("games")
44
+ def list_games() -> None:
45
+ """List the installed games."""
46
+ for game_id in registry:
47
+ game = registry[game_id]()
48
+ info = game.info
49
+ modes = ", ".join(info.action_modes)
50
+ typer.echo(
51
+ f"{game_id}: {info.name} ({info.min_players}-{info.max_players} players, "
52
+ f"{info.timing}; bots: {', '.join(bot_names(game))}; AI modes: {modes})"
53
+ )
54
+ for name, error in registry.errors.items():
55
+ typer.secho(f"{name}: failed to load: {error}", fg=typer.colors.RED, err=True)
56
+
57
+
58
+ def _spec(text: str) -> BotSpec | AISpec:
59
+ """Parse `bot:NAME`, or a model name with optional `@mode` and `+view`.
60
+
61
+ Examples:
62
+ >>> spec = _spec("typesafe:jev-latest@choice+image")
63
+ >>> spec.model, str(spec.mode), str(spec.view)
64
+ ('typesafe:jev-latest', 'choice', 'image')
65
+ """
66
+ if text.startswith("bot:"):
67
+ return BotSpec(bot=text.removeprefix("bot:"))
68
+ rest, _, view = text.partition("+")
69
+ model, _, mode = rest.partition("@")
70
+ return AISpec(
71
+ model=model,
72
+ mode=ActionMode(mode) if mode else None,
73
+ view=View(view) if view else View.TEXT,
74
+ )
75
+
76
+
77
+ @app.command()
78
+ def play(
79
+ game_id: Annotated[str, typer.Argument(help="The game to play.")],
80
+ players: Annotated[
81
+ list[str],
82
+ typer.Argument(
83
+ help=(
84
+ "One per seat: `bot:NAME` for a bot, or a model name such as "
85
+ "`typesafe:jev-latest`, optionally with `@choice`, `@structured` "
86
+ "or `@tools`, and `+image` or `+both` to show it pictures."
87
+ )
88
+ ),
89
+ ],
90
+ rounds: Annotated[int, typer.Option(help="How many matches to play.")] = 1,
91
+ seed: Annotated[int | None, typer.Option(help="Seed for the first match.")] = None,
92
+ swap: Annotated[
93
+ bool, typer.Option(help="Rotate seats every round, for fairness.")
94
+ ] = True,
95
+ quiet: Annotated[bool, typer.Option(help="Only print the summary.")] = False,
96
+ ) -> None:
97
+ """Play matches between bots and AI models, without the UI."""
98
+ settings = get_settings().model_copy(update={"ai_delay": 0.0})
99
+ service = MatchService(settings=settings)
100
+ specs = [_spec(p) for p in players]
101
+ totals: dict[str, float] = {}
102
+ spent: dict[str, Usage] = {}
103
+
104
+ async def run_all() -> None:
105
+ for round_ in range(rounds):
106
+ shift = round_ % len(specs) if swap else 0
107
+ seated = specs[shift:] + specs[:shift]
108
+ match = service.create(
109
+ game_id,
110
+ seated,
111
+ seed=None if seed is None else seed + round_,
112
+ start=False,
113
+ )
114
+ if not quiet:
115
+ match.subscribe(_printer())
116
+ await match.run()
117
+ names = [p.label for p in match.players]
118
+ for seat, name in enumerate(names):
119
+ spent[name] = spent.get(name, Usage()) + match.usage(seat)
120
+ if match.outcome is None:
121
+ typer.secho(f"Round {round_ + 1}: aborted: {match.error}", fg="red")
122
+ continue
123
+ for name, score in zip(names, match.outcome.scores, strict=True):
124
+ totals[name] = totals.get(name, 0.0) + score
125
+ result = ", ".join(
126
+ f"{n}={s:g}" for n, s in zip(names, match.outcome.scores, strict=True)
127
+ )
128
+ typer.echo(f"Round {round_ + 1}: {match.outcome.reason} ({result})")
129
+
130
+ updater = prices.update_in_background() if settings.update_prices else None
131
+ try:
132
+ asyncio.run(run_all())
133
+ except KeyError:
134
+ typer.secho(f"Unknown game: {game_id}", fg="red", err=True)
135
+ raise typer.Exit(1) from None
136
+ except ValueError as e:
137
+ typer.secho(str(e), fg="red", err=True)
138
+ raise typer.Exit(1) from None
139
+ finally:
140
+ if updater is not None:
141
+ updater.stop()
142
+ service.store.close()
143
+ _summary(totals, spent)
144
+
145
+
146
+ def _summary(totals: dict[str, float], spent: dict[str, Usage]) -> None:
147
+ typer.echo("Total score:")
148
+ for name, score in sorted(totals.items(), key=lambda kv: -kv[1]):
149
+ usage = spent.get(name, Usage())
150
+ cost = (
151
+ f" ({format_cost(usage.cost, priced=usage.priced)}, "
152
+ f"{format_tokens(usage.tokens)} tokens, {usage.requests} requests)"
153
+ if usage.requests
154
+ else ""
155
+ )
156
+ typer.echo(f" {name}: {score:g}{cost}")
157
+
158
+
159
+ def _printer() -> Any: # noqa: ANN401
160
+ printed = 0
161
+
162
+ def show(match: Any) -> None: # noqa: ANN401
163
+ nonlocal printed
164
+ for move in match.history[printed:]:
165
+ typer.echo(
166
+ f" {move.turn + 1}. {match.game.player_label(move.seat)}: "
167
+ f"{move.description}"
168
+ )
169
+ printed = len(match.history)
170
+
171
+ return show
172
+
173
+
174
+ def main() -> None:
175
+ """Run the CLI.
176
+
177
+ Variables in a `.env` file in the working directory are loaded first, so
178
+ model provider keys such as `TYPESAFE_API_KEY` can live there. Variables
179
+ already set in the environment win.
180
+ """
181
+ load_dotenv(".env")
182
+ app()
@@ -0,0 +1,33 @@
1
+ """The game engine: games, players, matches and the plugin registry."""
2
+
3
+ __all__ = [
4
+ "ENTRY_POINT_GROUP",
5
+ "AIPlayer",
6
+ "ActionMode",
7
+ "Decision",
8
+ "Game",
9
+ "GameInfo",
10
+ "GameRegistry",
11
+ "HumanPlayer",
12
+ "Match",
13
+ "MatchStatus",
14
+ "MoveRecord",
15
+ "Outcome",
16
+ "Player",
17
+ "PlayerKind",
18
+ "RandomBot",
19
+ "RenderContext",
20
+ "Renderer",
21
+ "SeatInfo",
22
+ "Timing",
23
+ "TurnContext",
24
+ "View",
25
+ "registry",
26
+ ]
27
+
28
+ from .ai import AIPlayer
29
+ from .game import ActionMode, Game, GameInfo, Outcome, Timing, View
30
+ from .match import Match, MatchStatus, MoveRecord, SeatInfo
31
+ from .players import Decision, HumanPlayer, Player, PlayerKind, RandomBot, TurnContext
32
+ from .registry import ENTRY_POINT_GROUP, GameRegistry, registry
33
+ from .render import RenderContext, Renderer