Calendar¶
Semantic game time: a day counter, SO-configured named phases within each day, manual advance,
and a save-safe scheduled-callback registry. Doc 07 places Calendar conceptually above the
engine's ITimeService (real seconds) — it never touches that service; v0.1 is manual-advance
only, driven entirely by the game calling AdvancePhase/AdvanceToNextDay. No seasons, no
weekdays, no real-time drive, no sim-speed (rule of two — those wait for a second real consumer).
What it is¶
- Headless core —
CalendarModel: a pure C# class (zeroUnityEngine/engine references) that owns all calendar semantics — day/phase math and the scheduled-entry registry. Fully unit-testable without Unity; portable if the Kit is ever spun out standalone. - Service layer —
ICalendarService/CalendarService: thin glue that wraps aCalendarModel, adds a save domain (CalendarSaveData), and forwards the model's callbacks as pooledCalendar*Events on the engine'sIEventManager. - Config —
CalendarConfig: a ScriptableObject holding the ordered list of phase definitions (id + LocString display name) a day is built from.
There is no editor window for Calendar (unlike WorldFlags) — a day/phase counter and a short pending-schedule list don't carry their own weight for a live inspector at this scope; revisit if a second consumer wants one.
Quickstart¶
- Create a
CalendarConfigasset (Create → LoLGameplayKit → Calendar Config) and fill in your phases in order (e.g.morning,day,dusk,night), each with a localization table + key for its display name. Place it in aResourcesfolder under the exact nameCalendarConfig. - Create a
GameplayKitConfigasset (Create → LoLGameplayKit), leaveenableCalendaron, and place it in aResourcesfolder namedGameplayKitConfig. Registration is automatic: the module shipsCalendarServiceRegistrar, anIServiceRegistrarthe engine discovers by assembly scan and constructs parameterlessly. It loads both assets fromResources, constructsCalendarService, callsInitialize()itself (the engine does not callInitialize/LateInitialize/Shutdownon externally registered services), and registersICalendarService. Missing assets fail loud with aDebug.LogErrorand the service simply isn't registered. - Resolve and use it from any game system:
var calendar = ServiceLocator.Instance.Get<ICalendarService>();
calendar.AdvancePhase(); // next phase; rolls into a new day at the end of the list
calendar.AdvanceToNextDay(); // skip straight to the next day
int day = calendar.CurrentDay; // 1-based
string phaseId = calendar.CurrentPhaseId; // data key, e.g. "dusk"
string label = localizationService.Format(calendar.CurrentPhaseDisplayName); // player-facing
- Schedule a save-safe callback and react to it going due (see
Samples/Scripts/CalendarSample.csfor the full listener pattern):
calendar.ScheduleInDays("quest.deadline", daysFromNow: 3, calendar.CurrentPhaseIndex);
public class MySystem : MonoBehaviour, IEventListener<CalendarScheduledDueEvent>
{
void OnEnable() => this.EventStartListening<CalendarScheduledDueEvent>();
void OnDisable() => this.EventStopListening<CalendarScheduledDueEvent>();
public void OnGameEvent(CalendarScheduledDueEvent evt)
{
if (evt.ScheduleId == "quest.deadline") { /* ... */ }
}
}
No delegate is ever stored on a scheduled entry — only the id survives a save, so matching on
ScheduleId in the handler is the only supported way to react, whether the entry was scheduled
this session or restored from a save.
API overview¶
CalendarModel (headless core)¶
| Member | Notes |
|---|---|
Day, CurrentPhaseIndex, CurrentPhaseId, PhaseCount, PhaseIds |
Current position; Day is 1-based, CurrentPhaseIndex is 0-based. |
AdvancePhase() |
Moves to the next phase; rolls into a new day after the last phase. Returns the ids of entries that became due. |
AdvanceToNextDay() |
Completes remaining phases and starts the next day (repeated AdvancePhase under the hood — every intermediate phase's events still fire). |
ScheduleAt(id, day, phaseIndex) / ScheduleInDays(id, daysFromNow, phaseIndex) |
Schedule (or reschedule) an entry. |
Cancel(id) |
Removes a pending entry; returns whether one was found. |
PendingEntries |
Every entry not yet due, in schedule order. |
CreateSnapshot() / RestoreSnapshot(snapshot) |
The save-integration seam. |
event Action<int> PhaseChanged, DayStarted, DayEnded; event Action<string> EntryDue |
Plain C# callbacks — what CalendarService bridges to pooled events. |
ICalendarService mirrors this surface (plus the display-name/id resolution helpers and the
inherited ILoLEngineService lifecycle), so most game code talks to the service, not the model
directly. CalendarModel is a public type you can instantiate and drive headless (that is how
the test suite pins the semantics) — but the service does not expose its internal model
instance or the model's C# callbacks; consumers react through the pooled Calendar*Events.
Key design decisions¶
- Scheduling is index-primary: the model's native representation is
(id, day, phaseIndex)— indices are what event payloads carry and what the headless core compares directly, with no dependency on a config object.ICalendarServiceadds phase-id overloads (ScheduleAt(id, day, phaseId),ScheduleInDays(id, daysFromNow, phaseId)) purely for ergonomics; they resolve to an index viaTryGetPhaseIndexand throwArgumentExceptionfor an unknown id. - Duplicate schedule ids replace, in place: calling
ScheduleAt/ScheduleInDayswith an id that already has a pending entry overwrites that entry's day/phase rather than adding a second one — its position in schedule order is preserved. There is exactly one pending entry per id at any time. - Scheduling in the past is due on the next advance: a day/phase at or before the current
position doesn't fire immediately when scheduled — nothing in the model checks due-ness except
during
AdvancePhase/AdvanceToNextDay, so even an entry scheduled for the current moment waits for the next Advance* call. - Due entries fire once, in schedule order, then are removed from
PendingEntries— no entry is ever reported twice, andAdvanceToNextDay's aggregated due-id list preserves the order entries became due across the phases it skipped. - Rollover event order is deterministic:
DayEnded(old day) →DayStarted(new day) →PhaseChanged→EntryDue/CalendarScheduledDueEventper due entry, in schedule order. A non-rollover advance fires onlyPhaseChangedand any due entries. - No delegates, anywhere: scheduled entries are pure
(id, day, phaseIndex)data — the headless model has no delegate-typed field that could dangle across a save/load. Consumers always react via theEntryDuecallback /CalendarScheduledDueEvent, matching on id. RestoreSnapshotclamps, doesn't throw: an out-of-rangeCurrentPhaseIndex(for example a save written under a config with more phases than the current one) is clamped into range instead of throwing, so a shrunk phase list degrades a load instead of corrupting it.LocStringon a ScriptableObject has no core precedent to copy:LocStringis sealed, has no parameterless constructor, and isn't[Serializable], so it cannot be a direct[SerializeField].CalendarConfig.PhaseDefinitioninstead serializes the raw localization table name + key as plain strings and assembles theLocStringon demand via aDisplayNameproperty. This is an original Kit pattern, not a mirrored core one — no core config SO holds aLocStringto copy from.
Save behavior¶
CalendarSaveData : GameplayKitSaveData is the module's save domain (DataId =
"gameplaykit.calendar", SchemaVersion = 1) — plain data (Day, CurrentPhaseIndex,
PendingEntries), no reference back to a live model. CalendarService:
- Gets/registers the domain via
ISaveSystem.GetData<CalendarSaveData>()inInitialize(), if a save system is available. - Subscribes to the domain's inherited
BeforeSaveEvent/AfterLoadEvent(the sanctioned "subscribe to save notifications from external components" seam onPersistableData) — capturing aCalendarModel.CreateSnapshot()into the domain right before serialization, and callingCalendarModel.RestoreSnapshot()from the domain right after deserialization. - Also restores immediately in
Initialize(), in case the domain already held data (a load that happened before this service existed, or a shared domain another system populated first).
If ISaveSystem is not available (module registered without it, or the engine's save service
disabled), the service still works — calendar state just doesn't persist. A warning is logged
once at Initialize().
Events contract¶
CalendarPhaseChangedEvent, CalendarDayStartedEvent, CalendarDayEndedEvent, and
CalendarScheduledDueEvent (all GameEvent) are dispatched via IEventManager.TriggerEvent —
pooled, per the Kit's Events contract, in the rollover order documented above. The model's plain
C# PhaseChanged/DayStarted/DayEnded/EntryDue callbacks exist only on CalendarModel
itself (for headless/test consumers that construct their own model); the service's internal
model instance is private and the pooled events are the service's only notification surface.
If IEventManager is not available, the service still works — calendar advance/scheduling just
don't raise Calendar*Events. A warning is logged once at Initialize().
Timed / sim-speed drive (deferred — Tier 2 hook)¶
v0.1 is manual-advance only: nothing in CalendarModel or CalendarService reads
UnityEngine.Time or the engine's ITimeService. A future timed/sim-speed mode (doc 05 §48) is
expected to be a thin driver on top of ICalendarService.AdvancePhase() — likely a
MonoBehaviour or service that calls it on an ITimeService channel/tick — rather than a change
to the model or service themselves. No hook is reserved in this release beyond that architectural
note; adding one before a second real consumer exists would violate the rule of two.
Seam ledger¶
Per the Kit's portability discipline (doc 07, "Portability discipline"), every module's docs page
lists exactly which engine surface it consumes. CalendarModel (the headless core) consumes
none — zero UnityEngine/LoLEngine.Core references. CalendarService and CalendarConfig
(the service/config layer) consume:
| Engine surface | Namespace | Used for |
|---|---|---|
ILoLEngineService |
LoLEngine.Core.ServiceManagement.Interfaces |
Service lifecycle (Initialize/Shutdown). |
ServiceDependencyAttribute |
LoLEngine.Core.ServiceManagement.Dependencies |
Documentation-of-intent only: the engine's dependency sorter never processes externally registered services, so the attribute has no runtime effect here. |
IEventManager, GameEvent |
LoLEngine.Core.Events.Interfaces / LoLEngine.Core.Events.GameEvents |
Pooled Calendar*Event dispatch. |
ISaveSystem, PersistableData (via GameplayKitSaveData) |
LoLEngine.Core.DataPersistence.Interfaces / .Data |
Save domain registration and the BeforeSaveEvent/AfterLoadEvent hooks. |
LocString, LocTable |
LoLEngine.Core.Localization.LocString |
Phase display names (CalendarConfig.PhaseDefinition.DisplayName, resolved via ILocalizationService.Format by the consumer). |
ScriptableObject, [CreateAssetMenu], [SerializeField] |
UnityEngine |
CalendarConfig's tuning-asset shape. |
UnityEngine.Debug |
UnityEngine |
Degrade-gracefully warnings when an optional dependency is absent. |
CalendarSample (the sample script, not part of the module's runtime surface) additionally
consumes UnityEngine.Input/MonoBehaviour/KeyCode for its keyboard test controls, and
ILocalizationService to resolve CurrentPhaseDisplayName for display — expected for a
consumer-facing sample, out of scope for the portability discipline (which concerns the module's
own runtime code, not samples).