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
105 changes: 105 additions & 0 deletions servers/engine/campaign_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Display-only campaign calendar projection.

The canonical engine clock is still ``Campaign.day``. This module maps that
integer into authored calendar labels for viewer/OpenWorlds read models without
introducing a second mutable time authority.
"""

from __future__ import annotations

from models import CampaignCalendar


def _month_cursor(calendar: CampaignCalendar, elapsed_days: int) -> tuple[int, int, int]:
months = calendar.months
if not months:
return calendar.epoch_year, 0, calendar.epoch_day + elapsed_days

month_index = min(max(calendar.epoch_month - 1, 0), len(months) - 1)
day_of_month = min(max(calendar.epoch_day, 1), months[month_index].days)
year = calendar.epoch_year
remaining = max(elapsed_days, 0)

while remaining:
days_left_in_month = months[month_index].days - day_of_month
if remaining <= days_left_in_month:
day_of_month += remaining
remaining = 0
else:
remaining -= days_left_in_month + 1
day_of_month = 1
month_index += 1
if month_index >= len(months):
month_index = 0
year += 1

return year, month_index, day_of_month


def _moon_phase(age: int, phase_names: list[str], cycle_days: int) -> str:
names = [str(name).strip() for name in phase_names if str(name).strip()]
if not names:
names = ["new", "waxing", "full", "waning"]
index = min(len(names) - 1, (age * len(names)) // max(cycle_days, 1))
return names[index]


def project_calendar_date(day: int, time_of_day: str, calendar: CampaignCalendar) -> dict:
"""Return a JSON-safe calendar projection for a campaign day.

``canonical_day`` is echoed so consumers never treat the rendered date as the
authority for ticks, rests, consequences, or strategic advancement.
"""

canonical_day = day if isinstance(day, int) and day > 0 else 1
if not calendar.months:
phase = str(time_of_day).strip()
return {
"available": False,
"canonical_day": canonical_day,
"label": f"Day {canonical_day}" + (f" · {phase}" if phase else ""),
}

elapsed_days = canonical_day - 1
year, month_index, day_of_month = _month_cursor(calendar, elapsed_days)
month = calendar.months[month_index] if calendar.months else None
month_name = month.name if month else "Day"
season = month.season if month else ""

weekday = ""
weekdays = [str(name).strip() for name in calendar.weekdays if str(name).strip()]
if weekdays:
weekday = weekdays[(calendar.week_start_index + elapsed_days) % len(weekdays)]

era = f" {calendar.era_suffix.strip()}" if calendar.era_suffix.strip() else ""
date_core = f"{day_of_month} {month_name} {year}{era}"
date_label = f"{weekday}, {date_core}" if weekday else date_core
phase = str(time_of_day).strip()
label = date_label + (f" · {phase}" if phase else "")

moons = []
for moon in calendar.moons:
cycle = max(moon.cycle_days, 1)
age = (moon.epoch_phase_day + elapsed_days) % cycle
moons.append(
{
"name": moon.name,
"age": age,
"cycle_days": cycle,
"phase": _moon_phase(age, moon.phase_names, cycle),
}
)

return {
"available": True,
"calendar": calendar.name,
"canonical_day": canonical_day,
"year": year,
"month": month_name,
"day_of_month": day_of_month,
"weekday": weekday,
"season": season,
"date_label": date_label,
"label": label,
"moons": moons,
}
8 changes: 8 additions & 0 deletions servers/engine/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from models import (
BacklogItem,
Campaign,
CampaignCalendar,
CampaignBacklog,
CompanionArc,
CompanionDossier,
Expand Down Expand Up @@ -1242,6 +1243,13 @@ def seed_world(world: dict, start_at: str = "", ending: str = "") -> Campaign:
c = Campaign(title=world.get("name", "Untitled World"), summary=world.get("premise", ""))
c.world_id = str(world.get("id", "")) # enables lookup_lore over this world's corpus
c.era = str(world.get("era") or world.get("current_year") or "") # chronology guardrail
if isinstance(world.get("calendar"), dict):
try:
calendar = CampaignCalendar.model_validate(world["calendar"])
if calendar.months:
c.calendar = calendar
except (ValidationError, ValueError, TypeError):
c.calendar = None

first_loc = None
for reg in _as_list(world, "regions"):
Expand Down
41 changes: 41 additions & 0 deletions servers/engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,46 @@ def canon_header(self) -> str:
)


class CalendarMonth(_StrictModel):
"""One authored month in a campaign calendar.

