Quests
Accept/track/turn-in over game-defined objectives; catalog/policy/reward are game SPIs.
Seams you implement 3
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayQuests(); and it wins.
IQuestCatalog game SPI
SPI the GAME implements to define its quests — the source of every quest id, its repeatable flag, and its objectives. The framework ships no quests; it only tracks progress against the definitions this catalog returns.
public sealed class MyCatalog : IQuestCatalog
{
private readonly Dictionary<ushort, QuestDefinition> _byId = new()
{
[1] = new QuestDefinition(1, repeatable: false,
new QuestObjectiveDefinition("kill_wolf", 5),
new QuestObjectiveDefinition("collect_pelt", 5)),
};
public QuestDefinition? Get(ushort questId) => _byId.GetValueOrDefault(questId);
} -
IEnumerable<QuestDefinition> All { get; }Every quest the catalog defines - only read to find AutoAccept quests. Defaulted to none so adding it breaks no existing catalog (rule 25); implement it if you use AutoAccept.
-
QuestDefinition Get(ushort questId)Resolves a quest definition by id.
IQuestPolicy game SPI
SPI the GAME implements to gate quest ACCEPTANCE — prerequisites, level gates, faction checks, "one per chain" rules. Called after the framework's own checks (unknown id, already active, log full) pass, so implement only game rules here. Returning false refuses the accept (the client gets an AcceptRefused verdict).
public sealed class MyPolicy : IQuestPolicy
{
public bool CanAccept(ISession session, long characterId, QuestDefinition quest)
=> quest.Id != 42 || PlayerLevelOf(characterId) >= 10; // gate quest 42 behind level 10
} -
bool CanAccept(ISession session, long characterId, QuestDefinition quest)Approve (or veto) a character accepting a quest.
IQuestRewardGrantor game SPI
Optional SPI the GAME implements to decide what a turn-in GRANTS. Called once per turn-in; the returned opaque blob rides the QuestTurnInResponse for the client to render or apply.
public sealed class MyGrantor : IQuestRewardGrantor
{
public byte[] Grant(long characterId, ushort questId)
{
// Apply rewards server-side, and/or return a blob the client renders.
return System.Array.Empty<byte>();
}
} -
byte[] Grant(long characterId, ushort questId)Produces the opaque reward payload for one turn-in (called AFTER the turn-in is recorded).
Providers you can swap 1
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IQuestStore provider
Persistence seam for quest state. Default InMemoryQuestStore keeps it in RAM (dev/tests); a document-backed override (via AddCrossplayPersistentStores) survives restarts. Register your own with services.AddSingleton<IQuestStore, MyStore>() before AddCrossplayQuests.
-
QuestState Get(long characterId)Loads a character's active + completed quests, or null if the character has none yet.
-
void Save(QuestState state)Persists a character's full quest state (called after every accept/report/turn-in that changes it).
Services you call 2
Crossplay implements these. Resolve them from DI and call them from your own systems.
IQuestClock
Wall clock for the repeat windows. A seam purely so a test can cross a UTC day boundary without waiting for one — the windows are the only part of Quests that depends on real time, and a daily reset is exactly the kind of rule that is never exercised if it can only be tested by waiting.
-
long UtcNowUnixMs { get; }Milliseconds since the Unix epoch, UTC.
IQuestService
Server-side quests API — accept / track / turn-in over the game's catalog. Clients send accept, log, and turn-in requests; objective PROGRESS is SERVER-REPORTED (Report) with no wire mutation path, so it can never be client-forged. Resolve it from DI: [Inject] IQuestService quests.
-
event Action<long, ushort> AbandonedRaised when a quest is abandoned, with (characterId, questId). Defaulted (never raised).
-
IReadOnlyList<ushort> AcceptAvailable(long characterId)Accepts every AutoAccept quest the character can take now (call it at character creation or login to start chains). Returns the quest ids accepted. Defaulted (accepts nothing).
-
event Action<long, ushort> AcceptedRaised when a quest is accepted, with (characterId, questId) - by a player or by the server. Lets the game tell a player about an auto-accepted quest. Defaulted (never raised).
-
QuestLog LogOf(ISession session)Builds the quest-log snapshot (active quests + completed quest ids) for the session's character.
-
QuestLog LogOf(long characterId)A character's quest log, with no session. Defaulted so adding it breaks no existing implementation.
-
void Report(ISession session, string counterKey, long delta = 1)Advances matching objectives on the character's ACTIVE quests. Server game-code only — no-op when delta ≤ 0, the key is empty, or the session has no selected character.
-
void Report(long characterId, string counterKey, long delta = 1)Advances matching objectives for a character with NO session - progress that happens while the player is away (a timer completes, an army arrives). Pushed to the character's session when it is online. Same rules as the session overload. Defaulted so adding it breaks no existing implementation (rule 25).
-
bool TryAbandon(long characterId, ushort questId, out ushort reason)Drops an active quest from a character's log - its progress and window reports go, and no turn-in is recorded (it counts neither as a completion nor for the repeat window, unless the quest sets AbandonStartsRepeatWindow). Defaulted so adding it breaks no existing implementation.
-
bool TryAbandon(ISession session, ushort questId, out ushort reason)Drops an active quest for the session's character (see the character-id overload).
-
bool TryAccept(ISession session, ushort questId, out ushort reason)Attempts to accept a quest for the session's selected character, running the framework checks (known id, not already active, log not full) then the game's IQuestPolicy.
-
bool TryAccept(long characterId, ushort questId, out ushort reason)Accepts a quest for a character from server code, with no session. The framework checks apply (known id, not active, log size, repeat window, prerequisites); the game's IQuestPolicy is NOT asked - server code is the authority. Defaulted so adding it breaks no existing implementation (rule 25).
-
bool TryTurnIn(ISession session, ushort questId, out byte[] rewardData, out ushort reason)Attempts to turn in a completed active quest, granting its reward and recording completion.
-
bool TryTurnIn(long characterId, ushort questId, out byte[] rewardData, out ushort reason)Turns in a completed quest for a character from server code, with no session (the reward grantor runs as usual). Defaulted so adding it breaks no existing implementation (rule 25).
-
event Action<long, ushort> TurnedInRaised once per turn-in, with (characterId, questId). Server-side hook for game/composition bridges (chain quests, mirror to another piece).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
QuestOptions
Quests tuning (defaults here, never inline).
-
int MaxActiveQuests { get; set; }Active quests per character (0 = unlimited).
-
bool PushProgress { get; set; }Push QuestProgress to the session as objectives advance.
Wire messages 7
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
QuestLogRequest Client → server: "send me my quest log." Carries no fields — the server resolves the requester's selected character from the session. Reply is a QuestLog (or a NotEligible notification when no character is selected).
QuestLog Server → client: the quest-log snapshot — active quests + already turned-in quest ids. The reply to a QuestLogRequest.
QuestAcceptRequest Client → server: "accept this quest from the game's catalog." The server validates the id and runs the game's accept policy, then replies with a QuestAcceptResponse.
QuestAcceptResponse Server → client: the accept verdict — the reply to a QuestAcceptRequest.
QuestTurnInRequest Client → server: "turn in this completed quest." The server verifies the quest is active and complete, then replies with a QuestTurnInResponse.
QuestTurnInResponse Server → client: the turn-in verdict — the reply to a QuestTurnInRequest. RewardData is the game grantor's opaque blob — the framework never knows what a reward IS.
QuestProgress Server → client: pushed when an active quest's objective advances (unsolicited, driven by a server-side report).
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IQuestClient
The Quests piece, client side: log/accept/turn-in plus live objective pushes. Quest ids and reward blobs are the GAME's vocabulary — this client never interprets them.
-
UniTask<QuestLog> LogAsync(CancellationToken ct = default)Requests the quest-log snapshot.
-
UniTask<QuestAcceptResponse> AcceptAsync(ushort questId, CancellationToken ct = default)Accepts a quest (refusals carry the reason code).
-
UniTask<QuestTurnInResponse> TurnInAsync(ushort questId, CancellationToken ct = default)Turns in a completed quest (the response carries the game's opaque reward).
-
event Action<QuestProgress> ProgressChangedRaised as active-quest objectives advance.
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayQuestsView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.