Skip to content

Commit 8bf8668

Browse files
committed
Implement world tick functionality for NPCs; add NPC spawning and movement logic, and update autosave and tick threading
1 parent ca13dc6 commit 8bf8668

3 files changed

Lines changed: 83 additions & 7 deletions

File tree

Discordia/GameLogic/GameSpace.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ def add_wilds(self, wilds: Wilds):
703703
self.map[wilds.y][wilds.x] = wilds
704704

705705
def add_actor(self, actor: Actors.Actor, space: Space | None = None):
706+
actor.parent_world = self
706707
if isinstance(actor, Actors.PlayerCharacter):
707708
actor.location = self.starting_town
708709
self.players.append(actor)
@@ -780,6 +781,20 @@ def pvp_attack(
780781
dmg = weapon.calc_damage(int(player_character.location.distance(loc)))
781782
return response
782783

784+
def tick(self):
785+
"""One step of world time: NPCs spawn, wander and fight without any player input."""
786+
# ponytail: no lock. Discord commands mutate the world from the bot's thread too, so a
787+
# tick can interleave with a command. Wrap both in one world lock if that ever misfires.
788+
self.npcs = [npc for npc in self.npcs if not npc.is_dead]
789+
if self.wilds and len(self.npcs) < len(self.wilds):
790+
self.add_actor(Actors.Raider.generate(1), random.choice(self.wilds))
791+
for npc in self.npcs:
792+
targets = [p for p in self.players if p.location == npc.location]
793+
if targets:
794+
npc.brain.update(random.choice(targets))
795+
else:
796+
npc.attempt_move(random.choice(list(DIRECTION_VECTORS.values())))
797+
783798
def handle_player_death(self, player: Actors.PlayerCharacter):
784799
LOG.info(f"Player {player.name} has died")
785800
player.location = self.starting_town

Discordia/test/test_game_logic.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,54 @@ def test_a_player_sees_their_own_neighbourhood(adapter):
552552

553553
def test_screenshots_degrade_gracefully_without_a_renderer(adapter):
554554
assert adapter.get_player_screenshot(adapter.get_player(1)) == "<No Renderer>"
555+
556+
557+
# --- World tick: NPCs act on real time, not on player input ------------------------------------
558+
559+
560+
def test_ticking_spawns_npcs_and_moves_them_without_the_player(adapter):
561+
world = adapter.world
562+
assert not world.npcs
563+
564+
world.tick()
565+
assert len(world.npcs) == 1
566+
npc = world.npcs[0]
567+
assert npc.parent_world is world
568+
assert world.is_space_valid(npc.location)
569+
570+
# Somewhere with room to walk, so "it never moved" means the tick is broken, not that it's boxed in
571+
npc.location = next(
572+
space
573+
for space in adapter.iter_spaces()
574+
if all(
575+
world.is_space_valid(neighbor)
576+
for neighbor in world.get_adjacent_spaces(space)
577+
)
578+
)
579+
start = npc.location
580+
seen = {start}
581+
for _ in range(50):
582+
world.tick()
583+
seen.add(npc.location)
584+
assert world.is_space_valid(npc.location)
585+
assert len(seen) > 1
586+
587+
588+
def test_a_tick_lets_an_npc_hit_a_player_sharing_its_space(adapter):
589+
world = adapter.world
590+
player = adapter.get_player(1)
591+
npc = Actors.Raider(world, 50, "Mugger")
592+
world.add_actor(npc, player.location)
593+
594+
world.tick()
595+
assert player.hit_points < player.hit_points_max
596+
597+
598+
def test_dead_npcs_are_dropped_on_the_next_tick(adapter):
599+
world = adapter.world
600+
npc = Actors.Raider(world, 1, "Doomed")
601+
world.add_actor(npc, world.starting_town)
602+
npc.take_damage(10)
603+
604+
world.tick()
605+
assert npc not in world.npcs

main.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@
1414
logging.basicConfig(level=logging.INFO)
1515

1616
AUTOSAVE_SECONDS = 60
17+
TICK_SECONDS = 5
1718

1819

19-
def autosave(database: Database, adapter: WorldAdapter):
20-
# ponytail: a plain timer loop, so a crash loses at most AUTOSAVE_SECONDS of play. Save on each mutating
21-
# command instead if that ever costs someone a real purchase.
20+
def every(seconds: float, action, name: str):
21+
# ponytail: a plain timer loop, so a crash loses at most AUTOSAVE_SECONDS of play, and ticks
22+
# drift by however long the action took. Both fine at these intervals.
2223
while True:
23-
time.sleep(AUTOSAVE_SECONDS)
24+
time.sleep(seconds)
2425
try:
25-
database.save(adapter)
26+
action()
2627
except Exception:
27-
LOG.exception("Autosave failed")
28+
LOG.exception("%s failed", name)
2829

2930

3031
def main():
@@ -50,7 +51,16 @@ def main():
5051
display = WindowRenderer(adapter)
5152

5253
threading.Thread(target=update_display, args=(display, args.show_window), daemon=True).start()
53-
threading.Thread(target=autosave, args=(database, adapter), daemon=True).start()
54+
threading.Thread(
55+
target=every,
56+
args=(AUTOSAVE_SECONDS, lambda: database.save(adapter), "Autosave"),
57+
daemon=True,
58+
).start()
59+
threading.Thread(
60+
target=every,
61+
args=(TICK_SECONDS, adapter.world.tick, "World tick"),
62+
daemon=True,
63+
).start()
5464
discord_interface = DiscordInterface(adapter)
5565
# discord_interface.bot.loop.create_task(update_display(display))
5666
# threading.Thread(target=discord_interface.bot.run, args=(ConfigParser.DISCORD_TOKEN,), daemon=True).start()

0 commit comments

Comments
 (0)