This is display metadata only. The engine's authoritative clock remains
``Campaign.day`` plus the tactical ``time_of_day`` phase.
"""

name: str
days: int = Field(..., ge=1, le=1000)
season: str = ""


class CalendarMoon(_StrictModel):
"""A deterministic moon phase track derived from ``Campaign.day``."""

name: str
cycle_days: int = Field(..., ge=1, le=10000)
epoch_phase_day: int = Field(0, ge=0)
phase_names: list[str] = Field(default_factory=lambda: ["new", "waxing", "full", "waning"])


class CampaignCalendar(_StrictModel):
"""Clean-room, setting-agnostic calendar display metadata.

Day 1 of the campaign maps to ``epoch_year``/``epoch_month``/``epoch_day``.
The model deliberately stores no mutable cursor; every rendered date is a
pure projection from ``Campaign.day``.
"""

name: str
era_suffix: str = ""
epoch_year: int = 1
epoch_month: int = Field(1, ge=1)
epoch_day: int = Field(1, ge=1)
weekdays: list[str] = Field(default_factory=list)
week_start_index: int = Field(0, ge=0)
months: list[CalendarMonth] = Field(default_factory=list)
moons: list[CalendarMoon] = Field(default_factory=list)


class StrategicClock(_StrictModel):
"""A setting-agnostic strategic pressure clock.

Expand Down Expand Up @@ -1400,6 +1440,7 @@ class Campaign(_StrictModel):
current_location_id: Optional[str] = None
day: int = 1 # in-world day counter
time_of_day: str = "morning"
calendar: Optional[CampaignCalendar] = None
map_kind: Literal["hex", "none"] = "none" # how the play-view renders the map
# World-state boolean flags the DM/engine set to gate events — e.g. "prize_seized"
# drives a companion's prize_seized agenda (S4). Additive: empty == today's behavior.
Expand Down
112 changes: 112 additions & 0 deletions servers/engine/tests/test_campaign_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from campaign_calendar import project_calendar_date
from content import seed_world
from models import CampaignCalendar, CalendarMonth, CalendarMoon


def _fixture_calendar() -> CampaignCalendar:
return CampaignCalendar(
name="Dale Reckoning",
era_suffix="DR",
epoch_year=1492,
epoch_month=1,
epoch_day=1,
weekdays=["Firstday", "Secondday", "Thirdday", "Fourthday", "Fifthday"],
months=[
CalendarMonth(name="Hammer", days=30, season="Deepwinter"),
CalendarMonth(name="Alturiak", days=30, season="The Claw of Winter"),
CalendarMonth(name="Ches", days=30, season="Springrise"),
],
moons=[
CalendarMoon(
name="Selune",
cycle_days=8,
phase_names=["new", "waxing", "full", "waning"],
)
],
)


def test_project_calendar_date_keeps_day_counter_canonical_and_renders_label():
projection = project_calendar_date(32, "dusk", _fixture_calendar())

assert projection["available"] is True
assert projection["canonical_day"] == 32
assert projection["year"] == 1492
assert projection["month"] == "Alturiak"
assert projection["day_of_month"] == 2
assert projection["weekday"] == "Secondday"
assert projection["season"] == "The Claw of Winter"
assert projection["date_label"] == "Secondday, 2 Alturiak 1492 DR"
assert projection["label"] == "Secondday, 2 Alturiak 1492 DR · dusk"
assert projection["moons"] == [
{
"name": "Selune",
"age": 7,
"cycle_days": 8,
"phase": "waning",
}
]


def test_project_calendar_date_degrades_empty_calendar_to_legacy_day_label():
projection = project_calendar_date(5, "night", CampaignCalendar(name="Empty Calendar"))

assert projection == {
"available": False,
"canonical_day": 5,
"label": "Day 5 · night",
}


def test_seed_world_preserves_optional_calendar_metadata_without_advancing_time():
campaign = seed_world(
{
"id": "calendar-test",
"name": "Calendar Test",
"premise": "A compact calendar fixture.",
"era": "1492 DR",
"calendar": _fixture_calendar().model_dump(mode="json"),
"regions": [
{"id": "loc-gate", "name": "Gate", "description": "A city gate.", "connections": []}
],
"factions": [],
"npc_roster": [],
"history": [],
"standing_threads": [],
"starting_options": [{"location_id": "loc-gate", "framing": "Start at the gate."}],
}
)

