Skip to content

Contracts (Contracts-lite)

Commissions with requirements, deadlines, and rewards: ContractDefinition (requirement predicates, deadline in calendar days, flag/item rewards) plus IContractService's offer → accept → evaluate → complete/expire lifecycle, save-integrated with a full event trail. Doc 07 scopes this module explicitly as "Contracts-lite" — the shape of a Pinakes Retrieval Request, nothing more. No penalties/reputation hooks, no standing orders, no chained contracts, no economy semantics (all deliberately deferred; see below).

What it is

  • ContractDefinition (ScriptableObject) — one commission's content: stable id, LocString title/description (raw table/key strings on the asset, same pattern as CalendarConfig.PhaseDefinition/ItemDefinition), requirement predicates, an optional deadline in days (0 = none), and rewards.
  • ContractCatalog (ScriptableObject) — the flat content asset: every ContractDefinition in the game, loaded from a Resources folder by the exact name "ContractCatalog" (same convention ItemDatabase/CalendarConfig use).
  • Headless core — ContractBook: a pure C# class (zero UnityEngine/engine references) that owns all contract semantics — the state machine, deterministic instance ids, requirement evaluation (via injected delegates), and deadline bookkeeping (plain day numbers). Fully unit-testable without Unity; portable if the Kit is ever spun out standalone.
  • Service layer — IContractService / ContractService: thin glue that wraps a ContractBook, adds a save domain (ContractsSaveData), forwards the book's callbacks as pooled Contract*Events, applies flag rewards through IWorldFlagService, and tracks the current day via CalendarDayStartedEvent.
  • The adapter seam — IContractItemCountProvider: the game-supplied item counter HasItem requirements evaluate through. See "The no-inventory decision" below — this is the module's most important design constraint.

There is no editor window for Contracts — a short instance list doesn't carry its own weight for a live inspector at this scope; revisit if a second consumer wants one.

Quickstart

  1. Create ContractDefinition assets (Create → LoLGameplayKit → Contracts → Contract Definition) and collect them in a ContractCatalog asset (same menu), placed in a Resources folder under the exact name ContractCatalog.
  2. Leave enableContracts on in your GameplayKitConfig asset (also in a Resources folder, named GameplayKitConfig). Registration is automatic: the module ships ContractsServiceRegistrar, an IServiceRegistrar the engine discovers by assembly scan and constructs parameterlessly. It loads both assets from Resources, constructs ContractService with whatever optional services are registered (IEventManager, ISaveSystem, IWorldFlagService, IItemDatabaseService, ICalendarService — all null-tolerant), calls Initialize() itself (the engine does not call Initialize/LateInitialize/Shutdown on externally registered services), and registers IContractService. Missing assets fail loud with a Debug.LogError and the service simply isn't registered.
  3. Plug in your item counting and drive the lifecycle from any game system:
var contracts = ServiceLocator.Instance.Get<IContractService>();
contracts.ItemCountProvider = myInventoryAdapter;   // IContractItemCountProvider — see below

string id = contracts.OfferContract("retrieve_ledger");  // "retrieve_ledger#1"
contracts.Accept(id);                                    // deadline = today + DeadlineDays

var result = contracts.TryComplete(id);
if (!result.Success)
{
    foreach (var req in result.UnmetRequirements) { /* show the player what's missing */ }
}
  1. Grant item rewards game-side from the completion event (see Samples/Scripts/ContractsSample.cs for the full listener pattern):
public class MySystem : MonoBehaviour, IEventListener<ContractCompletedEvent>
{
    void OnEnable()  => this.EventStartListening<ContractCompletedEvent>();
    void OnDisable() => this.EventStopListening<ContractCompletedEvent>();

    public void OnGameEvent(ContractCompletedEvent evt)
    {
        foreach (var reward in evt.ItemRewards)
            myInventory.Add(reward.ItemId, reward.Count);  // the module never grants items
    }
}

The no-inventory decision (read this first)

The Kit has no inventory module in v0.1.0, and Contracts refuses to pretend otherwise. Two consequences, both deliberate:

  • HasItem requirements count through an adapter. The game sets IContractService.ItemCountProvider (a one-method interface: int CountOf(string itemId)). While no provider is set, every HasItem requirement evaluates as unmet (the count reads as 0) — never a throw, never a silent pass. Completion attempts fail with the requirement in the unmet report, which is the honest answer when nobody can count items.
  • Item rewards are emitted, never granted. ContractCompletedEvent.ItemRewards carries the ids/counts; the game listens and grants them into its own inventory. The module does not (cannot) touch item state anywhere. Flag rewards, by contrast, ARE applied automatically — IWorldFlagService exists, so there is a real place to write them.

When a future inventory module lands (T2), the expected evolution is an engine-side IContractItemCountProvider implementation backed by it — the adapter seam stays, the game just stops having to write the adapter.

API overview

ContractBook (headless core)

