Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CODINGCONTRACT: Add support for other answer formats #1892

Open
wants to merge 10 commits into
base: dev
Choose a base branch
from

Conversation

G4mingJon4s
Copy link
Contributor

@G4mingJon4s G4mingJon4s commented Jan 3, 2025

Coding contracts are inherently not type-safe. This PR tries to combat this a bit, by making these changes:

  • Make attempt accept not only strings, but also the specific type the contract works with, like number[] or more specific formats
  • Make all contract types and their data and answer types accessible to the player
  • Add optional typing to getData and attempt

@G4mingJon4s
Copy link
Contributor Author

G4mingJon4s commented Jan 3, 2025

I spent about 1 hour now, testing "most" contracts for backwards-compatibility with my own cct script. I would really appreciate others testing their scripts too, since mine doesn't cover all contracts. These are the contracts, I would say, that are most affected by the string conversion and taking the format directly:

  • Merge Overlapping Intervals
  • Proper 2-Coloring of a Graph
  • Shortest Path in a Grid
  • Square Root

@G4mingJon4s
Copy link
Contributor Author

I'm working on making the types available to the player right now. In the definitions, it is suggested that the data is provided similar to how the SleeveTasks are structured.
I'm not quite sure what approach to take. I could make both by having the data type be computed based on the general types. For more detail, this is what I would settle on at the moment:

declare enum CodingContractName {
  FindLargestPrimeFactor = "Find Largest Prime Factor",
  ...
}

// Each contract has its data and answer types specified here. The player can use this however they like.
export type CodingContractSchema = {
  "Find Largest Prime Factor": [number, number],
  "Shortest Path in a Grid": [number[][], string],
  "Square Root": [bigint, string],
  [CodingContractName.FindLargestPrimeFactor]: [number, number] // Maybe this is safer?
}

// This would be the new way the contract data is returned, allowing for type-narrowing based on "type".
export type CodingContractData = { [T in keyof CodingContractSchema]: {
  type: `${T}`,
  data: CodingContractSchema[T][0]
} }[keyof CodingContractSchema];
/* This results in this type signature:
type CodingContractData =
  | { type: "Find Largest Prime Factor"; data: number; }
  | { type: "Shortest Path in a Grid"; data: number[][]; }
  | { type: "Square Root"; data: bigint; }
*/

The new way of returning the contract data is a API break, so maybe the optional typing is preferred? (see this playground example).
It allows for strict types even in JS, so it would be worth it IMO.

attempt would still be typed as any this way. Not sure if it makes sense to give the submission the same treatment. Having the user return in the same shape { type: string; data: any; }, but typed would be possible.

There are quite a few options here:

  • Leave everything as is, just give the player all of the types for each contract. They can figure it out on their own. This would be for TS users only (maybe JSDoc works too?). getData and attempt are still typed as any.
  • Make optional types by explicitly setting a type generic on getData and attempt. TS users only.
  • Make getData similar to SleeveTask, allowing for type-narrowing based on a property (as shown above). Every player would benefit from this, but it would be an API break.
  • Make attempt similar to SleeveTask, making it fully type-safe. The player would have to make the shape on their own though. For newer players especially, this could be quite difficult?
  • Merge option 3 and 4, making both getData and attempt fully type-safe.

This is something I really don't want to decide on my own here. IMO it isn't worth it for attempt, but getData would be a lot safer to use with option 3. For anyone who wants more type-safety, they have all types at their disposal through the definitions.

Copy link
Collaborator

@d0sboots d0sboots left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why you want to change the API of getData. The type of contracts is already exposed via ns.codingcontract.getType(); the function is literally named for this purpose. What gain is there by additionally jamming it into getData?

Edit: I could see there being a slightly better alternate world where ns.codingcontract.getContract() returned a single object with all the data pertinent to a CCT, and we didn't have multiple API functions to do the different jobs, but that's not the world we are in.

src/data/codingcontracttypes.ts Outdated Show resolved Hide resolved
@G4mingJon4s
Copy link
Contributor Author

Changing the way the data is provided to the user can give type-safety, even to non-TS users. Depending on the type property, the way data is typed changes. This results in a typing, similar to SleeveTask or PlayerRequirement.
The player doesn't need to do any "TS magic" or type-assertions. They just check which type the contract has and get the correct data type for free.

I agree, it would make more sense for that function to be called getContract or similar. Since it was suggested to change the CodingContractData type in the definitions file, I just went with that.
Maybe we just keep the existing functions and add this? That way, there's no API break and players who want type-safety can easily achieve it. It would be a bit weird to have multiple functions that do essentially the same, but that's already the case for a lot of other parts of NS.
Still, I would like to make the old functions more type-safe too. There are some small things that bother me with the current API:

  • getData and attempt being inherently typed in an unsafe way
  • getType returning string, instead of an union of possible types
  • No available types for any of the contracts

That's why I'd also like getData and attempt to be optionally typed like I described before. Players don't have to use it, so it shouldn't ever be a problem.

@d0sboots
Copy link
Collaborator

d0sboots commented Jan 9, 2025

