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 +54 -0
- ludotastic/api.py +191 -0
- ludotastic/cli.py +182 -0
- ludotastic/engine/__init__.py +33 -0
- ludotastic/engine/ai.py +276 -0
- ludotastic/engine/drawing.py +52 -0
- ludotastic/engine/game.py +342 -0
- ludotastic/engine/match.py +378 -0
- ludotastic/engine/models.py +89 -0
- ludotastic/engine/players.py +159 -0
- ludotastic/engine/registry.py +100 -0
- ludotastic/engine/render.py +41 -0
- ludotastic/engine/usage.py +116 -0
- ludotastic/games/__init__.py +5 -0
- ludotastic/games/snake/__init__.py +5 -0
- ludotastic/games/snake/game.py +490 -0
- ludotastic/games/snake/ui.py +85 -0
- ludotastic/games/tictactoe/__init__.py +5 -0
- ludotastic/games/tictactoe/game.py +314 -0
- ludotastic/games/tictactoe/ui.py +63 -0
- ludotastic/games/twentyfortyeight/__init__.py +17 -0
- ludotastic/games/twentyfortyeight/game.py +450 -0
- ludotastic/games/twentyfortyeight/ui.py +90 -0
- ludotastic/py.typed +0 -0
- ludotastic/service.py +229 -0
- ludotastic/settings.py +79 -0
- ludotastic/storage.py +176 -0
- ludotastic/web/__init__.py +51 -0
- ludotastic/web/match_view.py +260 -0
- ludotastic/web/pages.py +364 -0
- ludotastic-0.2.1.dist-info/METADATA +223 -0
- ludotastic-0.2.1.dist-info/RECORD +35 -0
- ludotastic-0.2.1.dist-info/WHEEL +4 -0
- ludotastic-0.2.1.dist-info/entry_points.txt +7 -0
- ludotastic-0.2.1.dist-info/licenses/LICENSE +21 -0
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
|