Player Adapter¶
The GameSession client API closely mimics how an individual player
would operate and which information they would need during a session.
Thus, for client programs that already have a class Player abstraction,
the client API can be integrated via the Adapter Pattern by implementing
a new kind of Player as an adapter to the GameSession.
Note
It is important to distinguish between “the player” as a human being
using the client program and “the Player” as an abstraction as
part of the client program.
Keep in mind that “the Player” needs access to more information
than “the player”;
this becomes apparent when wrapping a GameSession,
since it means explicitly giving “the Player” information
about its opponent.
Since the adapter Player needs access to information that is usually
communicated implicitly – for example, the ship positions shown via the UI
or even the length of a session – it is very likely necessary to extend the
Player abstraction to match.
The following is an outline of how such an adapter can be designed.
For simplicity, this outline only assumes
one “local” player directly using the client program, and
one “remote” player connected via the GameSession.
You likely still want to support local vs local play,
and might want to support remote vs remote play as well.
Creating Instances¶
A GameSession needs to be scoped to one round of the game
and can only disclose the opponent player name after connecting to a game.
This prevents just constructing a RemotePlayer that wraps the session.
We recommend to extend the Player abstraction with:
an alternative constructor via
classmethod()which isscoped to one round of the game via
contextmanager().
class RemotePlayer:
# this constructor usually is not called manually:
# 'identifier' is provided by the player on the remote session!
def __init__(self, identifier: str, session: GameSession):
self.identifier = identifier
self._session = session
@classmethod
@contextmanager
def create(cls, opponent: "str | None"):
"""
Create a scoped instance of this class and inform it of its ``opponent``
"""
# 'opponent' is the local player, 'session.opponent' the remote player
with GameSession.connect(opponent) as session:
yield cls(session.opponent, session)
The constructor can then be used in a with statement:
with RemotePlayer.create(...) as player:
...
Note that you can create two players of type P1 and P2
in one with statement using
with P1.create(...) as player1, P2.create(...) as player2:.
Warning
The opponent identifier is sent to whichever player is matched by the server
without any authentication.
Avoid exposing any personal information such as the account- or hostname
(e.g. by creating an f"{opponent}@{hostname}" identifier).
When in doubt, do not provide any identifier - the client API will then
create a random one that exposes no information.
Wrapping Methods¶
The methods of GameSession are unlikely to
directly match Player methods.
At the very least, you must delegate method calls:
class RemotePlayer:
...
def get_shot(self):
return self._session.expect_shot()
In addition, expect to adapt method calls:
class RemotePlayer:
...
def notify_shot(self, x, y):
# adapt the different parameter convention
return self._session.announce_shot((y, x))
When expected and provided methods are very different, be prepared to implement a facade between both conventions:
class RemotePlayer:
def __init__(self, ...):
...
# translate between individual and all-at-once placements
# by storing them internally
self._enemy_ship_buffer: "list[SHIP_PLACEMENT] | None" = []
self._my_ship_buffer: "list[SHIP_PLACEMENT] | None" = None
def notify_ship(self, size: int, pos: "tuple[int, int]", vertical: bool):
"""Inform about enemy placing a ship of specific `size` at `pos`"""
# keep collecting all ship placements without sending any
self._enemy_ship_buffer.append((size, pos, vertical))
def get_ship(self, size: int) -> "SHIP_PLACEMENT":
"""Get the next placement for a ship of specific `size`"""
# send ship placement only when we need the response
if self._my_ship_buffer is None:
self._my_ship_buffer = list(
session.place_ships(*self._enemy_ship_cache)
)
self._enemy_ship_buffer = None
# pick matching ship from collection provided from remote
for idx, (candidate_size, _, _) in enumerate(self._my_ship_buffer):
if size == candidate_size:
return self._my_ship_buffer.pop(idx)
raise ValueError(f"remote player placed no more ships of size {size}")