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_assemblyis 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_accessorieslists 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 anAccessoryInstallcarrying the part id and any sub-accessories installed on it. The recursiveaccessoriesfield lets you install accessories on accessories — e.g. agrilleandfog-lightsmounted on thestandardfront bumper — without flattening the structure. Nested by parent slot at the top level so socket ids that recur across parts (e.g.mud-flapon both frame and vocation) stay disambiguated. Use{}if the package has no pre-equipped accessories.assignmentprovides the default pick for each customization the player can change. Everycustomization.idlisted incustomizationsshould have an entry here.vehicle_idis 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 replacevehicle_id.gamepassis package-level gating. For authored package groups, trimGamepassis 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:
- (Assembly only) Start with
table.clone(package.base_assembly). - For each customization in
package.customizations(in declaration order), pick the item id frompicks[customization.id], falling back topackage.assignment[customization.id]. Skip if neither is set. - Apply that item's
Assemblyassignment 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:
- Start with
table.clone(package.base_tuning)when present. - For each selected customization item, apply its
Tuningassignment on top. - 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'sassembly-v1asset and mount them into a real Model. The package'sbase_accessoriesmap 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 onePaintCustomizationacross 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 sevenStockpackages 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.Assemblyrather 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_assemblyand customization items must exist in the vehicle's assembly definitions and should be accepted by a trim'sAssemblyOptions. Pick a part combination no trim allows andfindTrimByAssembly-based preview/spec/validation paths will fail to identify a trim. - The package's
vehicle_idis 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
PartDefinitionentries (assembly_definitions.luau) are independent of package-leveldisplay_name/price. The package'spriceis 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 asBuildOptions.resolveCustomizationCategories(packageId, picks)returns the selected package'scustomizationsflattened 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'sAssemblySystem:assembly-v1→{ kind = "assembly_v1", assetId = vehicleDef.AssetId };Legacy→ the trim'slegacy_trim/legacy_vehicleasset, 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 bysrc/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