From 856ce9a5c9b2c5db67a96262cc55f777756ed231 Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Mon, 2 Jun 2025 16:33:57 +0700 Subject: [PATCH] BUGFIX: Coding contract UI does not handle error properly when answer format is invalid (#2171) --- src/CodingContract/Contract.ts | 66 +++++++++++++++------ src/NetscriptFunctions/CodingContract.ts | 73 +++++++++++++++--------- src/Terminal/Terminal.ts | 29 +++++++--- src/ui/React/CodingContractModal.tsx | 25 +++++--- 4 files changed, 132 insertions(+), 61 deletions(-) diff --git a/src/CodingContract/Contract.ts b/src/CodingContract/Contract.ts index 2161b4556..bc8ba17fc 100644 --- a/src/CodingContract/Contract.ts +++ b/src/CodingContract/Contract.ts @@ -5,6 +5,7 @@ import { Generic_fromJSON, Generic_toJSON, IReviverValue, constructorsForReviver import { CodingContractEvent } from "../ui/React/CodingContractModal"; import { ContractFilePath, resolveContractFilePath } from "../Paths/ContractFilePath"; import { assertObject } from "../utils/TypeAssertion"; +import { Result } from "../types"; // Numeric enum /** Enum representing the different types of rewards a Coding Contract can give */ @@ -21,6 +22,7 @@ export enum CodingContractResult { Success, Failure, Cancelled, + InvalidFormat, } /** A class that represents the type of reward a contract gives */ @@ -67,7 +69,9 @@ export class CodingContract { reward: ICodingContractReward | null = null, ) { const path = resolveContractFilePath(fn); - if (!path) throw new Error(`Bad file path while creating a coding contract: ${fn}`); + if (!path) { + throw new Error(`Bad file path while creating a coding contract: ${fn}`); + } if (!CodingContractTypes[type]) { throw new Error(`Error: invalid contract type: ${type} please contact developer`); } @@ -100,33 +104,59 @@ export class CodingContract { } /** Checks if the answer is in the correct format. */ - isValid(answer: unknown): boolean { - if (typeof answer === "string") answer = CodingContractTypes[this.type].convertAnswer(answer); - return CodingContractTypes[this.type].validateAnswer(answer); + isValid(answer: unknown): Result<{ answer: unknown }> { + if (typeof answer === "string") { + try { + answer = CodingContractTypes[this.type].convertAnswer(answer); + } catch (error) { + return { + success: false, + message: `The answer is not in the right format for contract '${this.type}'. Reason: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + } + const result = CodingContractTypes[this.type].validateAnswer(answer); + if (!result) { + return { + success: false, + message: `The answer is not in the right format for contract '${this.type}'. Got: ${answer}`, + }; + } + return { success: true, answer }; } - isSolution(solution: unknown): boolean { - const type = CodingContractTypes[this.type]; - if (typeof solution === "string") solution = type.convertAnswer(solution); - if (!this.isValid(solution)) return false; - - return type.solver(this.state, solution); + isSolution(solution: unknown): { + result: Exclude; + message?: string; + } { + const validationResult = this.isValid(solution); + if (!validationResult.success) { + return { result: CodingContractResult.InvalidFormat, message: validationResult.message }; + } + /** + * We sometimes need to convert the given solution by calling CodingContractType.convertAnswer() (e.g., Square Root + * contract) before using it. The conversion is done in CodingContract.isValid(). + */ + solution = validationResult.answer; + return { + result: CodingContractTypes[this.type].solver(this.state, solution) + ? CodingContractResult.Success + : CodingContractResult.Failure, + }; } /** Creates a popup to prompt the player to solve the problem */ - async prompt(): Promise { - return new Promise((resolve) => { + async prompt(): Promise<{ result: CodingContractResult; message?: string }> { + return new Promise((resolve) => { CodingContractEvent.emit({ c: this, onClose: () => { - resolve(CodingContractResult.Cancelled); + resolve({ result: CodingContractResult.Cancelled }); }, onAttempt: (val: string) => { - if (this.isSolution(val)) { - resolve(CodingContractResult.Success); - } else { - resolve(CodingContractResult.Failure); - } + resolve(this.isSolution(val)); }, }); }); diff --git a/src/NetscriptFunctions/CodingContract.ts b/src/NetscriptFunctions/CodingContract.ts index b1cc4338e..ae152f296 100644 --- a/src/NetscriptFunctions/CodingContract.ts +++ b/src/NetscriptFunctions/CodingContract.ts @@ -1,5 +1,5 @@ import { Player } from "@player"; -import { CodingContract } from "../CodingContract/Contract"; +import { CodingContract, CodingContractResult } from "../CodingContract/Contract"; import { CodingContractObject, CodingContract as ICodingContract } from "@nsdefs"; import { InternalAPI, NetscriptContext } from "../Netscript/APIWrapper"; import { helpers } from "../Netscript/NetscriptHelpers"; @@ -7,6 +7,7 @@ import { CodingContractName } from "@enums"; import { generateDummyContract } from "../CodingContract/ContractGenerator"; import { isCodingContractName } from "../CodingContract/ContractTypes"; import { type BaseServer } from "../Server/BaseServer"; +import { exceptionAlert } from "../utils/helpers/exceptionAlert"; export function NetscriptCodingContract(): InternalAPI { const getCodingContract = function (ctx: NetscriptContext, hostname: string, filename: string): CodingContract { @@ -25,26 +26,51 @@ export function NetscriptCodingContract(): InternalAPI { contract: CodingContract, answer: unknown, ): string { - if (contract.isSolution(answer)) { - const reward = Player.gainCodingContractReward(contract.reward, contract.getDifficulty()); - helpers.log(ctx, () => `Successfully completed Coding Contract '${contract.fn}'. Reward: ${reward}`); - server.removeContract(contract.fn); - return reward; + const validationResult = contract.isValid(answer); + if (!validationResult.success) { + throw helpers.errorMessage(ctx, validationResult.message); } - if (++contract.tries >= contract.getMaxNumTries()) { - helpers.log(ctx, () => `Coding Contract attempt '${contract.fn}' failed. Contract is now self-destructing`); - server.removeContract(contract.fn); - } else { - helpers.log( - ctx, - () => - `Coding Contract attempt '${contract.fn}' failed. ${ - contract.getMaxNumTries() - contract.tries - } attempt(s) remaining.`, - ); + const resultOfCheckingSolution = contract.isSolution(answer); + switch (resultOfCheckingSolution.result) { + case CodingContractResult.Success: { + const reward = Player.gainCodingContractReward(contract.reward, contract.getDifficulty()); + helpers.log(ctx, () => `Successfully completed Coding Contract '${contract.fn}'. Reward: ${reward}`); + server.removeContract(contract.fn); + return reward; + } + /** + * This should never happen. If the answer format is invalid, it should already be handled by the call to + * contract.isValid() above. + */ + case CodingContractResult.InvalidFormat: { + exceptionAlert( + new Error( + `contract.isSolution() returns unexpected InvalidFormat result. Type: ${contract.type}. Answer: ${answer}`, + ), + true, + ); + return ""; + } + case CodingContractResult.Failure: { + if (++contract.tries >= contract.getMaxNumTries()) { + helpers.log(ctx, () => `Coding Contract attempt '${contract.fn}' failed. Contract is now self-destructing`); + server.removeContract(contract.fn); + } else { + helpers.log( + ctx, + () => + `Coding Contract attempt '${contract.fn}' failed. ${ + contract.getMaxNumTries() - contract.tries + } attempt(s) remaining.`, + ); + } + return ""; + } + default: { + const __: never = resultOfCheckingSolution.result; + } } - return ""; } @@ -53,15 +79,8 @@ export function NetscriptCodingContract(): InternalAPI { const filename = helpers.string(ctx, "filename", _filename); const host = _host ? helpers.string(ctx, "host", _host) : ctx.workerScript.hostname; const contract = getCodingContract(ctx, host, filename); - - if (!contract.isValid(answer)) - throw helpers.errorMessage( - ctx, - `Answer is not in the right format for contract '${contract.type}'. Got: ${answer}`, - ); - - const serv = helpers.getServer(ctx, host); - return attemptContract(ctx, serv, contract, answer); + const server = helpers.getServer(ctx, host); + return attemptContract(ctx, server, contract, answer); }, getContractType: (ctx) => (_filename, _host?) => { const filename = helpers.string(ctx, "filename", _filename); diff --git a/src/Terminal/Terminal.ts b/src/Terminal/Terminal.ts index ad891bdc6..609c121b3 100644 --- a/src/Terminal/Terminal.ts +++ b/src/Terminal/Terminal.ts @@ -503,40 +503,51 @@ export class Terminal { return this.error("There's already a Coding Contract in Progress"); } - const serv = Player.getCurrentServer(); - const contract = serv.getContract(contractPath); - if (!contract) return this.error("No such contract"); + const server = Player.getCurrentServer(); + const contract = server.getContract(contractPath); + if (!contract) { + return this.error("No such contract"); + } this.contractOpen = true; - const res = await contract.prompt(); + const promptResult = await contract.prompt(); //Check if the contract still exists by the time the promise is fulfilled - if (serv.getContract(contractPath) == null) { + if (server.getContract(contractPath) == null) { this.contractOpen = false; return this.error("Contract no longer exists (Was it solved by a script?)"); } - switch (res) { + switch (promptResult.result) { case CodingContractResult.Success: if (contract.reward !== null) { const reward = Player.gainCodingContractReward(contract.reward, contract.getDifficulty()); this.print(`Contract SUCCESS - ${reward}`); } - serv.removeContract(contract); + server.removeContract(contract); + break; + case CodingContractResult.InvalidFormat: + this.error( + `Contract FAILED - ${ + promptResult.message ?? `The answer is not in the right format for contract '${contract.type}'` + }`, + ); break; case CodingContractResult.Failure: ++contract.tries; if (contract.tries >= contract.getMaxNumTries()) { this.error("Contract FAILED - Contract is now self-destructing"); - serv.removeContract(contract); + server.removeContract(contract); } else { this.error(`Contract FAILED - ${contract.getMaxNumTries() - contract.tries} tries remaining`); } break; case CodingContractResult.Cancelled: - default: this.print("Contract cancelled"); break; + default: { + const __: never = promptResult.result; + } } this.contractOpen = false; } diff --git a/src/ui/React/CodingContractModal.tsx b/src/ui/React/CodingContractModal.tsx index b013a79f8..3af334846 100644 --- a/src/ui/React/CodingContractModal.tsx +++ b/src/ui/React/CodingContractModal.tsx @@ -24,22 +24,26 @@ export function CodingContractModal(): React.ReactElement { const [answer, setAnswer] = useState(""); useEffect(() => { - CodingContractEvent.subscribe((props) => setContract(props)); - }); + return CodingContractEvent.subscribe((props) => setContract(props)); + }, []); useEffect(() => { return () => { contract?.onClose(); }; }, [contract]); - if (contract === null) return <>; + if (contract === null) { + return <>; + } function onChange(event: React.ChangeEvent): void { setAnswer(event.target.value); } function onKeyDown(event: React.KeyboardEvent): void { - if (contract === null) return; + if (contract === null) { + return; + } const value = event.currentTarget.value; if (event.key === KEY.ENTER && value !== "") { @@ -51,15 +55,22 @@ export function CodingContractModal(): React.ReactElement { } function close(): void { - if (contract === null) return; + if (contract === null) { + return; + } contract.onClose(); setContract(null); } const contractType = CodingContractTypes[contract.c.type]; const description = []; - for (const [i, value] of contractType.desc(contract.c.getData()).split("\n").entries()) - description.push(" }}>); + for (const [i, value] of contractType.desc(contract.c.getData()).split("\n").entries()) { + description.push( + + {value}
+
, + ); + } return (