Member Notes
Offer(definitionId, requirements, rewards, deadlineDays) Mints a deterministic instance id "{definitionId}#{counter}" (1-based, per-definition, never rewinds). Captures requirement/reward snapshots — see "locked-in terms" below.
Accept(id) Offered → Accepted; stamps DeadlineDay = CurrentDay + DeadlineDays (null when DeadlineDays is 0). False for any other state.
Decline(id) Removes an Offered/Accepted instance outright. False for Completed/Expired (terminal).
TryComplete(id, context) Accepted → Completed if every requirement holds; otherwise a ContractCompletionResult naming the unmet requirements. State-gated before evaluation.
NotifyDayAdvanced(day) Tells the book the current day; expires every Accepted instance whose DeadlineDay < day. Returns the expired ids.
Instances, TryGetInstance(id, out record) Every tracked instance (any state), in Offer order.
CreateSnapshot() / RestoreInstance(...) / Clear() The save-integration seam. Restore fires no callbacks and advances the id counter past restored ids.
event Action<ContractInstanceRecord> Offered/Accepted/Completed/Expired/Declined Plain C# callbacks — what ContractService bridges to pooled events.

Requirement evaluation goes through a ContractRequirementContext — four injected delegates (itemCountOf, plus bool/int/string flag readers matching IWorldFlagService.Get*'s never-throws (key, default) shape). The book never references a service.

IContractService wraps this surface (OfferContract(definitionId) resolves the definition from the catalog first; TryComplete(id) builds the context from the live services). The book 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 book instance or the book's C# callbacks; consumers react through the pooled Contract*Events.

Requirement predicates (the v0.1 seed — two kinds)

Kind Fields Met when
HasItem ItemId, ItemCount (≥1) itemCountOf(ItemId) >= ItemCount (inclusive).
FlagCheck / BoolIs FlagKey, ExpectedBool GetBool(key, false) == ExpectedBool. Note: BoolIs(false) is met by a missing flag — the never-throws read shape defaults to false.
FlagCheck / IntAtLeast FlagKey, ExpectedInt GetInt(key, 0) >= ExpectedInt.
FlagCheck / StringEquals FlagKey, ExpectedString GetString(key, "") == ExpectedString (ordinal).

No AND/OR trees, no negation, no expression language — a contract's requirement list is a flat conjunction (all must hold). That is exactly what a Retrieval Request needs; anything richer waits for a second real consumer (rule of two).

Edge semantics (all pinned by tests)

  • Instance ids are deterministic: "{definitionId}#{counter}", counter 1-based per definition, monotonically increasing for the life of the save — a declined instance's number is never reused, and a save-load advances the counter past every restored id. No Guid.NewGuid, no randomness anywhere in the module (the Kit's RNG/determinism contract).
  • Locked-in terms: Offer snapshots the definition's requirements/rewards/deadline onto the instance. Later edits to the ContractDefinition asset never retroactively change an already-offered instance — including across a save/load, because the snapshot (not a definition lookup) is what the save captures.
  • Deadlines start at Accept, not Offer: an un-accepted offer sits forever; DeadlineDay = CurrentDay + DeadlineDays is stamped by Accept.
  • Completing on the deadline day is allowed (inclusive): expiry fires only when a later day starts — NotifyDayAdvanced(day) expires instances with DeadlineDay < day, strictly. Nothing checks deadlines during TryComplete itself; an instance the day-advance already expired is rejected by the state gate.
  • Double-accept is a no-op returning false: Accept only succeeds from Offered, so it can never re-stamp (and thereby extend) a deadline.
  • Accept-after-expire is rejected: Expired is terminal; there is no path back into Accepted. Re-offering the definition (a fresh instance, fresh id) is the way to retry.
  • Decline removes, it doesn't park: there is no Declined state — the instance is deleted from the book. Completed/Expired instances do stay tracked (the game may want to display them); they cannot be declined.
  • Completion is one-shot: a Completed instance fails a second TryComplete at the state gate, so rewards can never double-fire.
  • Offer of an unknown definition id (service level): logged as an error, returns null, nothing is offered. The headless book itself accepts any non-empty definitionId — it has no catalog to check against.
  • Failed completion changes nothing: an unmet-requirements result leaves the instance Accepted, with the instance record on the result so a turn-in UI can show what's missing.

Save behavior

ContractsSaveData : GameplayKitSaveData is the module's save domain (DataId = "gameplaykit.contracts", SchemaVersion = 1) — a single Instances list of ContractInstanceRecord (the book's own plain-data record doubles as the save shape; no separate save-record type). ContractService:

  • Gets/registers the domain via ISaveSystem.GetData<ContractsSaveData>() in Initialize(), if a save system is available.
  • Subscribes to the domain's inherited BeforeSaveEvent/AfterLoadEvent (the sanctioned seam on PersistableData) — capturing ContractBook.CreateSnapshot() into the domain right before serialization, and rebuilding the book (Clear() + RestoreInstance per record, then re-reading the calendar's current day) right after deserialization.
  • Also restores immediately in Initialize(), in case the domain already held data.

