Skip to content

Dealership Package Groups

Package groups are the dealership-facing layer: they define the purchasable offerings a player sees at a dealership. Each group is a labeled list of Packages, and each package bundles a pre-baked configuration with a set of customization knobs (frame, drivetrain, rim, paint, …).

Every vehicle is offered through package groups — they are the single catalog-entry shape the dealership uses. A vehicle's package groups are either authored (a <vehicle_key>.luau module under DealershipPackageGroups/, as the assembly-v1 Sierra HT family does) or, when no module is authored, auto-generated from the vehicle's on-sale trims (see autoGeneratePackageGroup.luau). Auto-generation is the default for legacy vehicles and reproduces the old trim-based listing (one package per on-sale trim, with paint + rim customizations). The model is "auto-generated until hand-authored, per vehicle" — authoring a module for a vehicle key replaces its auto-generated group. Authoring is independent of AssemblySystem; a legacy vehicle can be authored and an assembly vehicle can be auto-generated. Generated package data may contain placeholders where standardized vehicle-developer input does not yet provide final product metadata such as prices, images, display names, or vehicle ids. The goal is to move those placeholders into an easy, standardized authoring input instead of hand-editing generated output.

Where package groups live

src/Shared/VehicleDef/DealershipPackageGroups/
├── init.luau                       -- Loader, indexes groups by vehicleKey/id
└── <vehicle_key>.luau              -- One file per vehicle, returns { PackageGroup }

The loader auto-requires every sibling module, treating its filename as the vehicleKey. The module must return an array of PackageGroups produced via Types.package_group(...).

Helpers exposed by the loader (init.luau):

  • DealershipPackageGroups.getPackageGroup(vehicleKey, packageGroupId)
  • DealershipPackageGroups.getPackage(vehicleKey, packageGroupId, packageId)
  • DealershipPackageGroups.PackageGroups — the full { [vehicleKey]: { [groupId]: PackageGroup } } table

Schema

All types live in src/Shared/VehicleDef/Types.luau. Quick tour:

PackageGroup

{
    id: string,                 -- e.g. "stock", "stakebed"
    display_name: string,       -- UI label, e.g. "Stakebed"
    image: string,              -- UI image reference
    packages: { Package },      -- The packages visible inside the group
}

Construct via Types.package_group({ ... }).

Package

{
    id: string,                                  -- e.g. "standard-4x2"
    display_name: string,
    price: number,                               -- Sale price for this package
    vehicle_id: number,                          -- Trim VehicleId purchased by this package
    base_assembly: { [string]: string },         -- Assembly slot id -> part id
    base_accessories: {                          -- Pre-equipped accessories
        [string]: { [string]: AccessoryInstall } --   parent_slot -> { socket_id -> install }
    },
    base_tuning: { [string]: string }?,          -- Tuning slot id -> tuning id
    assignment: { [string]: string },            -- Default picks: customization_id -> item_id
    customizations: { PackageCustomization },    -- Knobs the player can change
    gamepass: string?,                           -- Optional package-level gate
}

-- An `AccessoryInstall` is the part id plus an optional recursive map of
-- sub-accessories installed on this part. Build via `Types.accessory(...)`:
--
--     accessory("standard")
--     accessory("standard", {
--         ["grille"] = accessory("standard-w-sensor"),
--         ["fog-lights"] = accessory("standard"),
--     })
type AccessoryInstall = {
    part: string,
    accessories: { [string]: AccessoryInstall }?,
}
  • base_assembly is the always-applied slice of the final assembly. Slots listed here are baked into the package and cannot be changed (unless a customization item explicitly overrides them — see below).
  • base_accessories lists accessories that come pre-equipped with the package. The outer key is the assembly slot id whose installed part exposes the accessory socket (e.g. drivetrain, cab); the inner key is the accessory socket id (e.g. front-bumper, side-mirrors); the value is an AccessoryInstall carrying the part id and any sub-accessories installed on it. The recursive accessories field lets you install accessories on accessories — e.g. a grille and fog-lights mounted on the standard front bumper — without flattening the structure. Nested by parent slot at the top level so socket ids that recur across parts (e.g. mud-flap on both frame and vocation) stay disambiguated. Use {} if the package has no pre-equipped accessories.
  • assignment provides the default pick for each customization the player can change. Every customization.id listed in customizations should have an entry here.
  • vehicle_id is the authoritative trim identity for purchase and saved car creation. Packages used to try to resolve this from assembly, but the current model deliberately keeps the purchased VehicleId explicit and stable on the package. Assembly-to-trim resolution still exists for preview/spec helpers and validation, but it does not replace vehicle_id.
  • gamepass is package-level gating. For authored package groups, trim Gamepass is ignored; declare the gate on the package instead.

