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);
const replacementParts = getReplacementParts(config);
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
const requiredReplacementPart = replacementParts.find(
(part) => part.id === config.requiredReplacementPartId,
const requiredReplacementPart = replacementParts.find((part) =>
config.requiredReplacementPartIds.includes(part.id),
);
const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label;
@@ -89,15 +89,16 @@ export function RepairRepairingStep({
const placeholderPositions = placeholderTargets.map(
(target) => target.position,
);
const hasCorrectPartPlaced = Boolean(
placedPartIds[config.requiredReplacementPartId],
const hasCorrectPartPlaced = config.requiredReplacementPartIds.some(
(id) => placedPartIds[id],
);
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
(part) => depositedBrokenPartIds[part.id],
);
const hasWrongPartPlaced = replacementParts.some(
(part) =>
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
!config.requiredReplacementPartIds.includes(part.id) &&
placedPartIds[part.id],
);
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
const installColor = isReadyToInstall
@@ -198,7 +199,7 @@ export function RepairRepairingStep({
const isPlaced = Boolean(placedPartIds[part.id]);
const feedbackState = getReplacementFeedbackState(
part.id,
config.requiredReplacementPartId,
config.requiredReplacementPartIds,
isPlaced,
);
@@ -387,12 +388,12 @@ function getPlacementFeedbackColor(
function getReplacementFeedbackState(
partId: string,
requiredPartId: string,
requiredPartIds: readonly string[],
isPlaced: boolean,
): RepairPartPlacementFeedbackProps["state"] {
if (!isPlaced) return null;
return partId === requiredPartId ? "valid" : "invalid";
return requiredPartIds.includes(partId) ? "valid" : "invalid";
}
function getPlaceholderTargets(
@@ -466,9 +467,12 @@ function getReplacementParts(
): readonly RepairMissionPartConfig[] {
if (config.replacementParts.length > 0) return config.replacementParts;
const fallbackId =
config.requiredReplacementPartIds[0] ?? `${config.id}-replacement`;
return [
{
id: config.requiredReplacementPartId,
id: fallbackId,
label: config.label,
modelPath: config.modelPath,
},
+3 -3
View File
@@ -25,7 +25,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
requiredReplacementPartId: "ebike-cooling-core-replacement",
requiredReplacementPartIds: ["ebike-cooling-core-replacement"],
brokenParts: [
{
id: "ebike-cooling-core",
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.8,
requiredReplacementPartId: "pylon-grid-relay-replacement",
requiredReplacementPartIds: ["pylon-grid-relay-replacement"],
scanPartSeconds: 1.4,
brokenParts: [
{
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.2,
requiredReplacementPartId: "farm-irrigation-pump-replacement",
requiredReplacementPartIds: ["farm-irrigation-pump-replacement"],
scanPartSeconds: 0.9,
brokenParts: [
{
+30 -1
View File
@@ -3,6 +3,7 @@ import type {
Vector3Scale,
Vector3Tuple,
} from "@/types/three/three";
import type { RepairCasePartAnchorName } from "@/data/gameplay/repairCaseConfig";
export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
@@ -24,7 +25,28 @@ export interface RepairMissionPartConfig {
id: string;
label: 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;
/**
* 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;
}
@@ -33,6 +55,7 @@ export interface RepairScannedBrokenPart {
label: string;
modelPath: string;
caseSlotName?: string;
targetNodeName?: string;
}
export interface RepairMissionConfig {
@@ -46,7 +69,13 @@ export interface RepairMissionConfig {
brokenUiPath: string;
case: RepairMissionCaseConfig;
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;
brokenParts: readonly RepairMissionPartConfig[];
replacementParts: readonly RepairMissionPartConfig[];