neon-sprawl/bruno/neon-sprawl-server/scripts/operator-chain-quest-flow-h...

228 lines
7.7 KiB
JavaScript

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,
};