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 asCalendarConfig.PhaseDefinition/ItemDefinition), requirement predicates, an optional deadline in days (0 = none), and rewards.ContractCatalog(ScriptableObject) — the flat content asset: everyContractDefinitionin the game, loaded from aResourcesfolder by the exact name"ContractCatalog"(same conventionItemDatabase/CalendarConfiguse).- Headless core —
ContractBook: a pure C# class (zeroUnityEngine/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 aContractBook, adds a save domain (ContractsSaveData), forwards the book's callbacks as pooledContract*Events, applies flag rewards throughIWorldFlagService, and tracks the current day viaCalendarDayStartedEvent. - 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¶
- Create
ContractDefinitionassets (Create → LoLGameplayKit → Contracts → Contract Definition) and collect them in aContractCatalogasset (same menu), placed in aResourcesfolder under the exact nameContractCatalog. - Leave
enableContractson in yourGameplayKitConfigasset (also in aResourcesfolder, namedGameplayKitConfig). Registration is automatic: the module shipsContractsServiceRegistrar, anIServiceRegistrarthe engine discovers by assembly scan and constructs parameterlessly. It loads both assets fromResources, constructsContractServicewith whatever optional services are registered (IEventManager,ISaveSystem,IWorldFlagService,IItemDatabaseService,ICalendarService— all null-tolerant), callsInitialize()itself (the engine does not callInitialize/LateInitialize/Shutdownon externally registered services), and registersIContractService. Missing assets fail loud with aDebug.LogErrorand the service simply isn't registered. - 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 */ }
}
- Grant item rewards game-side from the completion event (see
Samples/Scripts/ContractsSample.csfor 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.ItemRewardscarries 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 —IWorldFlagServiceexists, 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. NoGuid.NewGuid, no randomness anywhere in the module (the Kit's RNG/determinism contract). - Locked-in terms:
Offersnapshots the definition's requirements/rewards/deadline onto the instance. Later edits to theContractDefinitionasset 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 + DeadlineDaysis stamped byAccept. - Completing on the deadline day is allowed (inclusive): expiry fires only when a later
day starts —
NotifyDayAdvanced(day)expires instances withDeadlineDay < day, strictly. Nothing checks deadlines duringTryCompleteitself; an instance the day-advance already expired is rejected by the state gate. - Double-accept is a no-op returning false:
Acceptonly 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
Declinedstate — 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
TryCompleteat 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>()inInitialize(), if a save system is available. - Subscribes to the domain's inherited
BeforeSaveEvent/AfterLoadEvent(the sanctioned seam onPersistableData) — capturingContractBook.CreateSnapshot()into the domain right before serialization, and rebuilding the book (Clear()+RestoreInstanceper 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.