Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/reset_story_implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Reset Story Implementation

## Overview
This document describes the implementation of the `!reset_story` wizard command that allows restarting/resetting a story without having to restart the server.

## Feature Description
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:
- Testing story changes during development
- Recovering from a broken game state
- Restarting a story for a fresh playthrough without disconnecting players

## Usage
As a wizard (user with wizard privileges), type:
```
!reset_story
```

The command will:
1. Prompt for confirmation (as this affects all players)
2. If confirmed, reset the story world
3. Move all players back to their starting locations
4. Display a completion message

## Implementation Details

### Files Modified
- **tale/cmds/wizard.py**: Added the `do_reset_story` wizard command function
- **tale/driver.py**: Added the `reset_story()` method to the Driver class
- **tests/test_reset_story.py**: Added unit tests for the reset functionality

### What Gets Reset
1. **Deferreds**: All scheduled actions are cleared
2. **MudObject Registry**:
- All items are removed
- All NPCs and non-player livings are removed
- All locations are cleared (except players remain in registry)
- All exits are cleared
3. **Story Module**: The story module is reloaded from disk
4. **Zones**: All zone modules are unloaded and reloaded
5. **Game Clock**: Reset to the story's epoch or current time
6. **Player Positions**: All players are moved to their designated starting locations

### What Is Preserved
1. **Player Objects**: Player objects remain in the registry with the same vnum
2. **Player Inventory**: Players keep their items
3. **Player Stats**: Player statistics and attributes are preserved
4. **Player Connections**: Active player connections remain intact
5. **Server Uptime**: The server uptime counter continues

### Technical Approach
The implementation handles several challenging aspects:

1. **Module Reloading**: Python modules are removed from `sys.modules` and reimported to get fresh instances
2. **Registry Management**: The MudObjRegistry is selectively cleared to preserve players while removing other objects
3. **Safe Exception Handling**: Specific exceptions are caught when removing players from old locations
4. **Sequence Number Management**: The registry sequence number is adjusted to account for existing player vnums

### Error Handling
- If the story module cannot be reloaded, an error message is displayed
- If starting locations cannot be found, players are notified
- If a player's old location is in an invalid state, the error is caught and ignored
- All exceptions during reset are caught and reported to the wizard who initiated the command

## Testing
The implementation includes comprehensive unit tests:
- Test that the command exists and is registered
- Test that the command is a generator (for confirmation dialog)
- Test that confirmation is required
- Test that the Driver.reset_story method exists
- Test that the command calls the driver's reset method when confirmed

## Future Enhancements
Possible improvements for future versions:
- Option to reset only specific zones
- Option to preserve or clear player inventories
- Backup/restore of game state before reset
- Configuration to exclude certain objects from reset
- Reset statistics tracking (number of resets, last reset time)

## Known Limitations
1. Custom story data not managed by the standard story/zone system may not be properly reset
2. External systems (databases, file caches) are not automatically reset
3. LLM cache and character memories are not cleared (may need manual cleanup)
4. Player wiretaps are not automatically re-established after reset

## Command Documentation
The command includes built-in help accessible via:
```
help !reset_story
```

The help text explains:
- What the command does
- That it requires wizard privileges
- That it affects all players
- What is preserved and what is reset
24 changes: 23 additions & 1 deletion tale/cmds/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import os
import platform
import sys
import traceback
from types import ModuleType
from typing import Generator, Optional

