const axios = require("axios"); const { resetAllPrototypeQuestProgress, resetPrototypeResourceNodes, } = require("./bruno-dev-fixture-helper"); const { resetPrototypeCombatTargets } = require("./combat-targets-reset-helper"); const { clearInventory, sumItemQuantity, getInventory } = require("./inventory-api-helper"); const { moveNearPrototypeNpc, PULSE_COOLDOWN_MS, MAX_PULSE_DEFEAT_ATTEMPTS, sleep, } = require("./prototype-npc-bruno-helper"); const GATHER_QUEST_ID = "prototype_quest_gather_intro"; const COMBAT_QUEST_ID = "prototype_quest_combat_intro"; const REFINE_QUEST_ID = "prototype_quest_refine_intro"; const CHAIN_QUEST_ID = "prototype_quest_operator_chain"; const DELTA_NODE_ID = "prototype_urban_bulk_delta"; const CAST_SLOT_INDEX = 3; function resolveConfig(bru) { return { baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"), playerId: bru.getEnvVar("playerId") || bru.getVar("playerId"), jsonHeaders: { headers: { "Content-Type": "application/json" } }, }; } async function getQuestProgress(bru) { const { baseUrl, playerId } = resolveConfig(bru); const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, { validateStatus: () => true, }); if (response.status !== 200) { throw new Error(`quest-progress GET failed: ${response.status}`); } return response.data; } function questRow(body, questId) { const row = body.quests.find((x) => x.questId === questId); if (!row) { throw new Error(`missing quest row ${questId}: ${JSON.stringify(body.quests)}`); } return row; } async function acceptQuest(bru, questId) { const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); const response = await axios.post( `${baseUrl}/game/players/${playerId}/quests/${questId}/accept`, { schemaVersion: 1 }, { ...jsonHeaders, validateStatus: () => true }, ); if (response.status !== 200 || response.data?.accepted !== true) { throw new Error(`accept ${questId} failed: ${response.status} ${JSON.stringify(response.data)}`); } } async function moveNear(bru, x, z) { const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); await axios.post( `${baseUrl}/game/players/${playerId}/move`, { schemaVersion: 1, target: { x, y: 0.9, z } }, jsonHeaders, ); } async function tryGather(bru, interactableId) { const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); const response = await axios.post( `${baseUrl}/game/players/${playerId}/interact`, { schemaVersion: 1, interactableId }, { ...jsonHeaders, validateStatus: () => true }, ); if (response.status !== 200) { throw new Error(`gather ${interactableId} HTTP ${response.status}`); } if (response.data?.allowed === true || response.data?.reasonCode === "node_depleted") { return; } if (response.data?.reasonCode === "out_of_range") { throw new Error(`gather ${interactableId} out_of_range — moveNear before interact`); } throw new Error(`gather ${interactableId} denied: ${JSON.stringify(response.data)}`); } async function craft(bru, recipeId) { const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); const response = await axios.post( `${baseUrl}/game/players/${playerId}/craft`, { schemaVersion: 1, recipeId }, { ...jsonHeaders, validateStatus: () => true }, ); if (response.status !== 200 || response.data?.success !== true) { throw new Error(`craft ${recipeId} failed: ${response.status} ${JSON.stringify(response.data)}`); } } async function defeatNpc(bru, targetId) { const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders); await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, { schemaVersion: 1, targetId }, jsonHeaders, ); await sleep(bru, PULSE_COOLDOWN_MS); const castBody = { schemaVersion: 1, slotIndex: CAST_SLOT_INDEX, abilityId: "prototype_pulse", targetId, }; for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i += 1) { const response = await axios.post( `${baseUrl}/game/players/${playerId}/ability-cast`, castBody, jsonHeaders, ); const body = response.data; if (!body?.accepted || !body?.combatResolution) { throw new Error( `cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`, ); } if (body.combatResolution.targetDefeated) { return; } if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) { await sleep(bru, PULSE_COOLDOWN_MS); } } throw new Error(`did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`); } async function completeGatherIntro(bru) { await acceptQuest(bru, GATHER_QUEST_ID); await moveNear(bru, 10, -6); for (let i = 0; i < 3; i += 1) { await tryGather(bru, "prototype_resource_node_alpha"); } const progress = await getQuestProgress(bru); const row = questRow(progress, GATHER_QUEST_ID); if (row.status !== "completed") { throw new Error(`gather intro not completed: ${JSON.stringify(row)}`); } } async function completeCombatIntro(bru) { const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); await acceptQuest(bru, COMBAT_QUEST_ID); await axios.post( `${baseUrl}/game/players/${playerId}/hotbar-loadout`, { schemaVersion: 1, slots: [{ slotIndex: CAST_SLOT_INDEX, abilityId: "prototype_pulse" }], }, jsonHeaders, ); await defeatNpc(bru, "prototype_npc_melee"); await defeatNpc(bru, "prototype_npc_ranged"); await defeatNpc(bru, "prototype_npc_elite"); const progress = await getQuestProgress(bru); const row = questRow(progress, COMBAT_QUEST_ID); if (row.status !== "completed") { throw new Error(`combat intro not completed: ${JSON.stringify(row)}`); } } async function completeRefineIntro(bru) { await acceptQuest(bru, REFINE_QUEST_ID); const scrap = sumItemQuantity(await getInventory(bru), "scrap_metal_bulk"); if (scrap < 5) { throw new Error(`refine intro needs >=5 scrap, have ${scrap}`); } await craft(bru, "refine_scrap_standard"); const progress = await getQuestProgress(bru); const row = questRow(progress, REFINE_QUEST_ID); if (row.status !== "completed") { throw new Error(`refine intro not completed: ${JSON.stringify(row)}`); } } async function completeOperatorChain(bru) { await acceptQuest(bru, CHAIN_QUEST_ID); await moveNear(bru, 16, -2); await tryGather(bru, DELTA_NODE_ID); let progress = await getQuestProgress(bru); let row = questRow(progress, CHAIN_QUEST_ID); if (row.status !== "active" || row.currentStepIndex !== 1) { throw new Error(`chain step 1 not advanced: ${JSON.stringify(row)}`); } await craft(bru, "refine_scrap_standard"); progress = await getQuestProgress(bru); row = questRow(progress, CHAIN_QUEST_ID); if (row.status !== "active" || row.currentStepIndex !== 2) { throw new Error(`chain step 2 not advanced: ${JSON.stringify(row)}`); } await craft(bru, "make_field_stim_mk0"); progress = await getQuestProgress(bru); row = questRow(progress, CHAIN_QUEST_ID); if (row.status !== "completed") { throw new Error(`operator chain not completed: ${JSON.stringify(row)}`); } } /** * NEO-138: organic operator-chain completion via HTTP (gather/craft/encounter wiring). * Applies +15 Grid Operators rep on chain completion reward delivery. */ async function completeOperatorChainQuestFlow(bru) { await resetPrototypeResourceNodes(bru); await clearInventory(bru); await resetPrototypeCombatTargets(bru); await resetAllPrototypeQuestProgress(bru); await completeGatherIntro(bru); await completeCombatIntro(bru); await completeRefineIntro(bru); await completeOperatorChain(bru); } module.exports = { completeOperatorChainQuestFlow, CHAIN_QUEST_ID, };