quiver_quest 0.1.0
Quests for Starknet games: tasks, intervals, prerequisites, claim. No Dojo.
Quests for Starknet games: tasks, intervals, prerequisites, acceptance, claim. Pure Cairo and Starknet, no Dojo. The accepted API is ARC-01 §3, as amended by D-135.
A quest has:
A player accepts a quest before it counts, and holds at most MAX_HELD = 4 quests at once.
The game reports progress on tasks. The component counts it on the quests the player holds,
completes them, and lets the player claim them. Rewards are the game's: it grants them in its
hooks.
quiver_quest::logic
The pure library: types, their packing into one felt each, functions. State in, state out, no storage
quiver_quest::component::QuestComponent
The Starknet component: storage, events, hooks, the trusted internal layer, the optional external ABI
quiver_quest::interface
IQuest and IQuestView, with their dispatchers
quiver_quest::errors, quiver_quest::constants
The error strings (API) and the bounds
Embed the component, implement QuestHooksTrait, and choose what to expose. This consumer, like
Grim World's (ARC-01 §3.8), exposes only the views and calls the internal layer from its own
entrypoints, after its own checks:
#[starknet::contract]
mod Game {
use quiver_quest::component::QuestComponent;
use quiver_quest::logic::{Mode, TaskProgress};
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, get_caller_address};
component!(path: QuestComponent, storage: quest, event: QuestEvent);
#[abi(embed_v0)]
impl QuestViewImpl = QuestComponent::QuestViewImpl<ContractState>;
impl QuestInternalImpl = QuestComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
quest: QuestComponent::Storage,
results: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
QuestEvent: QuestComponent::Event,
}
impl QuestHooks of QuestComponent::QuestHooksTrait<ContractState> {
// Used by the external QuestImpl only, which this consumer does not embed
fn authorize_admin(
self: @QuestComponent::ComponentState<ContractState>, caller: ContractAddress,
) -> bool {
false
}
fn authorize_player(
self: @QuestComponent::ComponentState<ContractState>,
caller: ContractAddress,
player_id: felt252,
) -> bool {
false
}
fn on_quest_complete(
ref self: QuestComponent::ComponentState<ContractState>,
player_id: felt252, quest_id: u32, interval_id: u64, completions: u64,
) { // e.g. count completions for a title: self.get_contract_mut()
}
fn on_quest_claim(
ref self: QuestComponent::ComponentState<ContractState>,
player_id: felt252, quest_id: u32, interval_id: u64, claim_index: u64,
) { // grant the rewards; diminish them with claim_index; panic to refuse
}
}
#[external(v0)]
fn submit_results(ref self: ContractState, player_id: felt252, progress: Span<TaskProgress>) {
assert(get_caller_address() == self.results.read(), 'not results');
// Aggregated by task id, at most MAX_ENTRIES entries: one call per player per transaction
self.quest.progress_many(player_id, progress, Mode::Storage);
}
#[external(v0)]
fn accept_quest(ref self: ContractState, player_id: felt252, quest_id: u32) {
// The game's checks first: the caller owns player_id, the quest is on today's board, ...
// Refused when the player already holds MAX_HELD live quests ('Quest: too many held')
self.quest.accept(player_id, quest_id);
}
#[external(v0)]
fn claim_quest(ref self: ContractState, player_id: felt252, quest_id: u32, interval_id: u64) {
// The game's checks first: the caller owns player_id, the place allows a claim, ...
self.quest.claim(player_id, quest_id, interval_id);
}
}
A consumer that wants the access-checked ABI embeds QuestImpl as well:
#[abi(embed_v0)]
impl QuestImpl = QuestComponent::QuestImpl<ContractState>;
QuestImpl (external)
Through InternalImpl
define, retire, set_reporter
authorize_admin(caller), or 'Quest: not admin'
Nothing is checked
progress, progress_many
A registered reporter (set_reporter), or 'Quest: not reporter'
Nothing is checked
accept, abandon, claim
authorize_player(caller, player_id), or 'Quest: not authorized'
Nothing is checked
The internal layer is trusted. InternalImpl checks no caller: it is for the consumer's own
entrypoints, which make their own checks first. It is not reachable from outside unless the
consumer writes an entrypoint that calls it; embedding QuestViewImpl alone exposes only views.
The consumer decides who its admin is and who owns a player_id, in authorize_admin and
authorize_player. assert_reporter(caller) is there for a consumer that wants the reporter
registry on its own entrypoints.
Hooks run after the state is written: on_quest_complete once per completion, with
completions (1 for the first), and on_quest_claim once per claim, with claim_index (0 for
the first claim of that quest by that player). A hook that panics reverts the whole call: that is
how a consumer refuses a claim.
A hook may re-enter the component, since the state is written first.
progress is not completed again by the outer call.claim of the claim being made, or accept of the quest just completed, is
refused ('Quest: already claimed', 'Quest: already completed'). That reverts the whole
outer call.progress and progress_many take a Mode, per call.
Mode::Storage
Mode::Event
Reads, writes
The player's held list; each held quest's progress and record read and written at most once
None
Events
QuestCompleted per completion
QuestProgressed { player_id, task_id, count } per merged, non-zero entry
Hooks
on_quest_complete
None
Windows, intervals, prerequisites, acceptance
Enforced
Not enforced: the indexer applies them from QuestDefined and its own record of acceptances (accept emits no event)
Completion, claim, views
Yes
No: quest_progress stays zero, a claim reverts 'Quest: not completed'
Feed a quest in one mode only: progress in one mode is invisible to the other. Definitions are always stored and emitted.
Each progress and record is written at most once per call. For that to hold per transaction,
the consumer aggregates its results by task id, one entry per task, and calls progress_many
once per player per transaction. More than MAX_ENTRIES entries revert
('Quest: too many entries'), duplicates included: there is no fallback to a second call. The
package cannot see across calls; a second call in the same transaction is the consumer's error.
Progress never reverts for a quest-level reason. A held quest that is retired, outside the interval of its acceptance, or already completed in the interval is skipped; a quest not held is not read. Counts saturate at each task's total.
A schedule is start, end (0 = never), duration and interval in seconds. One-off:
duration = interval = 0, interval id 0. Recurring: 0 < duration <= interval; the quest is
active for duration seconds at the start of each interval, and its interval id is
(time - start) / interval, a u64.
Intervals are aligned on start, not on a calendar. A daily quest (duration = interval = 86 400) rolls over at 00:00 UTC only when start is a multiple of 86 400; the package does
not check it. Progress, completion and claim are per interval. An acceptance holds only in the
interval in which it was made: an unfinished acceptance is lost at rollover, with the progress
of that interval, and the quest must be accepted again.
Acceptance. Every quest needs acceptance: progress counts only on the quests a player holds.
accept refuses, in this order:
'Quest: does not exist', 'Quest: retired';'Quest: not active', outside the schedule;'Quest: locked', prerequisites not met;'Quest: already accepted', held and live in this interval;'Quest: already completed', this interval;'Quest: too many held', MAX_HELD live quests held.An acceptance ends at completion, at abandon, at retirement, or at rollover (A-11).
The held list. A player's list holds at most MAX_HELD = 4 live quests, two per storage
slot, in the order of acceptance.
quest_held(player_id) returns it, dead entries included; quest_is_accepted tells whether an
entry is live.accept, which prunes every dead entry before it counts the room
left. Progress never writes the list.abandon removes the quest, and the later entries move up.Prerequisites. A quest with conditions is unlocked when each of them has been completed at least once, at any time, before or after the quest was defined.
accept checks the prerequisites and caches the unlock in the player's record. Progress does
not read them.quest_is_unlocked evaluates them without writing.Retirement. retire is refused while a live quest names the quest as a condition ('Quest: has live dependents'): retire dependents first. A retired quest:
Any number of quests may use a task: tasks have no cap and no index. Only the quests a player holds cost anything on progress.
Every loop is bounded:
Bound Value BoundsMAX_TASKS
3
Tasks per quest; unrolled
MAX_CONDITIONS
7
Prerequisites per quest: records read by accept to evaluate them, dependents' counters updated by define and retire
MAX_ENTRIES
16
Entries of one progress_many call, checked first; the merge makes at most 16² comparisons
MAX_HELD
4
Live quests a player holds ('Quest: too many held' above): the quests one progress call can count and complete
MAX_HELD_LIMIT, HELD_SLOTS
8, 4
What the held list's layout holds: 4 slots of 2 entries. The walk of the list reads at most 4 slots, whatever MAX_HELD is: the code works for any MAX_HELD up to 8
live dependents
65 535
Live quests naming one quest as a condition ('Quest: too many dependents')
The network's limit. A Starknet transaction may use at most 1.1 × 10⁹ L2 gas ("Max L2 gas per transaction", docs.starknet.io, Learn > Cheatsheets > Chain info, https://docs.starknet.io/learn/cheatsheets/chain-info, read on 2026-09-28; the page gives it for Mainnet 0.14.2 and Sepolia 0.14.3, and 6 × 10⁹ per block). The project's own cap is far lower: the worst call the package allows must stay under 20 × 10⁶ L2 gas (A-G1 amendment). For scale, Grim World's worst tick is 5.1 × 10⁶ as a whole transaction.
The package's worst call. It is progress_many with 16 entries (the slowest merge), every
held quest completing, each quest with 3 tasks. Measured through a dispatcher with snforge
(GAS.md). The network's estimate reprices
each written slot at the prices below. "Created" means the player's progress and record slots
are new (the worst); "existing" means they are overwritten.
MAX_HELD = 4, hooks empty
6 213 063 / 6 168 215
2 997 063 / 2 796 215
MAX_HELD = 4, on_quest_complete writing one new slot
8 027 983 / 7 960 711
4 811 983 / 4 588 711
8 held (the layout's limit), hooks empty
11 430 213 / 11 340 517
4 998 213 / 4 596 517
8 held, on_quest_complete writing one new slot
15 060 053 / 14 925 509
8 628 053 / 8 181 509
Grim World's use: 16 entries, 3 quests and a daily contract completing, 0 to 2 prerequisites
4 553 406 / 4 469 558 (6 slots created, 2 overwritten)
—
Each held quest adds at most 1.31 × 10⁶ L2 gas. Most of that is its two storage writes, its progress and its record. A written slot costs, per transaction:
Written slot snforge (the figures above) The network (the game's FND-04, 149 Sepolia transactions) Created: zero before, non-zero after 459 106 about 453 500 Overwritten, zeroed or unchanged 57 106 about 32 000The worst call creates both slots of each quest: its first count in the interval and its first
completion. A quest whose slots exist already costs about 0.8 × 10⁶ less. The quests a player
does not hold cost nothing, however many share the reported tasks. The worst call is a property
of MAX_HELD, not of how many quests use a task.
The held list is never zeroed. A slot of the held list keeps a marker once it has held an
entry, so the list growing back into it overwrites the slot instead of creating it: 712 750
instead of 1 084 240 for that accept.
The one slot the package zeroes is a reporter's, when set_reporter(reporter, false) revokes it.
Other entrypoints, at their worst (snforge / network):
Entrypoint L2 gasaccept (7 prerequisites checked; creates a never-used list slot and the record)
1 921 540 / 1 885 222
abandon
574 060 / 523 848
claim
364 020 / 313 808
The consumer's transaction must fit. The whole transaction counts: the consumer's own
entrypoint and logic, the package's calls, the hooks (on_quest_complete runs once per completed
quest, so its cost multiplies with them), and the account's validation and execution. A consumer
measures its own worst transaction, and in particular the cost of its on_quest_complete times
MAX_HELD.
The reporter check in event mode. The external progress and progress_many of QuestImpl
read the reporter registry once (one storage read), in Mode::Event too, before emitting. Called
through the internal layer, event mode reads and writes nothing.
quiver_quest::logic is the pure library, without storage: state in, state out
(ARC-01 §3.2). It holds:
Mode, QuestSchedule, QuestTask, QuestDefinition, QuestTasks,
QuestConditions, QuestProgress, QuestRecord, QuestHeld, QuestHeldSlot and
TaskProgress;StorePacking<T, felt252>, layouts of §3.3);Error strings are in quiver_quest::errors.
Every loop is bounded: batch_merge by MAX_ENTRIES (checked first; at most MAX_ENTRIES²
comparisons when a task id repeats or two ids are equal modulo 128, one pass otherwise); the lookups of a merged batch
(batch_count_of, batch_first_position, progress_add) by MAX_ENTRIES; the condition checks
of definition_new by MAX_CONDITIONS (checked first); prerequisites_met by the one record per
condition the caller passes, MAX_CONDITIONS; the held list's functions by the entries of the
list, MAX_HELD_LIMIT. Tasks are unrolled, without loops.
Every test has a budget; the figures are in GAS.md: the library's benchmarks in
test_bench, the component's in test_component_bench, one per entrypoint on the worst case of
ARC-01 §5.1; the grid over the held list in test_component_grid; Grim World's case in
test_component_game. Per entrypoint, the measure and the budget are in
docs/BUDGETS.md.
Version 0.1.0
Uploaded 17 hours ago
License MIT
Cairo version ^2.19.0
Size 72.1 KB
Run the following command in your project dir
scarb add quiver_quest@0.1.0
Or add the following line to your Scarb.toml
quiver_quest = "0.1.0"