nightrunner_lib 0.4.0

A parser library for making text adventure games
Documentation
import {
  FormEvent,
  MutableRefObject,
  useEffect,
  useRef,
  useState,
} from "react";
import { NightRunner } from "@nightrunner/nightrunner_lib";
import "./App.css";

// *** Types ***
//
// Types to make the code more readable. This is not necessary, but it helps
// to understand the code, and since this is typescript, it's a good habit.

/**
 * Props for the App component.
 * @param {NightRunner} engine - The NightRunner instance.
 */
type AppProps = {
  engine: NightRunner;
};

/**
 * ActionResult is a union type of the possible results of an action.
 * An action is a command the player types and is parsed by NightRunner.
 *
 * The messageType is a string that describes the type of the result in
 * the context of an action.
 *
 * @param {string} messageType - The type of the result from the action as interpreted by NightRunner.
 * @param {string} data - When the return messageType from the parser is anything other than an `EventSuccess`, the value will be a string.
 */
type ActionResult = {
  messageType:
    | "look"
    | "inventory"
    | "drop_item"
    | "new_item"
    | "quit"
    | "help"
    | "subject_no_event";
  data: string;
};

/**
 * When the action executed by the player triggers an event, the event result is returned.
 * @param {string} messageType - The type of the result from the action as interpreted by NightRunner.
 * @param {EventSuccess} data -  When the return messageType from the parser is an `EventSuccess`, the value will be an object.
 */
type EventResult = {
  messageType: "event_success";
  data: EventSuccess;
};

/**
 * An EventSuccess type is an object that contains the result of an event.
 * It contains a message field that is a string, with all message_parts concatenated
 * together and can be used for simple layouts that don't require any special
 * formatting.
 * It also contains a message_parts field that is an object with the three main parts
 * of the message returned by NightRunner.
 * templated_words is an array of strings that are marked as template words using the
 * `{}` syntax in the data provided for NightRunner during initialization. The
 * templated words are replaced with the values contained in the `{}` syntax and also
 * added to this array. These words can be used to identify the words that were replaced
 * and can be used for highlighting the words in the display text. The only words that
 * are replaced are items and subjects currently in the player's current location.
 *
 * @param {string} message - The message returned by NightRunner corresponding to the event
 * result.
 * @param {string[]} templated_words - An array of strings that are marked as template words
 * using the `{}` syntax in the data provided for NightRunner during initialization.
 * @param {MessageParts} message_parts - An object with the three main parts of an event message
 * returned by NightRunner.
 */
type EventSuccess = {
  message: string;
  message_parts: MessageParts;
  templated_words: string[];
};

/**
 * A successful event message will contain the three main parts of the message.
 *
 * @param {string} room_text - This will be the text of the corresponding narrative active.
 * @param {string} event_text - Extra messages that might be returned from the event in adition to the narrative.
 * @param {string} exits - The exits of the current room, their directions, and a description of the room they lead to.
 *
 */
type MessagePartKeys = "room_text" | "event_text" | "exits";
type MessageParts = Map<MessagePartKeys, string>;

/**
 * A successful result will be either a string corresponding to the action result
 * or an EventSuccess object corresponding to the event result.
 */
type ResultOk = ActionResult | EventResult;

type ResultError = {
  message: string;
};

/**
 * An error returned by NightRunner will have a string message that can be displayed
 * to the player. We can't anotate an error type in a catch block, so we have to
 * help typescript know that the error contains a message field.
 * We can do this with a type guard function.
 * For more information check the documentation for [Type Guards]{@link (https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-type-assertions)}
 * @param x - The error object returned by NightRunner.
 * @returns boolean - True if the error object has a message field of type string.
 */
const isError = (x: any): x is ResultError => {
  return typeof x.message === "string";
};

/**
 * @returns JSX.Element - An empty div always at the bottom of the event message container.
 * This is used to make the event message container scroll to the bottom when more text is added.
 */
