Core Crate

Summary

Implement the shared public Rust crate (libs/core) with data models, validation, scoring profiles, sync protocol, and offline queue management. This crate ships to all consumers — server, dashboard, PWA, iOS, Android. Everything here is client-safe and can be decompiled — no secrets, no server-only logic.

Requirements

  • All entity structs matching Data Model: User, Org, OrgMembership, ScoringProfile, Zone, Drill, DrillTarget, DrillStep, Course, CourseDrill, Location, Event, EventCourse, EventDrill, Roster, Score, Friendship, Challenge, Achievement, UserAchievement, RangeBagItem, Favorite
  • Scoring profile system: built-in profiles (USPSA, IDPA, IPSC, Steel Challenge), five calculation methods (hit_factor, time_plus, points_only, time_only, pass_fail), zone definitions with default points
  • Score calculation functions for each calculation method
  • Final-score filtering: given a vec of scores, return only current (non-voided, non-replaced) scores
  • Validation functions for all entities (field presence, format, enum ranges, referential constraints that can be checked without DB)
  • Offline score queue: append scores, persist to local storage, flush to server, handle ack/reject, deduplicate by idempotency key
  • UUID v7 generation and parsing
  • Protobuf serialization/deserialization for all wire types (via prost, generated from proto/) — added after proto-definitions is complete, not in initial implementation
  • Enums for all status/type/role/visibility/calculation fields
  • No dependencies on server-only crates, no network I/O, no database access

Context

This is the single source of truth for business logic across all platforms. The server imports it directly. Dashboard and PWA compile it to WASM. iOS and Android consume it via UniFFI bindings. Any logic in core runs identically everywhere — validation, scoring, offline queue management, final-score filtering.

See Shared Rust Core for the public/private boundary and Data Model for entity definitions.

Details

Entity Models

All models use UUID v7 for IDs. Timestamps are UTC. Models derive Clone, Debug, PartialEq. Protobuf serialization is handled by prost-generated types — core models convert to/from proto types.

Drill (expanded from Data Model)

The Drill struct includes fields from the legacy system that the Data Model doc abbreviates:

struct Drill {
    id: Uuid,
    org_id: Uuid,
    owner_id: Uuid,
    visibility: Visibility,         // Private | Public | Published
    source_drill_id: Option<Uuid>,  // copy provenance
    scoring_profile_id: Uuid,
    name: String,
    drill_type: DrillType,          // LiveFire | DryFire
    description: String,
    par_time: Option<f64>,          // seconds
    total_rounds: Option<i32>,
    possible_points: Option<i32>,   // max achievable score
    zone_overrides: Vec<ZoneOverride>,
    targets: Vec<DrillTarget>,      // target descriptions
    steps: Vec<DrillStep>,          // ordered instructions
    category: Option<String>,       // e.g. "Pistol", "Rifle", "Shotgun"
    stage_layout: Option<Value>,    // opaque JSON blob — legacy stage layouts, future 3D builder
    is_active: bool,                // publish flag (inactive = hidden from browse)
    created_at: DateTime,
    updated_at: DateTime,
}

DrillTarget and DrillStep are structured sub-objects stored as JSON in Postgres:

struct DrillTarget {
    name: String,                   // e.g. "T1", "Steel Plate A"
    description: Option<String>,
}
 
struct DrillStep {
    position: i32,
    instruction: String,            // e.g. "Draw and engage T1 with 2 rounds"
}

Dry fire drills use the same struct — drill_type: DryFire, typically total_rounds: None, par_time set to recommended par. No separate dry fire model.

Score (expanded from Data Model)

struct Score {
    id: Uuid,
    event_id: Option<Uuid>,         // null for personal practice / dry fire
    drill_id: Uuid,
    shooter_id: Uuid,
    recorded_by_id: Uuid,
    replaces_score_id: Option<Uuid>,
    voided: bool,
    // Snapshots (immutable at creation time)
    drill_name: String,
    drill_type: DrillType,
    scoring_profile_name: String,
    calculation: CalculationMethod,
    // Results
    zone_breakdown: Vec<ZoneCount>,  // per-zone hit counts
    time: Option<f64>,               // seconds
    points: Option<i32>,
    hit_factor: Option<f64>,         // computed: points / time
    penalties: Option<i32>,
    pass: Option<bool>,              // for pass_fail only
    notes: Option<String>,           // free-text RO/shooter notes
    idempotency_key: Uuid,           // client-generated, for dedup
    timestamp: DateTime,             // when the score was recorded on-device
    created_at: DateTime,            // when the server received it
}
 
struct ZoneCount {
    zone_name: String,               // e.g. "Alpha", "Charlie"
    hits: i32,
}

Key decisions reflected here:

  • notes field added — legacy supports free-text notes per score, and it’s useful for RO comments.
  • event_id is optional — allows personal practice scores and dry fire sessions outside events.
  • idempotency_key — client-generated UUID sent with every score submission, used for dedup on flush.
  • Hit factor is computed and stored — no separate hit_factors table. The Score record is the source of truth. Historical hit factor queries just filter scores.

