Skip to content

Commit 112d6e0

Browse files
authored
Merge pull request #128 from neph1/copilot/investigate-resetting-story
Add !reset_story wizard command to restart story without server restart
2 parents 834652f + 70c269b commit 112d6e0

File tree

4 files changed

+333
-1
lines changed

4 files changed

+333
-1
lines changed

docs/reset_story_implementation.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Reset Story Implementation
2+
3+
## Overview
4+
This document describes the implementation of the `!reset_story` wizard command that allows restarting/resetting a story without having to restart the server.
5+
6+
## Feature Description
7+
The `!reset_story` command provides a way to reset the game world back to its initial state while keeping the server running and players connected. This is particularly useful for:
8+
- Testing story changes during development
9+
- Recovering from a broken game state
10+
- Restarting a story for a fresh playthrough without disconnecting players
11+
12+
## Usage
13+
As a wizard (user with wizard privileges), type:
14+
```
15+
!reset_story
16+
```
17+
18+
The command will:
19+
1. Prompt for confirmation (as this affects all players)
20+
2. If confirmed, reset the story world
21+
3. Move all players back to their starting locations
22+
4. Display a completion message
23+
24+
## Implementation Details
25+
26+
### Files Modified
27+
- **tale/cmds/wizard.py**: Added the `do_reset_story` wizard command function
28+
- **tale/driver.py**: Added the `reset_story()` method to the Driver class
29+
- **tests/test_reset_story.py**: Added unit tests for the reset functionality
30+
31+
### What Gets Reset
32+
1. **Deferreds**: All scheduled actions are cleared
33+
2. **MudObject Registry**:
34+
- All items are removed
35+
- All NPCs and non-player livings are removed
36+
- All locations are cleared (except players remain in registry)
37+
- All exits are cleared
38+
3. **Story Module**: The story module is reloaded from disk
39+
4. **Zones**: All zone modules are unloaded and reloaded
40+
5. **Game Clock**: Reset to the story's epoch or current time
41+
6. **Player Positions**: All players are moved to their designated starting locations
42+
43+
### What Is Preserved
44+
1. **Player Objects**: Player objects remain in the registry with the same vnum
45+
2. **Player Inventory**: Players keep their items
46+
3. **Player Stats**: Player statistics and attributes are preserved
47+
4. **Player Connections**: Active player connections remain intact
48+
5. **Server Uptime**: The server uptime counter continues
49+
50+
### Technical Approach
51+
The implementation handles several challenging aspects:
52+
53+
1. **Module Reloading**: Python modules are removed from `sys.modules` and reimported to get fresh instances
54+
2. **Registry Management**: The MudObjRegistry is selectively cleared to preserve players while removing other objects
55+
3. **Safe Exception Handling**: Specific exceptions are caught when removing players from old locations
56+
4. **Sequence Number Management**: The registry sequence number is adjusted to account for existing player vnums
57+
58+
### Error Handling
59+
- If the story module cannot be reloaded, an error message is displayed
60+
- If starting locations cannot be found, players are notified
61+
- If a player's old location is in an invalid state, the error is caught and ignored
62+
- All exceptions during reset are caught and reported to the wizard who initiated the command
63+
64+
## Testing
65+
The implementation includes comprehensive unit tests:
66+
- Test that the command exists and is registered
67+
- Test that the command is a generator (for confirmation dialog)
68+
- Test that confirmation is required
69+
- Test that the Driver.reset_story method exists
70+
- Test that the command calls the driver's reset method when confirmed
71+
72+
## Future Enhancements
73+
Possible improvements for future versions:
74+
- Option to reset only specific zones
75+
- Option to preserve or clear player inventories
76+
- Backup/restore of game state before reset
77+
- Configuration to exclude certain objects from reset
78+
- Reset statistics tracking (number of resets, last reset time)
79+
80+
## Known Limitations
81+
1. Custom story data not managed by the standard story/zone system may not be properly reset
82+
2. External systems (databases, file caches) are not automatically reset
83+
3. LLM cache and character memories are not cleared (may need manual cleanup)
84+
4. Player wiretaps are not automatically re-established after reset
85+
86+
## Command Documentation
87+
The command includes built-in help accessible via:
88+
```
89+
help !reset_story
90+
```
91+
92+
The help text explains:
93+
- What the command does
94+
- That it requires wizard privileges
95+
- That it affects all players
96+
- What is preserved and what is reset

tale/cmds/wizard.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import platform
1515
import sys
16+
import traceback
1617
from types import ModuleType
1718
from typing import Generator, Optional
1819

