💾 Archived View for thrig.me › blog › 2024 › 05 › 12 › trek-input-handling.gmi captured on 2024-05-26 at 14:45:52. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2024-05-12)

🚧 View Differences

-=-=-=-=-=-=-

trek(6) Style Input Handling

trek(6) is based on an earlier teleprinter game and is still playable on such; output to the user is text, and commands from the user are read as lines. Unlike a teleprinter where there may have been two different ribbons to distinguish what the computer printed from what the user typed (and had echoed back to them) there is no distinction in trek(6) between input and output. At least for the version I have on OpenBSD; there are several varieties of this game, and some could easily gussy up the input or output to make it look different. Gemtext also does not have a means to show the difference between input and output, but you can probably guess from the following.

    Press return to continue.
    What length game: short
    What skill game: impossible
    31 Klingons
    1 starbase at 4,2
    It takes 1150 units to kill a Klingon

Line input means the user has to type a full line and press enter before the game does anything; there is no "tab" support as found in fancy shells that suggest completions for you. Tab completion would require raw input so that individual keys typed can be responded to. There is some but not much ability to edit what has been typed on a line; one way to simulate this mode is to run cat(1) and then see how much you can edit what you type. Control+w or control+u or control+d are probably interesting here, as well as the output of the "stty -a" command in the unlikely event the previous controls have been bound to other keys. But this is not much if you're used to a fancy text editor or shell with features.

    $ stty -a | fgrep \^
    cchars: discard = ^O; dsusp = ^Y; eof = ^D; eol = <undef>;
            eol2 = <undef>; erase = ^?; intr = ^C; kill = ^U; lnext = ^V;
            min = 1; quit = ^\; reprint = ^R; start = ^Q; status = ^T;
            stop = ^S; susp = ^Z; time = 0; werase = ^W;

Linewise input can be made less bad—that is, easier to type—by accepting abbreviations, so instead of "computer\n" "chart\n" one can simply type "c ch\n" to pull up map details. This makes for faster gameplay and back in the days of 300 baud modems would have been even more important.

    Command: c ch
    Computer record of galaxy for all long range sensor scans

      -0- -1- -2- -3- -4- -5- -6- -7-
    0 ... ... ... ... .1. ... ... ... 0
    1 ... ... ... ... ... ... ... ... 1
    2 ... ... ... ... ... ...   2   1 2
    3 ... ... ... ... ... ...   4 $$ 3
    4 ... ... ... ... ... ...   4   1 4
    5 ... ... ... ... ... ... ... ... 5
    6 ... ... ... ... ... ... ... .1. 6
    7 ... ... ... ... ... ... ... ... 7
      -0- -1- -2- -3- -4- -5- -6- -7-

The abbreviations are hard-coded into the source, so there's no fancy string completion available—nor i18n for folks who do not English—though the code will match on "char" short for "chart" or "war" short for "warpcost" and the commands look like care has been taken that there are not a lot of commands with similar names. If new commands were added with conflicting names experienced players relying on motor memory would be in for a hard time. A good programmer would try to minimize conflicts if they did add new command names.

    struct cvntab   Cputab[] =
    {
            { "ch", "art",         (cmdfun)1,  0 },
            { "t",  "rajectory",   (cmdfun)2,  0 },
            { "c",  "ourse",       (cmdfun)3,  0 },
            { "m",  "ove",         (cmdfun)3,  1 },
            { "s",  "core",        (cmdfun)4,  0 },
            { "p",  "heff",        (cmdfun)5,  0 },
            { "w",  "arpcost",     (cmdfun)6,  0 },
            { "i",  "mpcost",      (cmdfun)7,  0 },
            { "d",  "istresslist", (cmdfun)8,  0 },
            { NULL, NULL,          NULL,       0 }
    };

Line input has the advantage of being fairly simple to implement as compared to editline(3) or readline libraries, e.g. simply use getline(3) in C, or similar calls in various other languages.

    $ perl -e 'print "Name? "; print scalar readline'
    Name? Bob
    Bob
    $ sbcl --noinform --eval \
      '(progn (princ "Name? ") (finish-output) (princ (read-line)))'
    Name? Bob
    Bob
    * (quit)

The main difficulty comes from tokenizing the input into words and looking them up in some sort of equivalent to the trek(6) "cvntab" structure, with support for abbreviations or similar "is this input unique?" checks, and the ability to chain commands on a single line, and so forth. The ability to type commands ahead is usually a good thing, though rogue(6) does have an option to disable typeahead as monsters can show up unexpectedly and murder you while your old commands are still walking you somewhere.

Bad Interface Sidequest

