From ab2b494c21bcb5777fc09988da86da4b38553322 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:04:46 -0400 Subject: [PATCH] feat: time_skip event handlers (T50) --- chat/state/world.py | 28 +++++++ tests/test_time_skip_handlers.py | 132 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/test_time_skip_handlers.py diff --git a/chat/state/world.py b/chat/state/world.py index 8a9ba89..f99ddb9 100644 --- a/chat/state/world.py +++ b/chat/state/world.py @@ -29,6 +29,34 @@ def _apply_chat_created(conn: Connection, e: Event) -> None: ) +@on("time_skip_elision") +def _apply_time_skip_elision(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE chat_state SET time = ? WHERE chat_id = ?", + (p["new_time"], p["chat_id"]), + ) + + +@on("time_skip_jump") +def _apply_time_skip_jump(conn: Connection, e: Event) -> None: + p = e.payload + chat_id = p["chat_id"] + conn.execute( + "UPDATE chat_state SET time = ? WHERE chat_id = ?", + (p["new_time"], chat_id), + ) + if p.get("reset_activity", False): + # Activity rows are keyed by entity_id with a container_id FK. + # Each chat owns its containers, so deleting activity rows whose + # container_id belongs to this chat clears every present entity. + conn.execute( + "DELETE FROM activity " + "WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)", + (chat_id,), + ) + + @on("guest_added") def _apply_guest_added(conn: Connection, e: Event) -> None: p = e.payload diff --git a/tests/test_time_skip_handlers.py b/tests/test_time_skip_handlers.py new file mode 100644 index 0000000..0fa6d86 --- /dev/null +++ b/tests/test_time_skip_handlers.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +import chat.state.world # registers handlers +from chat.state.world import get_activity, get_chat + + +def _chat_payload(**overrides): + payload = { + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "guest_bot_id": None, + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + payload.update(overrides) + return payload + + +def _container_payload(**overrides): + payload = { + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": { + "public": True, + "moving": False, + "audible_range": "normal", + "slots": [], + }, + "parent_id": None, + } + payload.update(overrides) + return payload + + +def _activity_payload(**overrides): + payload = { + "entity_id": "bot_a", + "container_id": 1, + "slot": "desk_chair", + "posture": "sitting", + "action": {"verb": "writing email"}, + "attention": "the screen", + "holding": ["pen"], + "status": {"hungry": False}, + } + payload.update(overrides) + return payload + + +def _seed_events(conn): + """Append seed events but do NOT project — caller appends more then projects once.""" + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + append_event(conn, kind="activity_change", payload=_activity_payload()) + + +def test_elision_advances_chat_clock_only(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_events(conn) + append_event(conn, kind="time_skip_elision", payload={ + "chat_id": "chat_bot_a", + "new_time": "2026-04-26T20:30:00+00:00", + }) + project(conn) + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"] == "2026-04-26T20:30:00+00:00" + + # Activity row preserved with the same fields it was seeded with. + a = get_activity(conn, "bot_a") + assert a is not None + assert a["entity_id"] == "bot_a" + assert a["container_id"] == 1 + assert a["slot"] == "desk_chair" + assert a["posture"] == "sitting" + assert a["action"] == {"verb": "writing email"} + assert a["attention"] == "the screen" + assert a["holding"] == ["pen"] + assert a["status"] == {"hungry": False} + + +def test_jump_with_reset_clears_activity(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_events(conn) + append_event(conn, kind="time_skip_jump", payload={ + "chat_id": "chat_bot_a", + "new_time": "2026-04-27T08:00:00+00:00", + "reset_activity": True, + }) + project(conn) + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"] == "2026-04-27T08:00:00+00:00" + + count = conn.execute( + "SELECT COUNT(*) FROM activity " + "WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)", + ("chat_bot_a",), + ).fetchone()[0] + assert count == 0 + assert get_activity(conn, "bot_a") is None + + +def test_jump_without_reset_preserves_activity(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_events(conn) + append_event(conn, kind="time_skip_jump", payload={ + "chat_id": "chat_bot_a", + "new_time": "2026-04-27T08:00:00+00:00", + "reset_activity": False, + }) + project(conn) + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"] == "2026-04-27T08:00:00+00:00" + + a = get_activity(conn, "bot_a") + assert a is not None + assert a["posture"] == "sitting" + assert a["action"]["verb"] == "writing email"