@@ -954,4 +955,25 @@ def do_set_rp_prompt(player: Player, parsed: base.ParseResult, ctx: util.Context
954955
target.set_roleplay_prompt(prompt, effect_description, time)
955956
player.tell("RP prompt set to: %s with effect: %s" % (prompt, effect_description))
956957
except ValueError as x:
957-
raise ActionRefused(str(x))
958+
raise ActionRefused(str(x))
959+
960+
961+
@wizcmd("reset_story")
962+
def do_reset_story(player: Player, parsed: base.ParseResult, ctx: util.Context) -> Generator:
963+
"""Reset/restart the story without restarting the server.
964+
This will reload all zones, reset the game clock, clear all deferreds,
965+
and move all players to their starting locations. Player inventory and stats are preserved.
966+
Usage: !reset_story
967+
"""
968+
if not (yield "input", ("Are you sure you want to reset the story? This will affect all players!", lang.yesno)):
969+
player.tell("Story reset cancelled.")
970+
return
971+
972+
player.tell("Resetting the story...")
973+
try:
974+
ctx.driver.reset_story()
975+
player.tell("Story has been reset successfully!")
976+
player.tell("All players have been moved to their starting locations.")
977+
except Exception as x:
978+
player.tell("Error resetting story: %s" % str(x))
979+
traceback.print_exc()

tale/driver.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,132 @@ def uptime(self) -> Tuple[int, int, int]:
898898
minutes, seconds = divmod(seconds, 60)
899899
return int(hours), int(minutes), int(seconds)
900900

901+
def reset_story(self) -> None:
902+
"""
903+
Reset/restart the story without restarting the server.
904+
This reloads zones, resets the game clock, clears deferreds,
905+
and moves players back to starting locations.
906+
Player inventory and stats are preserved.
907+
"""
908+
# Notify all players
909+
for conn in self.all_players.values():
910+
if conn.player:
911+
conn.player.tell("\n<bright>*** The story is being reset! ***</>")
912+
conn.player.tell("Please wait...\n")
913+
conn.write_output()
914+
915+
# Save player references (they should not be cleared)
916+
players = [conn.player for conn in self.all_players.values() if conn.player]
917+
918+
# Clear all deferreds
919+
with self.deferreds_lock:
920+
self.deferreds.clear()
921+
922+
# Clear the MudObject registry to remove all old objects (except players)
923+
# Items first
924+
base.MudObjRegistry.all_items.clear()
925+
926+
# Remove non-player livings from registry
927+
player_vnums = {p.vnum for p in players}
928+
livings_to_remove = [vnum for vnum in base.MudObjRegistry.all_livings.keys() if vnum not in player_vnums]
929+
for vnum in livings_to_remove:
930+
del base.MudObjRegistry.all_livings[vnum]
931+
932+
# Clear locations and exits
933+
base.MudObjRegistry.all_locations.clear()
934+
base.MudObjRegistry.all_exits.clear()
935+
base.MudObjRegistry.all_remains.clear()
936+
937+
# Reset sequence number but account for existing players
938+
# We need to ensure new objects get vnums higher than any existing objects
939+
if player_vnums:
940+
base.MudObjRegistry.seq_nr = max(player_vnums) + 1
941+
else:
942+
# No players, safe to reset to 1
943+
base.MudObjRegistry.seq_nr = 1
944+
945+
# Clear unbound exits
946+
self.unbound_exits.clear()
947+
948+
# Reload the story module using importlib
949+
try:
950+
import story as story_module
951+
importlib.reload(story_module)
952+
self.story = story_module.Story()
953+
self.story._verify(self)
954+
except (ImportError, AttributeError) as e:
955+
raise errors.TaleError("Failed to reload story module: %s" % str(e))
956+
957+
# Update configurations
958+
self.story.config.server_mode = self.game_mode
959+
mud_context.config = self.story.config
960+
961+
# Re-initialize the story
962+
self.story.init(self)
963+
self.llm_util.set_story(self.story)
964+
965+
# Reset game clock to the story's epoch or current time
966+
self.game_clock = util.GameDateTime(
967+
self.story.config.epoch or datetime.datetime.now().replace(microsecond=0),
968+
self.story.config.gametime_to_realtime
969+
)
970+
971+
# Reload zones
972+
# First, unload zone modules from sys.modules
973+
zone_modules_to_reload = [key for key in sys.modules.keys() if key.startswith('zones.')]
974+
for module_name in zone_modules_to_reload:
975+
del sys.modules[module_name]
976+
if 'zones' in sys.modules:
977+
del sys.modules['zones']
978+
979+
# Now reload zones
980+
self.zones = self._load_zones(self.story.config.zones)
981+
982+
# Bind exits
983+
for x in self.unbound_exits:
984+
x._bind_target(self.zones)
985+
self.unbound_exits.clear()
986+
987+
# Register periodicals again
988+
self.register_periodicals(self)
989+
990+
# Move all players to their starting locations
991+
try:
992+
start_location = self.lookup_location(self.story.config.startlocation_player)
993+
wizard_start = self.lookup_location(self.story.config.startlocation_wizard)
994+
except errors.TaleError:
995+
# If locations not found, try to find them again
996+
start_location = None
997+
wizard_start = None
998+
999+
for conn in self.all_players.values():
1000+
if conn.player:
1001+
p = conn.player
1002+
# Remove player from old location if it still exists
1003+
if p.location:
1004+
try:
1005+
p.location.remove(p, silent=True)
1006+
except (AttributeError, KeyError, ValueError):
1007+
# Location might be in an invalid state after reset
1008+
pass
1009+
1010+
# Determine starting location based on privileges
1011+
if "wizard" in p.privileges and wizard_start:
1012+
target_location = wizard_start
1013+
else:
1014+
target_location = start_location if start_location else wizard_start
1015+
1016+
if target_location:
1017+
# Move player to starting location
1018+
p.move(target_location, silent=True)
1019+
p.tell("\n<bright>Story reset complete!</>")
1020+
p.tell("You find yourself back at the beginning.\n")
1021+
p.look()
1022+
else:
1023+
p.tell("\n<bright>Error: Could not find starting location after reset.</>")
1024+
1025+
conn.write_output()
1026+
9011027
def prepare_combat_prompt(self,
9021028
attackers: List[base.Living],
9031029
defenders: List[base.Living],

tests/test_reset_story.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Tests for the reset_story wizard command.
3+
4+
'Tale' mud driver, mudlib and interactive fiction framework
5+
Copyright by Irmen de Jong ([email protected])
6+
"""
7+
8+
import unittest
9+
from unittest.mock import MagicMock, patch
10+
import tale
11+
from tale.base import Location, ParseResult
12+
from tale.cmds import wizard
13+
from tale.player import Player
14+
from tale.story import StoryConfig
15+
from tests.supportstuff import FakeDriver
16+
17+
18+
class TestResetStory(unittest.TestCase):
19+
def setUp(self):
20+
"""Set up test fixtures."""
21+
self.context = tale._MudContext()
22+
self.context.config = StoryConfig()
23+
self.context.driver = FakeDriver()
24+
25+
self.player = Player('test_wizard', 'f')
26+
self.player.privileges.add('wizard')
27+
28+
self.location = Location('test_location')
29+
self.location.init_inventory([self.player])
30+
31+
def test_reset_story_command_exists(self):
32+
"""Test that the reset_story command is registered."""
33+
# The command should be available
34+
self.assertTrue(hasattr(wizard, 'do_reset_story'))
35+
36+
def test_reset_story_is_generator(self):
37+
"""Test that reset_story is a generator (for the confirmation dialog)."""
38+
# The @wizcmd decorator wraps the function, so we check the 'is_generator' attribute
39+
# that was set by the decorator
40+
self.assertTrue(hasattr(wizard.do_reset_story, 'is_generator'))
41+
self.assertTrue(wizard.do_reset_story.is_generator)
42+
43+
def test_reset_story_requires_confirmation(self):
44+
"""Test that reset_story requires confirmation before executing."""
45+
parse_result = ParseResult(verb='!reset_story')
46+
47+
# Create a generator from the command
48+
gen = wizard.do_reset_story(self.player, parse_result, self.context)
49+
50+
# The first yield should be for input confirmation
51+
try:
52+
why, what = next(gen)
53+
self.assertEqual(why, 'input')
54+
# The confirmation message should mention affecting all players
55+
self.assertIn('all players', what[0].lower())
56+
except StopIteration:
57+
self.fail("Generator should yield for confirmation")
58+
59+
def test_reset_story_driver_method_exists(self):
60+
"""Test that the Driver.reset_story method exists."""
61+
from tale.driver import Driver
62+
self.assertTrue(hasattr(Driver, 'reset_story'))
63+
self.assertTrue(callable(getattr(Driver, 'reset_story')))
64+
65+
@patch('tale.driver.Driver.reset_story')
66+
def test_reset_story_calls_driver_reset(self, mock_reset):
67+
"""Test that the command calls the driver's reset_story method."""
68+
parse_result = ParseResult(verb='!reset_story')
69+
70+
# Mock the driver's reset_story method
71+
self.context.driver.reset_story = mock_reset
72+
73+
# Create and run the generator
74+
gen = wizard.do_reset_story(self.player, parse_result, self.context)
75+
76+
# Send confirmation "yes"
77+
try:
78+
next(gen) # First yield for input
79+
gen.send("yes") # Confirm the reset
80+
except StopIteration:
81+
pass
82+
83+
# The driver's reset_story should have been called
84+
mock_reset.assert_called_once()
85+
86+
87+
if __name__ == '__main__':
88+
unittest.main()

0 commit comments

Comments
 (0)