Multi-Shooter Drills

Multi-shooter drills (e.g. Bill Drill with 4 shooters) produce one Score per shooter per drill. There is no multi-shooter Score entity. The legacy completed_multi_shooter_drills.shooter_results JSON array explodes into N individual Score rows, each with the same event_id + drill_id but different shooter_id.

This keeps the Score model flat and queryable — leaderboards, history, and aggregations work on individual Score records without JSON parsing.

Course-Level Results

Courses are bundles of drills. There is no separate CourseResult entity. When a shooter completes a course at an event, they produce one Score per drill in the course. Course-level aggregation (total points, total time, overall hit factor) is computed by querying scores for a given event_id + shooter_id where drill_id IN (drills in course).

The legacy completed_custom_courses.course_results JSON array maps to individual Score rows — one per stage/drill.

Dry Fire Sessions

Dry fire uses the same Score model with drill_type: DryFire and event_id: None. A dry fire practice session is just a sequence of Score records for dry fire drills, recorded by the shooter themselves (recorded_by_id == shooter_id).

The par timer is a client-side UI feature (mobile-dry-fire), not a core model concern. Core just stores the result.

Scoring

Calculation Functions

fn calculate_hit_factor(points: i32, time: f64) -> f64;
fn calculate_time_plus(raw_time: f64, penalties: i32, penalty_seconds: f64) -> f64;
fn calculate_points_only(zone_breakdown: &[ZoneCount], profile: &ScoringProfile, overrides: &[ZoneOverride]) -> i32;
fn calculate_time_only(time: f64) -> f64;
fn calculate_pass_fail(points: i32, min_points: i32, time: Option<f64>, max_time: Option<f64>) -> bool;

Each function is pure — no side effects, no DB access. The calculate_score dispatcher takes a Score + ScoringProfile and calls the right method.

Built-in Profiles

Seeded as constants in core (not loaded from DB). The server writes them to DB on first migration, but core has the canonical definitions for client-side use:

ProfileCalculationZones
USPSAhit_factorAlpha(5), Charlie(3), Delta(1), Miss(0), No-Shoot(-10)
IDPAtime_plusDown Zero(0), Down One(+1s), Down Three(+3s), Miss(+5s)
IPSChit_factorAlpha(5), Charlie(3), Delta(1), Miss(-10), No-Shoot(-10)
Steel Challengetime_onlyHit(0), Miss(+3s)

Final-Score Filtering

fn final_scores(scores: &[Score]) -> Vec<&Score>;

Returns scores that are not voided and have not been replaced. Same logic runs on server (SQL WHERE clause), WASM, and native — see Data Model “Querying Final Scores”.

Offline Queue

The offline queue is a client-side ordered list of Score records waiting to be sent to the server.

struct OfflineQueue {
    scores: Vec<Score>,
}
 
impl OfflineQueue {
    fn enqueue(&mut self, score: Score);
    fn flush_batch(&self) -> Vec<Score>;  // returns next batch to send
    fn ack(&mut self, idempotency_keys: &[Uuid]);  // remove acknowledged scores
    fn reject(&mut self, idempotency_key: Uuid, reason: String);  // mark as failed
    fn pending_count(&self) -> usize;
}

Queue persistence (writing to IndexedDB in WASM, SQLite on mobile) is handled by platform-specific adapters outside core. Core defines the queue operations and data structure — callers provide the storage.

Deduplication: the server uses idempotency_key to detect duplicate submissions. If a score is already stored, the server responds with success (idempotent). Core’s ack removes it from the queue either way.

Validation

Validation functions return a Vec<ValidationError> (empty = valid). They check:

  • Required fields present and non-empty
  • Enum values in valid range
  • Numeric ranges (points >= 0, time > 0)
  • Zone breakdown zones exist in the referenced scoring profile
  • UUID format
  • Date ordering (start < end, created updated)

Validation does NOT check referential integrity against a database (that’s the server’s job). It checks what can be checked locally.

Open Questions

  • Designated target drills (Bill Drill pattern) — is this a drill category, a tag, or does it need a model-level concept? For now treating it as just a drill with specific target/step configuration.
  • Custom stage layout — legacy has custom_stage JSON for user-drawn layouts. Resolved: defer to post-MVP 3D drill builder (Bevy). Add stage_layout: Option<serde_json::Value> to Drill as an opaque blob for migration data preservation, but no MVP UI for editing it.
  • Media attachment on Score — legacy allows one image + one video per completed drill. Add media_ids: Vec<Uuid> to Score, or keep media as a separate entity linked by score_id? Leaning toward separate entity (media-api owns this).
  • Loadout / required items — legacy drills have a loadout list (gear needed). Add required_items: Vec<String> to Drill, or link to RangeBagItem?