Skip to content

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 (zero UnityEngine/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 a CalendarModel, adds a save domain (CalendarSaveData), and forwards the model's callbacks as pooled Calendar*Events on the engine's IEventManager.
  • 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

  1. Create a CalendarConfig asset (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 a Resources folder under the exact name CalendarConfig.
  2. Create a GameplayKitConfig asset (Create → LoLGameplayKit), leave enableCalendar on, and place it in a Resources folder named GameplayKitConfig. Registration is automatic: the module ships CalendarServiceRegistrar, an IServiceRegistrar the engine discovers by assembly scan and constructs parameterlessly. It loads both assets from Resources, constructs CalendarService, calls Initialize() itself (the engine does not call Initialize/LateInitialize/Shutdown on externally registered services), and registers ICalendarService. Missing assets fail loud with a Debug.LogError and the service simply isn't registered.
  3. 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
  1. Schedule a save-safe callback and react to it going due (see Samples/Scripts/CalendarSample.cs for 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. ICalendarService adds phase-id overloads (ScheduleAt(id, day, phaseId), ScheduleInDays(id, daysFromNow, phaseId)) purely for ergonomics; they resolve to an index via TryGetPhaseIndex and throw ArgumentException for an unknown id.
  • Duplicate schedule ids replace, in place: calling ScheduleAt/ScheduleInDays with 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, and AdvanceToNextDay'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) → PhaseChangedEntryDue/CalendarScheduledDueEvent per due entry, in schedule order. A non-rollover advance fires only PhaseChanged and 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 the EntryDue callback / CalendarScheduledDueEvent, matching on id.
  • RestoreSnapshot clamps, doesn't throw: an out-of-range CurrentPhaseIndex (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.
  • LocString on a ScriptableObject has no core precedent to copy: LocString is sealed, has no parameterless constructor, and isn't [Serializable], so it cannot be a direct [SerializeField]. CalendarConfig.PhaseDefinition instead serializes the raw localization table name + key as plain strings and assembles the LocString on demand via a DisplayName property. This is an original Kit pattern, not a mirrored core one — no core config SO holds a LocString to 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>() in Initialize(), if a save system is available.
  • Subscribes to the domain's inherited BeforeSaveEvent/AfterLoadEvent (the sanctioned "subscribe to save notifications from external components" seam on PersistableData) — capturing a CalendarModel.CreateSnapshot() into the domain right before serialization, and calling CalendarModel.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).