PackageCustomization

{
    id: string,                                  -- e.g. "frame", "paint"
    type: "assembly" | "tuning" | "rim" | "paint",
    camera_behavior: "default",
    display_name: string,
    items: { PackageCustomizationItem },
}

Construct via Types.customization({ ... })(items) — the helper is curried so you can declare a customization shape once and reuse it across packages with different item lists (see Sierra HT's Customization.Frame for the pattern).

PackageCustomizationItem

{
    id: string,
    display_name: string,
    assignment: {
        Assembly: { [string]: string }?,   -- Slot overrides written to the assembly
        Tuning: { [string]: string }?,     -- DriveSeat overlay selections
        Rim: string?,                      -- Rim reference id
        Paint: { color: string, finish: string }?, -- Optional package-specific escape hatch
    },
}

The fields are independent. A single item may set any combination. For example, a drivetrain item can set both Assembly.drivetrain and Tuning.drivetrain so the visible drivetrain model and runtime engine tuning change together. Normal dealership paint/rim pickers are derived from the package's vehicle_id trim and live assembly picks rather than authored directly in package data.

Resolving picks

The player's current picks are a { [customization_id]: item_id } map — UI state at the item level, e.g. { paint = "glacier-veil", rim = "le" }. The package def is the source of truth on what each assembly pick means. src-new/shared/library/dealership/package_assembly.luau resolves those picks into the slot map consumed by vehicle assembly. Paint and rim options are derived from trim data by package_customization_options.luau: purchase ultimately uses the package's explicit vehicle_id, while preview/spec helpers may resolve the live trim from assembly picks so factory paint/rim categories can update when a package customization changes which trim-shaped assembly is being previewed.

  • PackageAssembly.resolve(pkg, picks){ [slot]: part_id }

Assembly projection follows this shape:

  1. (Assembly only) Start with table.clone(package.base_assembly).
  2. For each customization in package.customizations (in declaration order), pick the item id from picks[customization.id], falling back to package.assignment[customization.id]. Skip if neither is set.
  3. Apply that item's Assembly assignment on top, overwriting any earlier value.

Later customizations win over earlier ones (and, for assembly, over base_assembly). This is how, on the Sierra HT, the Stakebed Standard package leaves frame and cab out of base_assembly and instead exposes them through a Frame customization that sets both atomically (frame = "extended-6x4", cab = "extended", etc.).

Tuning projection is the same idea for non-physical DriveSeat overlays:

  1. Start with table.clone(package.base_tuning) when present.
  2. For each selected customization item, apply its Tuning assignment on top.
  3. Hand the resolved tuning slot map to vehicle assembly, which copies the selected tuning models onto the assembled DriveSeat.

Use tuning assignments when a visible assembly choice also changes runtime behavior. For example, a frame picker can set Tuning.frame for suspension values, while a drivetrain picker can set Tuning.drivetrain for horsepower, gear ratios, and engine sounds. See Assembly Vehicle Tuning for the asset folder shape, tuning_definitions.luau, and VehicleDataPatch rules.

The resolved assembly slot map is then handed to:

  • src-new/shared/library/vehicle_assembly/model_builder.luau — to clone parts from the vehicle's assembly-v1 asset and mount them into a real Model. The package's base_accessories map is also passed in and installed during this pass.
  • VehicleDef:findTrimByAssembly(vehicleKey, assembly) in preview/spec and validation paths, where the system needs to know which trim's factory paint/rim options or specs match the live assembly picks.

For purchase, the package's vehicle_id is used to find the trim and create the saved vehicle. Keep vehicle_id consistent with the package's default and customizable assembly choices; validation catches assemblies that map to no trim, but the package still owns the purchased VehicleId.

Resolved paint/rim ids are looked up in VehicleDef.Paints / VehicleDef.Rims and applied by the consuming system (e.g. the dealership build session applies paint and rim inputs to the assembled model).

Authoring patterns

The Sierra HT (sierra_ht_2026.luau) demonstrates the conventions:

  • Item dictionaries at the top of the file (frame_items, drivetrain_items, rim_items, paint_items) declared once and reused across packages.
  • Customization instances built from those item dictionaries (PaintCustomization, RimCustomization, …). Reusing one PaintCustomization across every package keeps the player-visible paint list consistent.
  • Package factory functions (e.g. icePackage(...)) when many packages share the same shape and only differ in a few fields. The Sierra HT uses this to produce its seven Stock packages without repetition.
  • Co-locked assembly slots. When picking one part forces another (the electric frame requires the electric drivetrain; each frame requires a matching cab), set them together inside a single customization item's assignment.Assembly rather than exposing the dependent slot as its own customization.

These helper-heavy patterns are useful for complex hand-authored package groups, but they are not the default goal for generated vehicle data. For new migration and generator work, prefer denormalized package entries with repeated values written inline unless an existing vehicle-specific pattern clearly calls for a small helper. See Assembly Asset Authoring Workflow for the broader generated-code direction.

Relationship to the assembly system

Package groups do not replace the BaseAssemblyTree field, assembly_options, assembly_definitions, or accessory_definitions — they layer on top:

  • The part ids referenced from base_assembly and customization items must exist in the vehicle's assembly definitions and should be accepted by a trim's AssemblyOptions. Pick a part combination no trim allows and findTrimByAssembly-based preview/spec/validation paths will fail to identify a trim.
  • The package's vehicle_id is separate from its assembly part ids. It is the trim purchased by this package and should describe the package's intended base offering, even when assembly customizations can preview related trim configurations.
  • Display names and prices on PartDefinition entries (assembly_definitions.luau) are independent of package-level display_name/price. The package's price is the dealership sale price; per-part prices in the definitions are for the customization station.
  • Sockets, paints, and accessory metadata still come from the part definitions. Package groups only decide which parts are offered and how the player navigates between them.

Validation

Assembly-v1 package groups are validated offline by .lune/verifyVehiclePackageContract.luau. The complete registered vehicle set is validated by .lune/verifyAllAssemblyVehicles.luau.

For each package, validation checks:

  • the default package assignment, with no explicit pick overrides;
  • each customization item individually, with all other customizations left at the package default.

It does not currently validate the full cartesian product of every customization item combination. This is intentional for now: most package customizations are authored as independent slot changes, and sweeping each item finds the common mistakes without exploding validation cost. If two customizations interact in a way that only breaks when selected together, the current offline validation may not catch that combination.

For each validated pick set, the validator resolves package assembly using the package's default assignments, then checks for missing required slots, orphaned slots, invalid parts, cycles, repeated slots, required-but-missing accessories, invalid accessory installs, and invalid tuning references. It also reports unreferenced assembly and tuning definitions. Findings are aggregated and the script exits unsuccessfully when it finds errors or warnings.

Dealership catalog integration

src/Shared/VehicleDef/DealershipCatalog/initCatalogEntries.luau builds one CatalogEntry per (vehicleKey, packageGroupId) pair, with id package_group:<vehicleKey>:<packageGroupId>, iterating every group in DealershipPackageGroups.PackageGroups (authored + auto-generated). For each entry:

  • resolveBuildOptions() returns the group's packages as BuildOptions.
  • resolveCustomizationCategories(packageId, picks) returns the selected package's customizations flattened to { id, display_name, items: { id, display_name } }, then appends derived paint/rim categories for the currently resolved trim.
  • resolveVehicleAssetDef(buildOptionId) resolves from the vehicle's AssemblySystem: assembly-v1{ kind = "assembly_v1", assetId = vehicleDef.AssetId }; Legacy → the trim's legacy_trim / legacy_vehicle asset, resolved from the build-option id (<VehicleId>-<TrimName>).

Where to find the runtime

  • Package group loading: src/Shared/VehicleDef/DealershipPackageGroups/init.luau
  • Type definitions and constructors: src/Shared/VehicleDef/Types.luau
  • Picks → final assembly: src-new/shared/library/dealership/package_assembly.luau
  • Picks → final tuning: src-new/shared/library/dealership/package_tuning.luau
  • Trim → paint/rim options: src-new/shared/library/dealership/package_customization_options.luau
  • Final assembly → 3D model: src-new/shared/library/vehicle_assembly/model_builder.luau
  • Preview/spec trim helper from assembly: VehicleDef:findTrimByAssembly(vehicleKey, assembly)
  • Purchase trim identity: Package.vehicle_id, consumed by src/Server/Dealership/PurchaseVehicle.luau
  • Offline package validation: .lune/verifyVehiclePackageContract.luau
  • Complete assembly-vehicle validation suite: .lune/verifyAllAssemblyVehicles.luau
  • Catalog entries: src/Shared/VehicleDef/DealershipCatalog/initCatalogEntries.luau
  • Purchase flow: src/Server/Dealership/PurchaseVehicle.luau