assert campaign.day == 1
assert campaign.time_of_day == "morning"
assert campaign.calendar is not None
assert campaign.calendar.name == "Dale Reckoning"
assert project_calendar_date(campaign.day, campaign.time_of_day, campaign.calendar)["label"] == (
"Firstday, 1 Hammer 1492 DR · morning"
)


def test_seed_world_ignores_malformed_optional_calendar_metadata():
campaign = seed_world(
{
"id": "calendar-broken",
"name": "Broken Calendar Test",
"premise": "A fixture with bad optional calendar metadata.",
"era": "1492 DR",
"calendar": {
"name": "Broken Reckoning",
"months": [{"name": "Impossible", "days": 0}],
},
"regions": [
{"id": "loc-gate", "name": "Gate", "description": "A city gate.", "connections": []}
],
"factions": [],
"npc_roster": [],
"history": [],
"standing_threads": [],
"starting_options": [{"location_id": "loc-gate", "framing": "Start at the gate."}],
}
)

assert campaign.day == 1
assert campaign.calendar is None
4 changes: 4 additions & 0 deletions viewer/openworlds/screen-map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ function ScreenMap({ onNavigate, state, campMode, setCampMode }) {
const locationClocks = selected ? clocks.filter((c) => !c.location_id || c.location_id === selected.id) : clocks;
const locationProjects = selected ? projects.filter((p) => !p.location_id || p.location_id === selected.id) : projects;
const locationControl = selected ? controls.find((c) => c.location_id === selected.id) : null;
const calendar = surface?.calendar?.available ? surface.calendar : null;
const calendarMoon = Array.isArray(calendar?.moons) ? calendar.moons[0] : null;
const calendarDetail = calendar ? [calendar.season, calendarMoon ? `${calendarMoon.name}: ${calendarMoon.phase}` : ""].filter(Boolean).join(" · ") : "";

return (
<div className="screen" style={{ height: "100%", display: "grid", gridTemplateColumns: "minmax(0, 1fr) 340px", gap: 14, padding: 14 }}>
Expand All @@ -159,6 +162,7 @@ function ScreenMap({ onNavigate, state, campMode, setCampMode }) {
<div className="body-sm" style={{ color: "var(--ink-700)", marginTop: 3 }}>
{surface?.dayLabel || surfaceStatus}
</div>
{calendarDetail && <div className="body-xs" style={{ color: "var(--b-700)", marginTop: 3 }}>{calendarDetail}</div>}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
{!campMode && ["dawn", "day", "dusk", "night"].map((t) => (
Expand Down
6 changes: 5 additions & 1 deletion viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ function ScreenTable({ onNavigate, state, setState }) {
const roundOrder = Array.isArray(surface?.roundOrder) ? surface.roundOrder : [];
const scene = surface?.scene || {};
const encounter = surface?.encounter || {};
const calendar = surface?.calendar?.available ? surface.calendar : null;
const calendarMoon = Array.isArray(calendar?.moons) ? calendar.moons[0] : null;
const calendarDetail = calendar ? [calendar.season, calendarMoon ? `${calendarMoon.name}: ${calendarMoon.phase}` : ""].filter(Boolean).join(" · ") : "";
const [activeHero, setActiveHero] = React.useState(() => party[0]?.id || "");
const hero = party.find((p) => p.id === activeHero) || party[0] || { id: "", name: "Hero", short: "Hero", level: 1, class: "Adventurer", hp: 1, hpMax: 1 };
const visibleQuests = quests.filter((q) => !q.status || q.status === "active" || q.status === "open");
Expand Down Expand Up @@ -219,8 +222,9 @@ function ScreenTable({ onNavigate, state, setState }) {
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
pointerEvents: "none",
}}>
<div>
<div style={{ minWidth: 0, maxWidth: "min(620px, 62%)" }}>
<Pill tone="royal" dot>{surface?.dayLabel || activeCampaign.day || "Unknown time"}</Pill>
{calendarDetail && <div className="body-xs" title={calendarDetail} style={{ marginTop: 4, color: "var(--p-150)", textShadow: "0 1px 2px rgba(0,0,0,0.75)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{calendarDetail}</div>}
<div className="hand" style={{ marginTop: 6, color: "var(--p-100)", fontSize: 16, textShadow: "0 1px 2px rgba(0,0,0,0.8)" }}>
{scene.summary || activeCampaign.recap || "The table is waiting for a campaign snapshot."}
</div>
Expand Down
Loading
Loading