Expand Down Expand Up @@ -954,4 +955,25 @@ def do_set_rp_prompt(player: Player, parsed: base.ParseResult, ctx: util.Context
target.set_roleplay_prompt(prompt, effect_description, time)
player.tell("RP prompt set to: %s with effect: %s" % (prompt, effect_description))
except ValueError as x:
raise ActionRefused(str(x))
raise ActionRefused(str(x))


@wizcmd("reset_story")
def do_reset_story(player: Player, parsed: base.ParseResult, ctx: util.Context) -> Generator:
"""Reset/restart the story without restarting the server.
This will reload all zones, reset the game clock, clear all deferreds,
and move all players to their starting locations. Player inventory and stats are preserved.
Usage: !reset_story
"""
if not (yield "input", ("Are you sure you want to reset the story? This will affect all players!", lang.yesno)):
player.tell("Story reset cancelled.")
return

player.tell("Resetting the story...")
try:
ctx.driver.reset_story()
player.tell("Story has been reset successfully!")
player.tell("All players have been moved to their starting locations.")
except Exception as x:
player.tell("Error resetting story: %s" % str(x))
traceback.print_exc()
126 changes: 126 additions & 0 deletions tale/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,132 @@ def uptime(self) -> Tuple[int, int, int]:
minutes, seconds = divmod(seconds, 60)
return int(hours), int(minutes), int(seconds)

def reset_story(self) -> None:
"""
Reset/restart the story without restarting the server.
This reloads zones, resets the game clock, clears deferreds,
and moves players back to starting locations.
Player inventory and stats are preserved.
"""
# Notify all players
for conn in self.all_players.values():
if conn.player:
conn.player.tell("\n<bright>*** The story is being reset! ***</>")
conn.player.tell("Please wait...\n")
conn.write_output()

# Save player references (they should not be cleared)
players = [conn.player for conn in self.all_players.values() if conn.player]

# Clear all deferreds
with self.deferreds_lock:
self.deferreds.clear()

# Clear the MudObject registry to remove all old objects (except players)
# Items first
base.MudObjRegistry.all_items.clear()

# Remove non-player livings from registry
player_vnums = {p.vnum for p in players}
livings_to_remove = [vnum for vnum in base.MudObjRegistry.all_livings.keys() if vnum not in player_vnums]
for vnum in livings_to_remove:
del base.MudObjRegistry.all_livings[vnum]

# Clear locations and exits
base.MudObjRegistry.all_locations.clear()
base.MudObjRegistry.all_exits.clear()
base.MudObjRegistry.all_remains.clear()

# Reset sequence number but account for existing players
# We need to ensure new objects get vnums higher than any existing objects
if player_vnums:
base.MudObjRegistry.seq_nr = max(player_vnums) + 1
else:
# No players, safe to reset to 1
base.MudObjRegistry.seq_nr = 1

# Clear unbound exits
self.unbound_exits.clear()

# Reload the story module using importlib
try:
import story as story_module
importlib.reload(story_module)
self.story = story_module.Story()
self.story._verify(self)
except (ImportError, AttributeError) as e:
raise errors.TaleError("Failed to reload story module: %s" % str(e))

# Update configurations
self.story.config.server_mode = self.game_mode
mud_context.config = self.story.config

# Re-initialize the story
self.story.init(self)
self.llm_util.set_story(self.story)

# Reset game clock to the story's epoch or current time
self.game_clock = util.GameDateTime(
self.story.config.epoch or datetime.datetime.now().replace(microsecond=0),
self.story.config.gametime_to_realtime
)

# Reload zones
# First, unload zone modules from sys.modules
zone_modules_to_reload = [key for key in sys.modules.keys() if key.startswith('zones.')]
for module_name in zone_modules_to_reload:
del sys.modules[module_name]
if 'zones' in sys.modules:
del sys.modules['zones']

# Now reload zones
self.zones = self._load_zones(self.story.config.zones)

# Bind exits
for x in self.unbound_exits:
x._bind_target(self.zones)
self.unbound_exits.clear()

# Register periodicals again
self.register_periodicals(self)

# Move all players to their starting locations
try:
start_location = self.lookup_location(self.story.config.startlocation_player)
wizard_start = self.lookup_location(self.story.config.startlocation_wizard)
except errors.TaleError:
# If locations not found, try to find them again
start_location = None
wizard_start = None

for conn in self.all_players.values():
if conn.player:
p = conn.player
# Remove player from old location if it still exists
if p.location:
try:
p.location.remove(p, silent=True)
except (AttributeError, KeyError, ValueError):
# Location might be in an invalid state after reset
pass

# Determine starting location based on privileges
if "wizard" in p.privileges and wizard_start:
target_location = wizard_start
else:
target_location = start_location if start_location else wizard_start

if target_location:
# Move player to starting location
p.move(target_location, silent=True)
p.tell("\n<bright>Story reset complete!</>")
p.tell("You find yourself back at the beginning.\n")
p.look()
else:
p.tell("\n<bright>Error: Could not find starting location after reset.</>")

conn.write_output()

def prepare_combat_prompt(self,
attackers: List[base.Living],
defenders: List[base.Living],
Expand Down
88 changes: 88 additions & 0 deletions tests/test_reset_story.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Tests for the reset_story wizard command.

'Tale' mud driver, mudlib and interactive fiction framework
Copyright by Irmen de Jong (irmen@razorvine.net)
"""

import unittest
from unittest.mock import MagicMock, patch
import tale
from tale.base import Location, ParseResult
from tale.cmds import wizard
from tale.player import Player
from tale.story import StoryConfig
from tests.supportstuff import FakeDriver


class TestResetStory(unittest.TestCase):
def setUp(self):
"""Set up test fixtures."""
self.context = tale._MudContext()
self.context.config = StoryConfig()
self.context.driver = FakeDriver()

self.player = Player('test_wizard', 'f')
self.player.privileges.add('wizard')

self.location = Location('test_location')
self.location.init_inventory([self.player])

def test_reset_story_command_exists(self):
"""Test that the reset_story command is registered."""
# The command should be available
self.assertTrue(hasattr(wizard, 'do_reset_story'))

def test_reset_story_is_generator(self):
"""Test that reset_story is a generator (for the confirmation dialog)."""
# The @wizcmd decorator wraps the function, so we check the 'is_generator' attribute
# that was set by the decorator
self.assertTrue(hasattr(wizard.do_reset_story, 'is_generator'))
self.assertTrue(wizard.do_reset_story.is_generator)

def test_reset_story_requires_confirmation(self):
"""Test that reset_story requires confirmation before executing."""
parse_result = ParseResult(verb='!reset_story')

# Create a generator from the command
gen = wizard.do_reset_story(self.player, parse_result, self.context)

# The first yield should be for input confirmation
try:
why, what = next(gen)
self.assertEqual(why, 'input')
# The confirmation message should mention affecting all players
self.assertIn('all players', what[0].lower())
except StopIteration:
self.fail("Generator should yield for confirmation")

def test_reset_story_driver_method_exists(self):
"""Test that the Driver.reset_story method exists."""
from tale.driver import Driver
self.assertTrue(hasattr(Driver, 'reset_story'))
self.assertTrue(callable(getattr(Driver, 'reset_story')))

@patch('tale.driver.Driver.reset_story')
def test_reset_story_calls_driver_reset(self, mock_reset):
"""Test that the command calls the driver's reset_story method."""
parse_result = ParseResult(verb='!reset_story')

# Mock the driver's reset_story method
self.context.driver.reset_story = mock_reset

# Create and run the generator
gen = wizard.do_reset_story(self.player, parse_result, self.context)

# Send confirmation "yes"
try:
next(gen) # First yield for input
gen.send("yes") # Confirm the reset
except StopIteration:
pass

# The driver's reset_story should have been called
mock_reset.assert_called_once()


if __name__ == '__main__':
unittest.main()