An inability to type ahead can be bad, as you may need to wait for the interface, confirm that the correct widget has focus, and only then type what you may have forgotten after all the pop-ups and whatnot were busy distracting you and wasting CPU cycles. Can you interact with the computer at your speed of thought, or is it forever throwing things in your way? Here is a sketch that illustrates some of the problems of a bad interface.

    // badui - a bad user interface with latency and such
    #include <ctype.h>
    #include <curses.h>
    #include <locale.h>
    #include <stdlib.h>
    #include <time.h>
    // This may need to be set higher if you are a very slow typer.
    #define NOISE 20
    
    void latency(void) {
        int n = NOISE * 2;
        while (1) {
            int r = rand() % NOISE;
            if (r) {
                n += r;
                break;
            } else n += NOISE;
        }
        napms(n);
    }
    
    int maybe(int odds) { return rand() % 100 < odds; }
    
    void prompt(void) {
        while (1) {
            move(0, 0); clear();
            addstr("Are you sure? [Y/N]");
            if (maybe(20)) latency();
            if (maybe(50)) flushinp();
            int ch = getch();
            if (maybe(1)) continue;
            if (ch == 'Y' || ch == 'N') break;
        }
        move(0, 0); clear();
    }
    
    void spam(void) {
        int y, x;
        getyx(stdscr, y, x);
        for (size_t n = 0; n < 3; ++n) {
            move(LINES - 1, 0); clrtoeol();
            addstr(maybe(75) ? "spam!" : "lovely spam!");
            latency(); clrtoeol();
        }
        move(y, x);
    }
    
    void whoops(void) {
        int y, x;
        getyx(stdscr, y, x);
        if (x) move(y, x - 1);
    }
    
    int main(int argc, char *argv[]) {
        setlocale(LC_ALL, ""); initscr(); keypad(stdscr, TRUE);
        cbreak(); typeahead(-1); noecho();
        srand(time(NULL)); napms(1000 * (3 + rand() % 33));
        addstr("Just type... (or 'Q' to quit)\n");
        while (1) {
            if (maybe(5)) spam();
            latency();
            if (maybe(80)) flushinp();
            int ch = getch();
            if (ch == 'Q' || (maybe(1) && maybe(1))) break;
            if (maybe(5)) continue;
            if (maybe(2)) {
                prompt();
                continue;
            }
            latency();
            if (ch == 127) whoops();
            else if (isprint(ch)) addch(ch);
        }
        endwin();
    }

badui.c

    $ CFLAGS=-lcurses make badui
    cc -lcurses   -o badui badui.c
    badui.c(/tmp/badui-8cd665.o:(latency)): warning: rand() may return deterministic values, is that what you want?
    $ ./badui
    ...

Improvements would be to move the "spam" routine into a distinct thread (or to unblock the input loop) so that spam can happen at any time, all the time, and to add even more distracting animations and beeps and whatnot. Maybe some people like looking at spinning dots animated in a circle while the very modern and very fast computer is stuck doing who knows what?

My opinion is that notifications and latency (both startup and interactive) should be minimized or eliminated, though there will be some cases where typeahead must be discarded, typically when the situation has changed. In a game this may be when additional Klingons show up, or maybe the filesystem is really full and the program really does need to warn about that. I'm more in the "let the computer (or my character) die rather than distracting me about it" camp.

Matching Words

A trie (or similar) can find matching words for a given input, e.g. where the prefix "com" might find "computer" and "command" actions, though another option is to copy the trek(6) abbreviation and full command form. Commands are usually limited and thought about, so can live in a table, while a trie may better suit the case of a lot of random words. Another concern is how many commands have been typed in: if someone has typed "computer chart", then it makes little sense to show them the computer's "Request: " prompt when there is already the "chart" command provided. Thus, the "read the input" and the "prompt for commands" code probably need to share a bit more information than just the words as they come in.

    Command: computer

    Request: chart
    Computer record of galaxy for all long range sensor scans
    ...
    Command: computer chart
    Computer record of galaxy for all long range sensor scans

Note the lack of a "Request" prompt when the "chart" sub-command was supplied on the same line as the "computer" command. trek(6) does this by checking whether the player's input is at a newline, or not, or at least I think that's what the funny business in the testnl function is doing. Memory was hilariously limited back then, so the code is not carrying around much state. A modern take might be:

    #!/usr/bin/env perl
    # read-words - prompt for words from standard input
    use 5.36.0;

    {
        my @words;

        sub getaword {
            unless (@words) {
                my $line = readline *STDIN;
                die "whoops no more input" unless defined $line;
                @words = split ' ', $line;
            }
            my $next = shift @words;
            return $next, $#words;
        }
    }

    my $remain = -1;
    while (1) {
        print "Request: " if $remain < 0;
        ( my $command, $remain ) = getaword;
        exit if $command =~ m/(?i)^term/;
        say "Command: ", $command;
    }

This only prints the prompt when there aren't any more words to be had, though does not handle an unexpected end-of-file as well as it could: a prompt is printed, but then the EOF condition is raised.

    $ perl read-words
    Request: test
    Command: test
    Request: test with more words
    Command: test
    Command: with
    Command: more
    Command: words
    Request: term

A downside of the split function in the above code is that you need enough memory to store what could be quite a lot of words; however, the split function is easier than custom code to carve off words from the input line one by one, and users probably will not paste in millions of words in one go. Until they do.

Or, in LISP, without a split but thus more complicated input parsing:

    ; PORTABILITY On the wild assumption that the device used supports these.
    (defconstant +norm+ (format nil "~C[m" #\Esc))
    (defconstant +bold+ (format nil "~C[1m" #\Esc))
    (defconstant +undr+ (format nil "~C[4m" #\Esc))
    
    (let ((buffer "") (start 0) (end 0))
      (declare (string buffer))
      (flet ((isblank (item ch)
               (declare (ignore item))
               (or (char= ch #\space) (char= ch #\tab)))
             (notblank (item ch)
               (declare (ignore item))
               (char/= ch #\space #\tab)))
        (defun read-token (prompt &aux result)
          "maybe prompts the user and returns the next token via READ-LINE, or throws an error"
          (declare (string prompt))
          (tagbody
            (when (plusp start) (go TOKEN))
            (format t "~&~a " prompt)
           REDO
            (unwind-protect
                (progn (princ +bold+)
                       (finish-output)
                       (setf buffer (read-line)))
              (princ +norm+))
            (setf start (position #\x buffer :test #'notblank))
            (unless start (go REDO))
           TOKEN
            (setf end (position #\space buffer :start start :test #'isblank))
            (if end
              (progn
                (setf result (subseq buffer start end))
                (setf start (position #\x buffer :start end :test #'notblank))
                (unless start (setf start 0)))
              (progn
                (setf result (subseq buffer start))
                (setf start 0))))
          (values result start))))
    
    (loop (format t "~a, " (read-token "Command: ")))