Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364"""Tests for campaign log functionality."""
from pathlib import Path
import pytest
from storied.log import ( CampaignLog, Duration, GameTime, LogEntry, TranscriptLog, load_log, log_event,)
class TestGameTime: def test_default_values(self): t = GameTime() assert t.day == 1 assert t.hour == 6 assert t.minute == 0
def test_str(self): assert str(GameTime(day=3, hour=14, minute=30)) == "Day 3, 14:30"
def test_to_anchor(self): assert GameTime(day=1, hour=6, minute=0).to_anchor() == "#d1-0600" assert GameTime(day=3, hour=14, minute=30).to_anchor() == "#d3-1430"
def test_from_anchor(self): t = GameTime.from_anchor("#d1-0600") assert t.day == 1 assert t.hour == 6 assert t.minute == 0
t = GameTime.from_anchor("d3-1430") # Without # assert t.day == 3 assert t.hour == 14 assert t.minute == 30
def test_from_anchor_invalid(self): with pytest.raises(ValueError, match="anchor"): GameTime.from_anchor("invalid")
def test_add_duration_minutes(self): t = GameTime(day=1, hour=6, minute=0) result = t.add_duration(Duration(minutes=30)) assert result.day == 1 assert result.hour == 6 assert result.minute == 30
def test_add_duration_hours(self): t = GameTime(day=1, hour=6, minute=0) result = t.add_duration(Duration(minutes=120)) assert result.day == 1 assert result.hour == 8 assert result.minute == 0
def test_add_duration_crosses_day(self): t = GameTime(day=1, hour=22, minute=0) result = t.add_duration(Duration(minutes=180)) # 3 hours assert result.day == 2 assert result.hour == 1 assert result.minute == 0
def test_period_of_day(self): assert GameTime(hour=6).period_of_day() == "Morning" assert GameTime(hour=11).period_of_day() == "Morning" assert GameTime(hour=12).period_of_day() == "Afternoon" assert GameTime(hour=17).period_of_day() == "Afternoon" assert GameTime(hour=18).period_of_day() == "Evening" assert GameTime(hour=23).period_of_day() == "Evening"
def test_atmosphere(self): assert GameTime(hour=3).atmosphere() == "deep night" assert GameTime(hour=5).atmosphere() == "first light" assert GameTime(hour=9).atmosphere() == "morning light" assert GameTime(hour=13).atmosphere() == "high sun" assert GameTime(hour=15).atmosphere() == "afternoon" assert GameTime(hour=18).atmosphere() == "fading light" assert GameTime(hour=21).atmosphere() == "lamplight and shadow" assert GameTime(hour=23).atmosphere() == "deep night"
class TestDuration: def test_parse_minutes(self): d = Duration.parse("30 min") assert d.minutes == 30 assert d.raw == "30 min"
def test_parse_hours(self): d = Duration.parse("2 hours") assert d.minutes == 120
def test_parse_days(self): d = Duration.parse("3 days") assert d.minutes == 3 * 24 * 60
def test_parse_rounds_short_fight_floors_to_one_minute(self): # 5 rounds = 30 seconds, but the clock's minimum precision is # one minute, so a sub-minute combat still advances the clock. assert Duration.parse("5 rounds").minutes == 1
def test_parse_rounds_ten_rounds_is_one_minute(self): # 10 rounds = 60 seconds — the one natural minute boundary. assert Duration.parse("10 rounds").minutes == 1
def test_parse_rounds_twenty_rounds_is_two_minutes(self): assert Duration.parse("20 rounds").minutes == 2
def test_parse_rounds_long_combat(self): # 100 rounds = 10 minutes. assert Duration.parse("100 rounds").minutes == 10
def test_parse_scene(self): d = Duration.parse("scene") assert d.minutes == 5
def test_parse_variations(self): assert Duration.parse("1 hour").minutes == 60 assert Duration.parse("1 hr").minutes == 60 assert Duration.parse("1h").minutes == 60 assert Duration.parse("1 day").minutes == 24 * 60
def test_str_with_raw(self): d = Duration.parse("30 min") assert str(d) == "30 min"
def test_str_without_raw(self): d = Duration(minutes=30) assert str(d) == "30 min"
class TestLogEntry: def test_parse_basic(self): entry = LogEntry.parse("- #d1-0600 | Arrived at Port Haven | 30 min") assert entry is not None assert entry.anchor == "#d1-0600" assert entry.event == "Arrived at Port Haven" assert entry.duration.minutes == 30
def test_parse_with_tags(self): entry = LogEntry.parse("- #d1-0600 | Short rest | 1 hour | rest:short") assert entry is not None assert entry.tags == ["rest:short"]
def test_parse_invalid(self): assert LogEntry.parse("Not a valid line") is None assert LogEntry.parse("- no anchor") is None
def test_to_line(self): entry = LogEntry( anchor="#d1-0600", event="Arrived", duration=Duration.parse("30 min"), ) assert entry.to_line() == "- #d1-0600 | Arrived | 30 min"
def test_to_line_with_tags(self): entry = LogEntry( anchor="#d1-0600", event="Rest", duration=Duration.parse("1 hour"), tags=["rest:short"], ) assert entry.to_line() == "- #d1-0600 | Rest | 1 hour | rest:short"
class TestCampaignLog: def test_new_log(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) assert log.current_day == 1 assert log.current_time.hour == 6 assert log.current_entries == []
def test_append_entry(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) anchor = log.append_entry("Arrived at Port Haven", "30 min")
assert anchor == "#d1-0600" assert len(log.current_entries) == 1 assert log.current_time.minute == 30
def test_append_multiple_entries(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Event 1", "30 min") log.append_entry("Event 2", "1 hour")
assert len(log.current_entries) == 2 assert log.current_time.hour == 7 assert log.current_time.minute == 30
def test_persistence(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Test event", "30 min")
# Load fresh log2 = CampaignLog(world_id="test", base_path=tmp_path) assert len(log2.current_entries) == 1 assert log2.current_entries[0].event == "Test event"
def test_roll_day(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00 log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day
assert log.current_day == 2 assert len(log.previous_summaries) == 1 assert "Morning event" in log.previous_summaries[0]
# Check day file was created day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md" assert day_file.exists()
def test_roll_day_preserves_crossing_entry(self, tmp_path): """Entry that triggers a day roll must appear in the old day's file.""" log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Morning event", "2 hours") log.append_entry("Traveled all day", "20 hours")
day1 = tmp_path / "worlds" / "test" / "log" / "day+001.md" content = day1.read_text() assert "Morning event" in content assert "Traveled all day" in content
def test_format_for_context(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Arrived at tavern", "30 min") log.append_entry("Met the barkeep", "1 hour")
context = log.format_for_context() assert "Day 1" in context assert "Arrived at tavern" in context assert "Met the barkeep" in context
def test_time_since_rest_no_rest(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Activity", "2 hours")
since = log.time_since_rest("short") assert since.minutes >= 2 * 60
def test_time_since_rest_with_rest(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Activity", "2 hours") log.append_entry("Rest", "1 hour", tags=["rest:short"]) log.append_entry("More activity", "30 min")
since = log.time_since_rest("short") assert since.minutes == 30
class TestConvenienceFunctions: def test_load_log(self, tmp_path): log = load_log(world_id="test", base_path=tmp_path) assert isinstance(log, CampaignLog)
def test_log_event(self, tmp_path): anchor = log_event( "Test event", "30 min", world_id="test", base_path=tmp_path, ) assert anchor == "#d1-0600"
# Verify it was saved log = load_log(world_id="test", base_path=tmp_path) assert len(log.current_entries) == 1
# ── TranscriptLog ────────────────────────────────────────────────────────
@pytest.fixturedef transcript(tmp_path: Path) -> TranscriptLog: (tmp_path / "worlds" / "test").mkdir(parents=True) return TranscriptLog("test", tmp_path)
class TestTranscriptLog: def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( "I look around", "The tavern is dimly lit.", GameTime(day=1, hour=8, minute=0), ) path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" assert path.exists()
def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( "I look around", "The tavern is dimly lit.", GameTime(day=1, hour=8, minute=0), ) content = ( tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" ).read_text() assert "> I look around" in content assert "The tavern is dimly lit." in content assert "### Day 1, 08:00" in content
def test_multiple_turns_append(self, transcript: TranscriptLog): time1 = GameTime(day=1, hour=8, minute=0) time2 = GameTime(day=1, hour=8, minute=15) transcript.append_turn("First", "Response one.", time1) transcript.append_turn("Second", "Response two.", time2) context = transcript.recent_turns(1) assert "First" in context assert "Second" in context
def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( "[Session starting]", "Welcome back!", GameTime(day=1, hour=8, minute=0), ) path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" assert not path.exists()
def test_recent_turns_limit(self, transcript: TranscriptLog): for i in range(15): transcript.append_turn( f"Turn {i}", f"Response {i}.", GameTime(day=1, hour=8, minute=i), ) context = transcript.recent_turns(1, n=5) assert "Turn 10" in context assert "Turn 14" in context assert "Turn 0" not in context
def test_recent_turns_empty(self, transcript: TranscriptLog): assert transcript.recent_turns(1) == ""
def test_recent_turns_spans_days(self, transcript: TranscriptLog): transcript.append_turn( "Yesterday", "Something happened.", GameTime(day=1, hour=20, minute=0), ) transcript.append_turn( "Today", "Morning arrives.", GameTime(day=2, hour=8, minute=0), ) context = transcript.recent_turns(2) assert "Yesterday" in context assert "Today" in context
def test_includes_display_blocks(self, transcript: TranscriptLog): dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." transcript.append_turn( "Look around", dm_response, GameTime(day=1, hour=8, minute=0), ) context = transcript.recent_turns(1) assert "```map" in context assert "+-+" in context