feat(repair): support multiple required parts and per-part case anchor

- RepairMissionConfig.requiredReplacementPartId (string) is replaced by
  requiredReplacementPartIds (readonly string[]) so a mission can accept
  several alternative correct parts (e.g. pylon will accept either cable).
- RepairMissionPartConfig gains optional caseAnchor (where the standalone
  spawns inside packderelance), caseLockGroup (mutually exclusive parts),
  and targetNodeName (snap onto a node of the broken model rather than a
  placeholder slot in the case).
- RepairScannedBrokenPart gains targetNodeName so scan results can carry
  this hint through to the repairing step.
- RepairRepairingStep validation logic (placed/wrong/feedback) now matches
  any id in requiredReplacementPartIds. Existing data is migrated mechanically
  (single-element arrays); part-level new fields are wired in subsequent
  commits.
This commit is contained in:
Tom Boullay
2026-06-02 18:26:45 +02:00
parent 7d2a257e84
commit d2ce990165
3 changed files with 46 additions and 13 deletions
@@ -80,8 +80,8 @@ export function RepairRepairingStep({
useState(false); useState(false);
const replacementParts = getReplacementParts(config); const replacementParts = getReplacementParts(config);
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts); const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
const requiredReplacementPart = replacementParts.find( const requiredReplacementPart = replacementParts.find((part) =>
(part) => part.id === config.requiredReplacementPartId, config.requiredReplacementPartIds.includes(part.id),
); );
const requiredReplacementLabel = const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label; requiredReplacementPart?.label ?? config.label;
@@ -89,15 +89,16 @@ export function RepairRepairingStep({
const placeholderPositions = placeholderTargets.map( const placeholderPositions = placeholderTargets.map(
(target) => target.position, (target) => target.position,
); );
const hasCorrectPartPlaced = Boolean( const hasCorrectPartPlaced = config.requiredReplacementPartIds.some(
placedPartIds[config.requiredReplacementPartId], (id) => placedPartIds[id],
); );
const hasDepositedBrokenParts = brokenPartsToDeposit.every( const hasDepositedBrokenParts = brokenPartsToDeposit.every(
(part) => depositedBrokenPartIds[part.id], (part) => depositedBrokenPartIds[part.id],
); );
const hasWrongPartPlaced = replacementParts.some( const hasWrongPartPlaced = replacementParts.some(
(part) => (part) =>
part.id !== config.requiredReplacementPartId && placedPartIds[part.id], !config.requiredReplacementPartIds.includes(part.id) &&
placedPartIds[part.id],
); );
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts; const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
const installColor = isReadyToInstall const installColor = isReadyToInstall
@@ -198,7 +199,7 @@ export function RepairRepairingStep({
const isPlaced = Boolean(placedPartIds[part.id]); const isPlaced = Boolean(placedPartIds[part.id]);
const feedbackState = getReplacementFeedbackState( const feedbackState = getReplacementFeedbackState(
part.id, part.id,
config.requiredReplacementPartId, config.requiredReplacementPartIds,
isPlaced, isPlaced,
); );
@@ -387,12 +388,12 @@ function getPlacementFeedbackColor(
function getReplacementFeedbackState( function getReplacementFeedbackState(
partId: string, partId: string,
requiredPartId: string, requiredPartIds: readonly string[],
isPlaced: boolean, isPlaced: boolean,
): RepairPartPlacementFeedbackProps["state"] { ): RepairPartPlacementFeedbackProps["state"] {
if (!isPlaced) return null; if (!isPlaced) return null;
return partId === requiredPartId ? "valid" : "invalid"; return requiredPartIds.includes(partId) ? "valid" : "invalid";
} }
function getPlaceholderTargets( function getPlaceholderTargets(
@@ -466,9 +467,12 @@ function getReplacementParts(
): readonly RepairMissionPartConfig[] { ): readonly RepairMissionPartConfig[] {
if (config.replacementParts.length > 0) return config.replacementParts; if (config.replacementParts.length > 0) return config.replacementParts;
const fallbackId =
config.requiredReplacementPartIds[0] ?? `${config.id}-replacement`;
return [ return [
{ {
id: config.requiredReplacementPartId, id: fallbackId,
label: config.label, label: config.label,
modelPath: config.modelPath, modelPath: config.modelPath,
}, },
+3 -3
View File
@@ -25,7 +25,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
interactUiPath: REPAIR_INTERACT_UI_PATH, interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE, case: DEFAULT_REPAIR_CASE,
requiredReplacementPartId: "ebike-cooling-core-replacement", requiredReplacementPartIds: ["ebike-cooling-core-replacement"],
brokenParts: [ brokenParts: [
{ {
id: "ebike-cooling-core", id: "ebike-cooling-core",
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE, case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.8, reassemblySeconds: 1.8,
requiredReplacementPartId: "pylon-grid-relay-replacement", requiredReplacementPartIds: ["pylon-grid-relay-replacement"],
scanPartSeconds: 1.4, scanPartSeconds: 1.4,
brokenParts: [ brokenParts: [
{ {
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE, case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.2, reassemblySeconds: 1.2,
requiredReplacementPartId: "farm-irrigation-pump-replacement", requiredReplacementPartIds: ["farm-irrigation-pump-replacement"],
scanPartSeconds: 0.9, scanPartSeconds: 0.9,
brokenParts: [ brokenParts: [
{ {
+30 -1
View File
@@ -3,6 +3,7 @@ import type {
Vector3Scale, Vector3Scale,
Vector3Tuple, Vector3Tuple,
} from "@/types/three/three"; } from "@/types/three/three";
import type { RepairCasePartAnchorName } from "@/data/gameplay/repairCaseConfig";
export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const; export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
@@ -24,7 +25,28 @@ export interface RepairMissionPartConfig {
id: string; id: string;
label: string; label: string;
nodeName?: string; nodeName?: string;
/**
* Name of a node inside the broken model where this part should snap on
* install. Used by replacement parts that target a slot in the broken
* model itself (e.g. pylon cable installs at the world-position of the
* pylon's `cable2` node), and by broken parts that should spawn at their
* original location on the broken model rather than a static offset.
*/
targetNodeName?: string;
caseSlotName?: string; caseSlotName?: string;
/**
* Anchor name in the packderelance case where this replacement part is
* visually injected. When set, the part spawns at the world-position of
* that anchor instead of a generic placeholder slot.
*/
caseAnchor?: RepairCasePartAnchorName;
/**
* Group identifier for mutually exclusive replacement parts (e.g. pylon
* cables: only one cable can be held/installed at a time). When one part
* of the group is held, others in the same group are visually ghosted
* and non-interactive.
*/
caseLockGroup?: string;
modelPath?: string; modelPath?: string;
} }
@@ -33,6 +55,7 @@ export interface RepairScannedBrokenPart {
label: string; label: string;
modelPath: string; modelPath: string;
caseSlotName?: string; caseSlotName?: string;
targetNodeName?: string;
} }
export interface RepairMissionConfig { export interface RepairMissionConfig {
@@ -46,7 +69,13 @@ export interface RepairMissionConfig {
brokenUiPath: string; brokenUiPath: string;
case: RepairMissionCaseConfig; case: RepairMissionCaseConfig;
reassemblySeconds?: number; reassemblySeconds?: number;
requiredReplacementPartId: string; /**
* Replacement part IDs accepted as the correct install. Multiple values
* are used when several alternatives are valid (e.g. pylon accepts either
* cable model). Install validation succeeds when any one of these parts
* is snapped into a placeholder slot.
*/
requiredReplacementPartIds: readonly string[];
scanPartSeconds?: number; scanPartSeconds?: number;
brokenParts: readonly RepairMissionPartConfig[]; brokenParts: readonly RepairMissionPartConfig[];
replacementParts: readonly RepairMissionPartConfig[]; replacementParts: readonly RepairMissionPartConfig[];