Instances carries [JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)] — the engine's load path is JsonConvert.PopulateObject into the live registered instance, and Newtonsoft's default appends to a non-null list, duplicating every tracked contract on every load. Pinned by a regression test. The record's nested lists (Requirements, Rewards.FlagRewards/ItemRewards) carry the attribute too, per the Kit-wide convention: every List<T> reachable from a save domain gets Replace. On nested lists it is redundant today (the outer list's Replace forces fresh element construction on each load — also pinned by the same regression test), but it is uniform and keeps the trap closed if a nested record is ever hoisted to a top-level save field.

If ISaveSystem is not available, the service still works — instance state just doesn't persist. A warning is logged once at Initialize().

Events contract

ContractOfferedEvent, ContractAcceptedEvent (carries the resolved nullable DeadlineDay), ContractCompletedEvent (carries ItemRewards — emitted only, see above — and FlagRewardsApplied, the flag rewards the service actually wrote before triggering), and ContractExpiredEvent (all GameEvent) are dispatched via IEventManager.TriggerEvent — pooled, per the Kit's Events contract (GetEvent → fill → TriggerEvent → ReleaseEvent).

There is deliberately no ContractDeclinedEvent: decline is the player's own explicit, synchronous action on an instance that then ceases to exist — there is no cross-system state change for a decoupled listener to react to. The headless book does raise a C# Declined callback (symmetry for headless consumers/tests); if a second real consumer wants the pooled event, adding it is trivial (rule of two).

If IEventManager is not available, the service still works — transitions just don't raise Contract*Events. Note the knock-on: day tracking also arrives via the event manager (see next section), so no event manager also means inert deadlines.

Deadline tracking & the Calendar seam

ContractService deliberately has no hard Calendar coupling. It listens for the pooled CalendarDayStartedEvent on IEventManager (implementing IEventListener<CalendarDayStartedEvent>) and forwards evt.Day to ContractBook.NotifyDayAdvanced — plus it reads ICalendarService.CurrentDay directly exactly twice: once at Initialize() and once after each save-load (Calendar's own restore fires no change events — a load is a jump, not a transition — so the day event would never arrive for a load; re-reading closes that gap).

Degrade: with no ICalendarService registered (module disabled, or Contracts running without Calendar), deadlines are inert — the book's CurrentDay stays at its default of 1, nothing ever advances it, so Accept stamps deadlines that never come due. A warning is logged once at Initialize(). Offer/accept/decline/complete all work normally.

Deliberately deferred (per doc 07)

  • Penalties / reputation hooks on expiry or decline (T2 — expiry today just flips state and raises the event; what it costs the player is the game's business until then).
  • Standing orders / repeating contracts and chained contracts (T2).
  • Economy semantics — payment in currency, price haggling, market interaction (T3 full).
  • Requirement expression trees (AND/OR/NOT) — flat conjunction only until a second consumer.
  • A pooled ContractDeclinedEvent — see the events contract above.

Seam ledger

Per the Kit's portability discipline (doc 07, "Portability discipline"), every module's docs page lists exactly which engine surface it consumes. ContractBook and every runtime data type (ContractRequirement, ContractRewardSet/ContractFlagReward/ContractItemReward, ContractInstanceRecord, ContractCompletionResult, ContractRequirementContext) consume none — zero UnityEngine/LoLEngine.Core references. ContractService, ContractDefinition/ContractCatalog, and the registrar 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, IEventListener<T> LoLEngine.Core.Events.* Pooled Contract*Event dispatch, and listening for CalendarDayStartedEvent.
ISaveSystem, PersistableData (via GameplayKitSaveData) LoLEngine.Core.DataPersistence.* Save domain registration and the BeforeSaveEvent/AfterLoadEvent hooks.
LocString, LocTable LoLEngine.Core.Localization.LocString ContractDefinition.Title/Description.
ScriptableObject, [CreateAssetMenu], [SerializeField] UnityEngine ContractDefinition/ContractCatalog's content-asset shape.
UnityEngine.Resources UnityEngine ContractsServiceRegistrar loading GameplayKitConfig/ContractCatalog.
UnityEngine.Debug UnityEngine Degrade-gracefully warnings, unknown-definition errors, OnValidate issue logging.

Kit-sibling surfaces (module assemblies, not engine): IWorldFlagService (Flags) for FlagCheck reads + flag-reward writes; IItemDatabaseService (Items) for the one-time Initialize-time content sanity pass (unknown item ids in requirements/rewards log warnings — evaluation itself never touches the database); ICalendarService + CalendarDayStartedEvent (Calendar) for day tracking, per the section above. All three optional.

ContractsSample (the sample script, not part of the module's runtime surface) additionally consumes UnityEngine.Input/MonoBehaviour/KeyCode for its keyboard test controls — expected for a consumer-facing sample, out of scope for the portability discipline.