I'm all for getType returning an enum member. That is backward-compatible and improves type-safety in a simple and straightforward way. It exposes the full list of Coding Contracts, but honestly I think that is a good thing.

getData can't be changed. That is too big of a breaking change for something that is a marginal improvement, at best. We've rejected much bigger improvements than that due to backward compatibility, and there are lots of people out there with big libraries of CCT solvers.

I think I'd be open to adding a getContract method that returns all the info in a single object. That can then be typesafe. It should have a RAM cost of at least getType + getData to be balanced.

I don't think attempt can ever be really typesafe, because its true type always has to be unknown. Also, it will always need to accept strings for backward compatibility. I think it is a good ergonomic change to add the ability to accept the "native" type in answers, when that is sensible, but IMO we shouldn't tie ourselves in knots with the type system to make that happen.

@G4mingJon4s
Copy link
Contributor Author

Since all of the contracts types are in the definitions now, the internals can be somewhat type-safe too. At least the solver definitions.
This is pretty much ready to go. It still bothers me that ccts don't have their own folder and aren't separated in any way based on some categories. Since I already changed around the internals quite a bit, I would be more than happy to clean up this mess in a future PR.

@G4mingJon4s G4mingJon4s marked this pull request as ready for review January 10, 2025 20:46
@G4mingJon4s
Copy link
Contributor Author

Someone should really test their cct script on this. In theory, it should be 100% backwards-compatible.
I went through every contract once and made sure the conversion logic is correct for properly-formatted strings. Not sure how it handles JSON.stringify submissions. Since converted answers get validated too, the validation part should also be working.

@G4mingJon4s
Copy link
Contributor Author

I tested this script on the current 2.7.1dev and this PR and it works on both versions without any errors. Well, except for contract filenames allowing for duplicates, which is an unrelated bug. I will fix that in a separate PR when this is merged.
I tested both converting every answer to strings via JSON.stringify and returning the format itself without any conversion. Both work without any errors, except one edge-cases involving passing an empty array as a string.

Copy link
Collaborator

@d0sboots d0sboots left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The internal typesafety changes are great. I've only got one (somewhat significant) concern about the API

@@ -18,11 +19,20 @@ export function NetscriptCodingContract(): InternalAPI<ICodingContract> {
};

return {
attempt: (ctx) => (answer, _filename, _hostname?) => {
attempt: (ctx) => (answer: unknown, _filename: unknown, _hostname?: unknown, _type?: unknown) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't have to explicitly type these, all parameters are automatically unknown through a process I don't fully understand/recall.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving out the explicit types actually results in an error. I'm not sure either, why this is the case. For getContract, I can remove them safely though.

src/NetscriptFunctions/CodingContract.ts Show resolved Hide resolved
src/ScriptEditor/NetscriptDefinitions.d.ts Outdated Show resolved Hide resolved
src/ScriptEditor/NetscriptDefinitions.d.ts Outdated Show resolved Hide resolved
* @param filename - Filename of the contract.
* @param host - Hostname of the server containing the contract. Optional. Defaults to current server if not
* provided.
* @returns A reward description string on success, or an empty string on failure.
*/
attempt(answer: string | number | any[], filename: string, host?: string): string;
attempt(answer: any, filename: string, host?: string): string;
attempt<T extends CodingContractName | `${CodingContractName}`>(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan of having two versions of the function, but I think trying to collapse their signatures would be even worse.

Note that the TSDoc here is only applying to the first version; the second one is undocumented. It pains me to duplicate all the documentation for the other version, but I think you're gonna have to. The docs about the forth parameter should only go on the second override ofc. Also, you should mention that it's only useful for people writing native Typescript code.

Honestly, the whole thing leaves a very sour taste in my mouth, because it feels awkward and shoehorned in just for TS, and even then, not really quite the right design. The most "natural" design, where all type info would flow automatically and clearly, would be getContract returning a class that had attempt on it. Unfortunately, the way the ns object works makes this tricky, although not impossible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to collapse the signature. It's not that clean, but it could work.
I don't think copying the documentation would be a good idea. The documentation should actually work on all overloads here, just not when filling in the arguments, which is pretty important.
A function on getContract would be a good option, since it avoids all of this. We would run in the same situation as with the port handles. Since the function can only be used up to the maximum tries for any given contract, it should be fine though? Handling the script death would still be a weird quirk.

From what I can tell, it's either merging the signatures or a function on getContract. I'm fine with both suggestions tbh.
If we choose to add a callback there, I would tend to remove the typings on attempt and getData at the same time. I don't like adding it on one, but not the other.
The new signature would be something like this: attempt<T extends CodingContractName | ´${CodingContractName}´ | "" = "">(answer: CodingContractAnswer<T>, filename: string, host?: string, type?: T): string;. It's not that bad IMO, but I could see how it confuses some players.

src/ScriptEditor/NetscriptDefinitions.d.ts Show resolved Hide resolved
src/ScriptEditor/NetscriptDefinitions.d.ts Show resolved Hide resolved
src/ScriptEditor/NetscriptDefinitions.d.ts Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants