A 5e storytelling engine with an LLM DM
1"""Tests for campaign log functionality."""
2
3from pathlib import Path
4
5import pytest
6
7from storied.log import (
8 CampaignLog,
9 Duration,
10 GameTime,
11 LogEntry,
12 TranscriptLog,
13 load_log,
14 log_event,
15)
16
17
18class TestGameTime:
19 def test_default_values(self):
20 t = GameTime()
21 assert t.day == 1
22 assert t.hour == 6
23 assert t.minute == 0
24
25 def test_str(self):
26 assert str(GameTime(day=3, hour=14, minute=30)) == "Day 3, 14:30"
27
28 def test_to_anchor(self):
29 assert GameTime(day=1, hour=6, minute=0).to_anchor() == "#d1-0600"
30 assert GameTime(day=3, hour=14, minute=30).to_anchor() == "#d3-1430"
31
32 def test_from_anchor(self):
33 t = GameTime.from_anchor("#d1-0600")
34 assert t.day == 1
35 assert t.hour == 6
36 assert t.minute == 0
37
38 t = GameTime.from_anchor("d3-1430") # Without #
39 assert t.day == 3
40 assert t.hour == 14
41 assert t.minute == 30
42
43 def test_from_anchor_invalid(self):
44 with pytest.raises(ValueError, match="anchor"):
45 GameTime.from_anchor("invalid")
46
47 def test_add_duration_minutes(self):
48 t = GameTime(day=1, hour=6, minute=0)
49 result = t.add_duration(Duration(minutes=30))
50 assert result.day == 1
51 assert result.hour == 6
52 assert result.minute == 30
53
54 def test_add_duration_hours(self):
55 t = GameTime(day=1, hour=6, minute=0)
56 result = t.add_duration(Duration(minutes=120))
57 assert result.day == 1
58 assert result.hour == 8
59 assert result.minute == 0
60
61 def test_add_duration_crosses_day(self):
62 t = GameTime(day=1, hour=22, minute=0)
63 result = t.add_duration(Duration(minutes=180)) # 3 hours
64 assert result.day == 2
65 assert result.hour == 1
66 assert result.minute == 0
67
68 def test_period_of_day(self):
69 assert GameTime(hour=6).period_of_day() == "Morning"
70 assert GameTime(hour=11).period_of_day() == "Morning"
71 assert GameTime(hour=12).period_of_day() == "Afternoon"
72 assert GameTime(hour=17).period_of_day() == "Afternoon"
73 assert GameTime(hour=18).period_of_day() == "Evening"
74 assert GameTime(hour=23).period_of_day() == "Evening"
75
76 def test_atmosphere(self):
77 assert GameTime(hour=3).atmosphere() == "deep night"
78 assert GameTime(hour=5).atmosphere() == "first light"
79 assert GameTime(hour=9).atmosphere() == "morning light"
80 assert GameTime(hour=13).atmosphere() == "high sun"
81 assert GameTime(hour=15).atmosphere() == "afternoon"
82 assert GameTime(hour=18).atmosphere() == "fading light"
83 assert GameTime(hour=21).atmosphere() == "lamplight and shadow"
84 assert GameTime(hour=23).atmosphere() == "deep night"
85
86
87class TestDuration:
88 def test_parse_minutes(self):
89 d = Duration.parse("30 min")
90 assert d.minutes == 30
91 assert d.raw == "30 min"
92
93 def test_parse_hours(self):
94 d = Duration.parse("2 hours")
95 assert d.minutes == 120
96
97 def test_parse_days(self):
98 d = Duration.parse("3 days")
99 assert d.minutes == 3 * 24 * 60
100
101 def test_parse_rounds_short_fight_floors_to_one_minute(self):
102 # 5 rounds = 30 seconds, but the clock's minimum precision is
103 # one minute, so a sub-minute combat still advances the clock.
104 assert Duration.parse("5 rounds").minutes == 1
105
106 def test_parse_rounds_ten_rounds_is_one_minute(self):
107 # 10 rounds = 60 seconds — the one natural minute boundary.
108 assert Duration.parse("10 rounds").minutes == 1
109
110 def test_parse_rounds_twenty_rounds_is_two_minutes(self):
111 assert Duration.parse("20 rounds").minutes == 2
112
113 def test_parse_rounds_long_combat(self):
114 # 100 rounds = 10 minutes.
115 assert Duration.parse("100 rounds").minutes == 10
116
117 def test_parse_scene(self):
118 d = Duration.parse("scene")
119 assert d.minutes == 5
120
121 def test_parse_variations(self):
122 assert Duration.parse("1 hour").minutes == 60
123 assert Duration.parse("1 hr").minutes == 60
124 assert Duration.parse("1h").minutes == 60
125 assert Duration.parse("1 day").minutes == 24 * 60
126
127 def test_str_with_raw(self):
128 d = Duration.parse("30 min")
129 assert str(d) == "30 min"
130
131 def test_str_without_raw(self):
132 d = Duration(minutes=30)
133 assert str(d) == "30 min"
134
135
136class TestLogEntry:
137 def test_parse_basic(self):
138 entry = LogEntry.parse("- #d1-0600 | Arrived at Port Haven | 30 min")
139 assert entry is not None
140 assert entry.anchor == "#d1-0600"
141 assert entry.event == "Arrived at Port Haven"
142 assert entry.duration.minutes == 30
143
144 def test_parse_with_tags(self):
145 entry = LogEntry.parse("- #d1-0600 | Short rest | 1 hour | rest:short")
146 assert entry is not None
147 assert entry.tags == ["rest:short"]
148
149 def test_parse_invalid(self):
150 assert LogEntry.parse("Not a valid line") is None
151 assert LogEntry.parse("- no anchor") is None
152
153 def test_to_line(self):
154 entry = LogEntry(
155 anchor="#d1-0600",
156 event="Arrived",
157 duration=Duration.parse("30 min"),
158 )
159 assert entry.to_line() == "- #d1-0600 | Arrived | 30 min"
160
161 def test_to_line_with_tags(self):
162 entry = LogEntry(
163 anchor="#d1-0600",
164 event="Rest",
165 duration=Duration.parse("1 hour"),
166 tags=["rest:short"],
167 )
168 assert entry.to_line() == "- #d1-0600 | Rest | 1 hour | rest:short"
169
170
171class TestCampaignLog:
172 def test_new_log(self, tmp_path):
173 log = CampaignLog(world_id="test", base_path=tmp_path)
174 assert log.current_day == 1
175 assert log.current_time.hour == 6
176 assert log.current_entries == []
177
178 def test_append_entry(self, tmp_path):
179 log = CampaignLog(world_id="test", base_path=tmp_path)
180 anchor = log.append_entry("Arrived at Port Haven", "30 min")
181
182 assert anchor == "#d1-0600"
183 assert len(log.current_entries) == 1
184 assert log.current_time.minute == 30
185
186 def test_append_multiple_entries(self, tmp_path):
187 log = CampaignLog(world_id="test", base_path=tmp_path)
188 log.append_entry("Event 1", "30 min")
189 log.append_entry("Event 2", "1 hour")
190
191 assert len(log.current_entries) == 2
192 assert log.current_time.hour == 7
193 assert log.current_time.minute == 30
194
195 def test_persistence(self, tmp_path):
196 log = CampaignLog(world_id="test", base_path=tmp_path)
197 log.append_entry("Test event", "30 min")
198
199 # Load fresh
200 log2 = CampaignLog(world_id="test", base_path=tmp_path)
201 assert len(log2.current_entries) == 1
202 assert log2.current_entries[0].event == "Test event"
203
204 def test_roll_day(self, tmp_path):
205 log = CampaignLog(world_id="test", base_path=tmp_path)
206 log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00
207 log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day
208
209 assert log.current_day == 2
210 assert len(log.previous_summaries) == 1
211 assert "Morning event" in log.previous_summaries[0]
212
213 # Check day file was created
214 day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md"
215 assert day_file.exists()
216
217 def test_roll_day_preserves_crossing_entry(self, tmp_path):
218 """Entry that triggers a day roll must appear in the old day's file."""
219 log = CampaignLog(world_id="test", base_path=tmp_path)
220 log.append_entry("Morning event", "2 hours")
221 log.append_entry("Traveled all day", "20 hours")
222
223 day1 = tmp_path / "worlds" / "test" / "log" / "day+001.md"
224 content = day1.read_text()
225 assert "Morning event" in content
226 assert "Traveled all day" in content
227
228 def test_format_for_context(self, tmp_path):
229 log = CampaignLog(world_id="test", base_path=tmp_path)
230 log.append_entry("Arrived at tavern", "30 min")
231 log.append_entry("Met the barkeep", "1 hour")
232
233 context = log.format_for_context()
234 assert "Day 1" in context
235 assert "Arrived at tavern" in context
236 assert "Met the barkeep" in context
237
238 def test_time_since_rest_no_rest(self, tmp_path):
239 log = CampaignLog(world_id="test", base_path=tmp_path)
240 log.append_entry("Activity", "2 hours")
241
242 since = log.time_since_rest("short")
243 assert since.minutes >= 2 * 60
244
245 def test_time_since_rest_with_rest(self, tmp_path):
246 log = CampaignLog(world_id="test", base_path=tmp_path)
247 log.append_entry("Activity", "2 hours")
248 log.append_entry("Rest", "1 hour", tags=["rest:short"])
249 log.append_entry("More activity", "30 min")
250
251 since = log.time_since_rest("short")
252 assert since.minutes == 30
253
254
255class TestConvenienceFunctions:
256 def test_load_log(self, tmp_path):
257 log = load_log(world_id="test", base_path=tmp_path)
258 assert isinstance(log, CampaignLog)
259
260 def test_log_event(self, tmp_path):
261 anchor = log_event(
262 "Test event",
263 "30 min",
264 world_id="test",
265 base_path=tmp_path,
266 )
267 assert anchor == "#d1-0600"
268
269 # Verify it was saved
270 log = load_log(world_id="test", base_path=tmp_path)
271 assert len(log.current_entries) == 1
272
273
274# ── TranscriptLog ────────────────────────────────────────────────────────
275
276
277@pytest.fixture
278def transcript(tmp_path: Path) -> TranscriptLog:
279 (tmp_path / "worlds" / "test").mkdir(parents=True)
280 return TranscriptLog("test", tmp_path)
281
282
283class TestTranscriptLog:
284 def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path):
285 transcript.append_turn(
286 "I look around",
287 "The tavern is dimly lit.",
288 GameTime(day=1, hour=8, minute=0),
289 )
290 path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md"
291 assert path.exists()
292
293 def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path):
294 transcript.append_turn(
295 "I look around",
296 "The tavern is dimly lit.",
297 GameTime(day=1, hour=8, minute=0),
298 )
299 content = (
300 tmp_path / "worlds" / "test" / "transcripts" / "day+001.md"
301 ).read_text()
302 assert "> I look around" in content
303 assert "The tavern is dimly lit." in content
304 assert "### Day 1, 08:00" in content
305
306 def test_multiple_turns_append(self, transcript: TranscriptLog):
307 time1 = GameTime(day=1, hour=8, minute=0)
308 time2 = GameTime(day=1, hour=8, minute=15)
309 transcript.append_turn("First", "Response one.", time1)
310 transcript.append_turn("Second", "Response two.", time2)
311 context = transcript.recent_turns(1)
312 assert "First" in context
313 assert "Second" in context
314
315 def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path):
316 transcript.append_turn(
317 "[Session starting]",
318 "Welcome back!",
319 GameTime(day=1, hour=8, minute=0),
320 )
321 path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md"
322 assert not path.exists()
323
324 def test_recent_turns_limit(self, transcript: TranscriptLog):
325 for i in range(15):
326 transcript.append_turn(
327 f"Turn {i}",
328 f"Response {i}.",
329 GameTime(day=1, hour=8, minute=i),
330 )
331 context = transcript.recent_turns(1, n=5)
332 assert "Turn 10" in context
333 assert "Turn 14" in context
334 assert "Turn 0" not in context
335
336 def test_recent_turns_empty(self, transcript: TranscriptLog):
337 assert transcript.recent_turns(1) == ""
338
339 def test_recent_turns_spans_days(self, transcript: TranscriptLog):
340 transcript.append_turn(
341 "Yesterday",
342 "Something happened.",
343 GameTime(day=1, hour=20, minute=0),
344 )
345 transcript.append_turn(
346 "Today",
347 "Morning arrives.",
348 GameTime(day=2, hour=8, minute=0),
349 )
350 context = transcript.recent_turns(2)
351 assert "Yesterday" in context
352 assert "Today" in context
353
354 def test_includes_display_blocks(self, transcript: TranscriptLog):
355 dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern."
356 transcript.append_turn(
357 "Look around",
358 dm_response,
359 GameTime(day=1, hour=8, minute=0),
360 )
361 context = transcript.recent_turns(1)
362 assert "```map" in context
363 assert "+-+" in context