Skip to content

Commit d527d67

Browse files
committed
Add order management and world tick functionality to DiscordInterface
- Implement order method to hold actions until the next tick - Introduce tick method to resolve orders and update the world state - Update main.py to initialize DiscordInterface with tick_seconds parameter - Enhance tests to validate order behavior and tick resolution
1 parent 9daac6f commit d527d67

3 files changed

Lines changed: 136 additions & 15 deletions

File tree

Discordia/Interface/DiscordInterface.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import logging
5+
import random
46
import time
5-
from typing import Callable, List, Sequence, Tuple, cast
7+
from typing import Any, Callable, Dict, List, Sequence, Tuple, cast
68

79
import discord
810
from discord import app_commands
@@ -42,6 +44,10 @@
4244

4345
NO_STORE = "There's no store here. Find a town that has one."
4446

47+
SUPERSEDED = (
48+
object()
49+
) # what an order resolves to when the player replaces it before the tick
50+
4551

4652
def _character(interaction: discord.Interaction) -> Actors.PlayerCharacter:
4753
"""The character behind an interaction. Checks only get the interaction, so dig the cog out of the command."""
@@ -106,6 +112,7 @@ def __init__(
106112
self,
107113
world_adapter: WorldAdapter,
108114
jobs: Sequence[Tuple[float, Callable[[], None], str]] = (),
115+
tick_seconds: float = 5.0,
109116
):
110117
"""`jobs` are (seconds, action, name) triples run periodically alongside the commands."""
111118
self.bot: commands.Bot = commands.Bot(
@@ -115,9 +122,13 @@ def __init__(
115122
self.bot.setup_hook = self._setup_hook
116123
self.world_adapter: WorldAdapter = world_adapter
117124
self.jobs = jobs
125+
self.tick_seconds = tick_seconds
118126
self._job_loops: List[tasks.Loop] = (
119127
[]
120128
) # kept alive; a Loop nobody holds gets collected
129+
self._orders: Dict[
130+
Actors.PlayerCharacter, Tuple[Callable[[], Any], asyncio.Future]
131+
] = {}
121132

122133
def _start_job(self, seconds: float, action: Callable[[], None], name: str):
123134
"""Run `action` every `seconds` on the bot's event loop: same thread as the commands, so no locking.
@@ -135,8 +146,36 @@ async def job():
135146
job.start()
136147
self._job_loops.append(job)
137148

149+
def order(
150+
self, character: Actors.PlayerCharacter, action: Callable[[], Any]
151+
) -> asyncio.Future:
152+
"""Hold a world-changing action until the next tick, and hand back its eventual result.
153+
154+
One order per character: typing a second one before the tick replaces the first, whose command
155+
gets SUPERSEDED back. Spamming a command therefore buys nothing but a change of mind.
156+
"""
157+
_, previous = self._orders.pop(character, (None, None))
158+
if previous is not None and not previous.done():
159+
previous.set_result(SUPERSEDED)
160+
future = asyncio.get_running_loop().create_future()
161+
self._orders[character] = (action, future)
162+
return future
163+
164+
def tick(self):
165+
"""Resolve everyone's orders, then let the world act. Same-tick orders resolve in random order."""
166+
orders, self._orders = self._orders, {}
167+
for action, future in random.sample(list(orders.values()), len(orders)):
168+
if future.done(): # the command that asked for it went away
169+
continue
170+
try:
171+
future.set_result(action())
172+
except Exception as exc:
173+
future.set_exception(exc)
174+
self.world_adapter.world.tick()
175+
138176
async def _setup_hook(self):
139177
await self.bot.add_cog(self)
178+
self._start_job(self.tick_seconds, self.tick, "World tick")
140179
for seconds, action, name in self.jobs:
141180
self._start_job(seconds, action, name)
142181
synced = await self.bot.tree.sync()
@@ -237,12 +276,19 @@ async def look(self, interaction: discord.Interaction):
237276
async def move(
238277
self, interaction: discord.Interaction, direction: app_commands.Choice[str]
239278
):
240-
"""Move your character one space in the given direction"""
279+
"""Move your character one space in the given direction, resolved on the next tick"""
241280
await interaction.response.defer()
242281
character = self._player(interaction)
243-
results: List[PlayerActionResponse] = self.world_adapter.move_player(
244-
character, DIRECTION_VECTORS[direction.value]
282+
results = await self.order(
283+
character,
284+
lambda: self.world_adapter.move_player(
285+
character, DIRECTION_VECTORS[direction.value]
286+
),
245287
)
288+
if results is SUPERSEDED:
289+
await interaction.followup.send("You change your mind before setting off.")
290+
return
291+
results = cast(List[PlayerActionResponse], results)
246292
msg = ""
247293
# If any Events happen, let the PC know step-by-step
248294
for r in results:
@@ -265,11 +311,19 @@ async def move(
265311
async def attack(
266312
self, interaction: discord.Interaction, direction: app_commands.Choice[str]
267313
):
268-
"""Attack; give a direction for a ranged attack"""
314+
"""Attack, resolved on the next tick; give a direction for a ranged attack"""
315+
await interaction.response.defer()
269316
character = self._player(interaction)
270-
response: PlayerActionResponse = self.world_adapter.attack(
271-
character, DIRECTION_VECTORS[direction.value]
317+
response = await self.order(
318+
character,
319+
lambda: self.world_adapter.attack(
320+
character, DIRECTION_VECTORS[direction.value]
321+
),
272322
)
323+
if response is SUPERSEDED:
324+
await interaction.followup.send("You hold your fire.")
325+
return
326+
response = cast(PlayerActionResponse, response)
273327
target_name = response.target.name if response.target else "nobody"
274328
await _send(
275329
interaction,

Discordia/test/test_discord_interface.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import pytest
66
from discord import app_commands
77

8-
from Discordia.GameLogic import GameSpace
9-
from Discordia.Interface.DiscordInterface import DiscordInterface
8+
from Discordia.GameLogic import Actors, GameSpace
9+
from Discordia.Interface.DiscordInterface import SUPERSEDED, DiscordInterface
1010
from Discordia.Interface.WorldAdapter import (
1111
InvalidSpaceException,
1212
NotRegisteredException,
@@ -127,6 +127,72 @@ def test_space_check_passes_inside_a_town():
127127
assert run_checks(command, interaction)
128128

129129

130+
PLAYER = cast(Actors.PlayerCharacter, "a player") # orders only ever key on identity
131+
132+
133+
def ticking_interface(on_world_tick=lambda: None) -> DiscordInterface:
134+
"""A cog whose world does nothing but record that it ticked."""
135+
adapter = SimpleNamespace(world=SimpleNamespace(tick=on_world_tick))
136+
return DiscordInterface(world_adapter=cast(WorldAdapter, adapter))
137+
138+
139+
def test_orders_wait_for_the_tick_instead_of_resolving_when_typed():
140+
done = []
141+
interface = ticking_interface()
142+
143+
async def scenario():
144+
future = interface.order(PLAYER, lambda: done.append("moved") or "arrived")
145+
assert not done, "the order ran before the tick"
146+
interface.tick()
147+
assert await future == "arrived"
148+
149+
asyncio.run(scenario())
150+
assert done == ["moved"]
151+
152+
153+
def test_a_second_order_replaces_the_first_so_spamming_buys_nothing():
154+
ran = []
155+
interface = ticking_interface()
156+
157+
async def scenario():
158+
first = interface.order(PLAYER, lambda: ran.append("north"))
159+
second = interface.order(PLAYER, lambda: ran.append("south"))
160+
interface.tick()
161+
assert await first is SUPERSEDED
162+
assert await second is None
163+
164+
asyncio.run(scenario())
165+
assert ran == ["south"]
166+
167+
168+
def test_orders_resolve_before_the_world_acts():
169+
sequence = []
170+
interface = ticking_interface(on_world_tick=lambda: sequence.append("world"))
171+
172+
async def scenario():
173+
future = interface.order(PLAYER, lambda: sequence.append("player"))
174+
interface.tick()
175+
await future
176+
177+
asyncio.run(scenario())
178+
assert sequence == ["player", "world"]
179+
180+
181+
def test_a_rejected_order_raises_in_the_command_that_asked_for_it():
182+
interface = ticking_interface()
183+
184+
async def scenario():
185+
def blocked():
186+
raise InvalidSpaceException("You can't go that way.")
187+
188+
future = interface.order(PLAYER, blocked)
189+
interface.tick()
190+
with pytest.raises(InvalidSpaceException):
191+
await future
192+
193+
asyncio.run(scenario())
194+
195+
130196
def test_jobs_run_on_the_bots_event_loop():
131197
ticks = []
132198
interface = DiscordInterface(

main.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ def main():
3939
display = WindowRenderer(adapter)
4040

4141
threading.Thread(target=update_display, args=(display, args.show_window), daemon=True).start()
42-
# Both jobs run on the bot's event loop, in lockstep with the commands: no locking needed, and a
43-
# crash loses at most AUTOSAVE_SECONDS of play.
44-
discord_interface = DiscordInterface(adapter, jobs=[
45-
(TICK_SECONDS, adapter.world.tick, "World tick"),
46-
(AUTOSAVE_SECONDS, lambda: database.save(adapter), "Autosave"),
47-
])
42+
# The tick and the autosave run on the bot's event loop, in lockstep with the commands: no locking
43+
# needed, and a crash loses at most AUTOSAVE_SECONDS of play.
44+
discord_interface = DiscordInterface(
45+
adapter,
46+
jobs=[(AUTOSAVE_SECONDS, lambda: database.save(adapter), "Autosave")],
47+
tick_seconds=TICK_SECONDS,
48+
)
4849
# discord_interface.bot.loop.create_task(update_display(display))
4950
# threading.Thread(target=discord_interface.bot.run, args=(ConfigParser.DISCORD_TOKEN,), daemon=True).start()
5051
LOG.info("Discordia Server has successfully started. Press Ctrl+C to quit.")

0 commit comments

Comments
 (0)