Skip to content

Commit

Permalink
add '--style=machine' for 'smawg play'
Browse files Browse the repository at this point in the history
  • Loading branch information
Expurple committed Nov 5, 2023
1 parent ae7ef26 commit f537a0e
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 19 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ and this project adheres to

## \[Unreleased]

\-
### Added

- `--style=machine` for `smawg play`.

## \[0.21.0] - 2023-09-30

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ and easy interoperability with other programming languages.
* Support for custom maps, races and other assets.
* Support for custom rules (see [docs/rules.md](docs/rules.md)).
* Deterministic or randomized outcomes.
* Machine-readable output for interoperability with other programming languages
(see [docs/style.md](docs/style.md)).

### Missing features

* Unique effects for each Race and Special Power.

### Future plans

* Options for more machine-readable CLI output.
* In-house AI and GUI examples.
* Better support for expansions.

Expand Down Expand Up @@ -70,7 +71,12 @@ A simple set of options to get you started:
python3 -m smawg play --relative-path assets/tiny.json
```

It should guide you through the usage. See `--help` for more details.
It should guide you through the usage.

You can also pass `--style=machine` to get machine-readable output and use it
from a different programming language (see [docs/style.md](docs/style.md))

See `--help` for more details.

### As a library

Expand Down
38 changes: 38 additions & 0 deletions docs/style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Style

`smawg play` has two output formats, optimized for different use cases:

- `human`. The default style, optimized for manual testing and exploration.
- `machine`. Optimized for machine readability. Its main purpose is to support
interoperability with clients in other programming languages.

The format is chosen through the `--style` parameter.

## Quick comparison

| Feature | `human` | `machine` |
| ------------------------------- | ------------------------------------------ | ---------------------------------- |
| Input prompt | `'> '` | None |
| Status messages | Startup, turn change, game end | None |
| `help` command prints | Help directly | JSON with a `"result"` string |
| Valid `show-...` commands print | Human-readable tables | JSON with a `"result"` array |
| Valid game actions print | Only dice roll results | Always JSON with a `"result"` |
| Invalid commands print | Human-readable error with help suggestion | JSON with detailed `"error"` |
| Empty commands | Do nothing | Print a JSON with `"error"` |
| Entering the dice value (`-d`) | On a separate line, after a prompt | On a separate line, with no prompt |
| Invalid dice values (`-d`) | Prompt to re-enter | Fail the action |
| `EOFError`, `KeyboardInterrupt` | Caught, cause to silently exit with code 1 | Not caught, cause a crash |

## Details for `machine`

The main interaction loop looks like this:

- The user gives a command. The syntax is exactly the same as with `human` style.
- `smawg` prints back a JSON object, containing either `"result"` or `"error"` key.

The only exception is `conquer-dice` command, when `smawg` is run with `-d` flag.
The user must enter the dice value on a separate line right after the command.
`smawg` won't respond with anything before that.

Obviously, `quit` command is also an exception, because it causes `smawg` to
exit, rather than do something and print the result.
146 changes: 136 additions & 10 deletions smawg/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import json
import sys
from abc import ABC, abstractmethod
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
from importlib import import_module
from pathlib import Path
Expand All @@ -21,7 +22,7 @@
from pydantic.dataclasses import dataclass
from tabulate import tabulate

from smawg import AbstractRules, Assets, Game, RulesViolation
from smawg import AbstractRules, Assets, Combo, Game, Player, RulesViolation
from smawg._metadata import PACKAGE_DIR, VERSION
from smawg.basic_rules import (
Abandon, Conquer, ConquerWithDice, Decline, Deploy, EndTurn, SelectCombo,
Expand Down Expand Up @@ -172,14 +173,33 @@ def _parse_command(line: str) -> _Command | None:
# The interactive interpreter
# -----------------------------------------------------------------------------

class _Client(ABC):
"""The interface for command line interaction styles."""

@abstractmethod
def __init__(self, game: Game) -> None:
...

@abstractmethod
def run(self) -> int:
...


def _autocomplete(text: str, state: int) -> str | None:
"""Command completer for `readline`."""
results: list[str | None] = [c for c in _COMMANDS if c.startswith(text)]
results.append(None)
return results[state]


def _read_dice() -> int:
def _init_readline() -> None:
import readline
readline.set_completer_delims(" ")
readline.set_completer(_autocomplete)
readline.parse_and_bind("tab: complete")


def _read_dice_with_reenter() -> int:
"""Get result of a dice roll from an interactive console."""
prompt = "Enter the result of the dice roll: "
while True:
Expand All @@ -189,8 +209,8 @@ def _read_dice() -> int:
prompt = "The result must be an integer, try again: "


class _Client:
"""Handles console IO."""
class _HumanClient(_Client):
"""Command line interactions with --style=human."""

def __init__(self, game: Game) -> None:
self.game = game
Expand All @@ -203,10 +223,7 @@ def run(self) -> int:
Return an exit code.
"""
print(_START_SCREEN)
import readline
readline.set_completer_delims(" ")
readline.set_completer(_autocomplete)
readline.parse_and_bind("tab: complete")
_init_readline()
try:
return self._run_main_loop()
except (EOFError, KeyboardInterrupt):
Expand Down Expand Up @@ -331,6 +348,99 @@ def _command_conquer_dice(self, region: int) -> None:
print(f"Rolled {dice_value} on the dice, conquest was {description}.")


class _MachineClient(_Client):
"""Command line interactions with --style=machine."""

def __init__(self, game: Game) -> None:
self.game = game

def run(self) -> int:
"""Interpret user commands until stopped by `'quit'`, ^C or ^D.
Return an exit code.
In contrast with `_HumanClient`, `EOFError` and `KeyboardInterrupt` are
intentionally not caught and cause a crash.
"""
_init_readline() # Just in case, for manual testing.
exit_code: int | None = None
while exit_code is None:
line = input()
try:
command = _parse_command(line)
if command is None:
raise ValueError("no command provided")
exit_code = self._execute(command)
except ValidationError as e:
# Workaround to serialize `ArgsKwargs`.
# Straightforward `e.errors()` causes `json.dumps` to crash
# when the `ValidationError` is caused by missing arguments.
args = json.loads(e.json())
error = {"type": e.__class__.__name__, "args": args}
print(json.dumps({"error": error}))
except (ValueError, RulesViolation) as e:
error = {"type": e.__class__.__name__, "args": e.args}
print(json.dumps({"error": error}))
return exit_code

def _execute(self, command: _Command) -> int | None:
"""Execute the given `command`.
Return an exit code if the command is `_Quit`, or `None` otherwise.
Raise:
* `ValueError`
if some argument has invalid value.
* `smawg.RulesViolation` subtypes
if given command violates the game rules.
"""
result: Any
match command:
case _Help():
result = _HELP
case _Quit():
return 0
case _ShowCombos():
result = self._command_show_combos()
case _ShowPlayers():
result = self._command_show_players()
case _ShowRegions(player_id):
result = self._command_show_regions(player_id)
case _MaybeDry(False, action):
result = self.game.do(action)
case _MaybeDry(True, action):
for e in self.game.rules.check(action):
raise e # Raise the first error, if any.
result = None
case not_covered:
# mypy 1.5.1 can't deduce `not_covered: Never` here.
# When this is fixed in the pinned mypy, remove 'type:ignore'.
assert_never(not_covered) # type:ignore
print(json.dumps({"result": result}))
return None

def _command_show_players(self) -> Any:
return [
TypeAdapter(Player).dump_python(p, mode="json")
for p in self.game.players
]

def _command_show_combos(self) -> Any:
return [
TypeAdapter(Combo).dump_python(c, mode="json")
for c in self.game.combos
]

def _command_show_regions(self, player_id: int) -> Any:
if not 0 <= player_id < len(self.game.players):
msg = f"<player> must be between 0 and {len(self.game.players)}"
raise ValueError(msg)
player = self.game.players[player_id]
return TypeAdapter(Player).dump_python(
player, mode="json", include={"active_regions", "decline_regions"}
)


# -----------------------------------------------------------------------------
# Argument parsing and the entry point
# -----------------------------------------------------------------------------
Expand All @@ -346,6 +456,12 @@ def argument_parser() -> ArgumentParser:
metavar="ASSETS_FILE",
help="path to JSON file with assets"
)
parser.add_argument(
"--style",
choices=["human", "machine"],
default="human",
help="set the output style (see docs/style.md for details)",
)
parser.add_argument(
"--rules",
metavar="RULES_PLUGIN",
Expand Down Expand Up @@ -400,11 +516,21 @@ def root_command(args: Namespace) -> None:
args.assets_file, args.relative_path, args.no_shuffle
)
rules = _import_rules(args.rules)
client_type: Type[_Client]
match args.style:
case "human":
roll_dice = _read_dice_with_reenter
client_type = _HumanClient
case "machine":
roll_dice = lambda: int(input()) # noqa
client_type = _MachineClient
case _:
assert False, "invalid styles should be caught by argparse"
if args.read_dice:
game = Game(assets, rules, dice_roll_func=_read_dice)
game = Game(assets, rules, dice_roll_func=roll_dice)
else:
game = Game(assets, rules)
client = _Client(game)
client = client_type(game)
exit_code = client.run()
sys.exit(exit_code)

Expand Down
12 changes: 6 additions & 6 deletions smawg/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Abandon, Conquer, ConquerWithDice, Decline, Deploy, EndTurn, SelectCombo,
StartRedeployment
)
from smawg.cli import _Client, _MaybeDry, _parse_command
from smawg.cli import _HumanClient, _MaybeDry, _parse_command
from smawg.default_rules import Action
from smawg.tests.common import TINY_ASSETS

Expand Down Expand Up @@ -92,22 +92,22 @@ def test_unsupported_dry_run(self) -> None:
self.assertRaises(ValueError, _parse_command, "?show-regions 0")


class TestCliClient(unittest.TestCase):
"""Tests for `smawg.cli._Client`.
class TestCliHumanClient(unittest.TestCase):
"""Tests for `smawg.cli._HumanClient`.
That class is private, but having tests helps with fearless refactoring.
"""

@patch("sys.stdout", new=StringIO())
def test_exit_codes(self) -> None:
"""`_Client.run()` should return 0 on 'quit' and 1 on EOF."""
"""`_HumanClient.run()` should return 0 on 'quit' and 1 on EOF."""
with patch("sys.stdin", new=StringIO("quit\n")):
game = Game(TINY_ASSETS)
client = _Client(game)
client = _HumanClient(game)
self.assertEqual(client.run(), 0)
with patch("sys.stdin", new=StringIO("")):
game = Game(TINY_ASSETS)
client = _Client(game)
client = _HumanClient(game)
self.assertEqual(client.run(), 1)


Expand Down

0 comments on commit f537a0e

Please sign in to comment.