11from __future__ import annotations
22
3+ import asyncio
34import logging
5+ import random
46import time
5- from typing import Callable , List , Sequence , Tuple , cast
7+ from typing import Any , Callable , Dict , List , Sequence , Tuple , cast
68
79import discord
810from discord import app_commands
4244
4345NO_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
4652def _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 ,
0 commit comments