const AlwaysScrollToBottom = () => {
  const elementRef = useRef() as MutableRefObject<HTMLDivElement>;
  useEffect(() => elementRef.current.scrollIntoView());
  return <div ref={elementRef} />;
};

function App({ engine }: AppProps) {
  const firstRoomData: EventSuccess = engine.first_room_text();
  const [input, setInput] = useState("");
  const [roomText, setRoomText] = useState(
    firstRoomData.message_parts.get("room_text") || ""
  );
  const [eventText, setEventText] = useState<string[]>([]);
  const [exits, setExits] = useState(firstRoomData.message_parts.get("exits") || "");
  const [message, setMessage] = useState(firstRoomData.message);
  const [currentIndex, setCurrentIndex] = useState(0);

  const submitAction = (e: FormEvent) => {
    e.preventDefault();
    try {
      let result: ResultOk = engine.parse(input);
      parseResult(result);
    } catch (e) {
      if (isError(e)) {
        eventText.push(e.message);
      }
    }
    setInput("");
  };
  const parseResult = (result: ResultOk) => {
    switch (result.messageType) {
      case "look":
      case "inventory":
      case "drop_item":
      case "new_item":
      case "subject_no_event":
        if (eventText?.length > 0 && result?.data?.length > 0) {
          eventText.push("\n");
        }
        if (result?.data?.length > 0) {
          eventText.push(result.data);
        }
        break;
      case "help":
        alert(result.data);
        break;
      case "event_success":
        const new_room_text = result.data.message_parts.get("room_text") || roomText;
        const new_event_text = result.data.message_parts.get("event_text") || "";
        const new_exits = result.data.message_parts.get("exits") || exits;
        setRoomText(new_room_text);
        setExits(new_exits);
        setMessage(message + "\n\n" + result.data.message);
        if (new_event_text?.length > 0) {
          setEventText([...eventText, new_event_text]);
        } else {
          setEventText([]);
        }
        break;
    }
    // This is here so you can see the result of the action in the console for
    // learning purposes.
    console.log(result);
  };

  const renderEventText = () => {
    return eventText.map((text, i) => {
      return <div key={i}>{text}</div>;
    });
  };

  const renderSeparateAreas = () => {
    return (
      <div>
        <h1>Using each message part in their own area</h1>
        <div className="centered">
          <div>Room message</div>
          <pre className="room-message display-area">{roomText}</pre>
          <div>Event Message</div>
          <pre className="event-message display-area">
            <div>
              {renderEventText()}
              <AlwaysScrollToBottom />
            </div>
          </pre>
          <div>Exits</div>
          <pre className="exits display-area">{exits}</pre>
        </div>
      </div>
    );
  };

  const renderSingleArea = () => {
    return (
      <div>
        <h1>Using separate message parts in one single area</h1>
        <div className="centered">
          <pre className="display-area single-area">
            <div>{roomText}</div>
            <br />
            <div>{renderEventText()}</div>
            <br />
            <div>{exits}</div>
            <AlwaysScrollToBottom />
          </pre>
        </div>
      </div>
    );
  };

  const renderMessageNoParts = () => {
    return (
      <div>
        <h1>Using the full message without extra parts</h1>
        <div className="centered">
          <pre className="display-area single-area">
            <div>{message + "\n\n" + eventText.join("\n")}</div>
            <AlwaysScrollToBottom />
          </pre>
        </div>
      </div>
    );
  };

  let displays = [
    renderSingleArea(),
    renderSeparateAreas(),
    renderMessageNoParts(),
  ];

  return (
    <div className="App">
      <header className="App-header">
        {displays[currentIndex]}
        <form onSubmit={submitAction}>
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            required
          />
          <button type="submit">Send</button>
        </form>
        <button
          style={{ marginTop: "15px" }}
          onClick={() => {
            if (currentIndex < displays.length - 1) {
              setCurrentIndex(currentIndex + 1);
            } else {
              setCurrentIndex(0);
            }
          }}
        >
          Toggle message parts display
        </button>
      </header>
    </div>
  );
}

export default App;