Readthrough mode available for TADS games

Readthrough mode is now available for any author to use! Currently the code is adv3 only, but I hope to get a Lite version too (or find an amazing soul that wants to do the conversion).
[Edit: it’s now available]

Readthrough mode is intended for two main purposes.
One: if you are bringing in new testers and you only need them to test later parts of the game, this mode will help them get briefed on the rest of your story in the process of whisking them to the point where you need the work.
Two: bringing players in to your (finished) game who might otherwise never try it because of its length, its puzzle difficulty, their puzzle disinterestedness, or general parserphobia.
The mode seems to me to be a particularly favorable parser introduction to those who are leery of or daunted by parser games, because not only can they see the game play out its commands without effort on their part, they can also dabble freely with entering their own commands with no risk, since the next time they enter a blank command the readthrough takes over again.

I have posted about this previously, but for those who missed it, this is a mode where a player can launch the game, and simply hit the Enter key (with a blank command line) to watch the game play the next step. They can proceed all the way through the game in this manner. However, the game allows the player to enter their own commands as well, with the understanding that the next time a blank command is entered, their autonomous actions will all be undone and the readthrough will proceed from where it left off. They can, however, exit the mode entirely at any point in the readthrough sequence, in which case they can play autonomously from the state that the game was left in. This could be particularly helpful for testers.

‘Undo’ can get a little squirrelly in certain situations, so some issues may turn up there, but I hope someone might give it a whirl!

It should be able to be incorporated into any existing adv3 game. All the author needs to do is supply a list of command strings (which is probably most easily accomplished by using the RECORD feature built into TADS… the code can load the strings from a RECORD file) and perhaps determine how they want to present the option of launching the game in readthrough mode versus normal mode.
If you have randomization in your game, and you need to forcibly move characters or set data, you can include anonymous functions in your step list, which will be executed directly before the following command.

// in a RECORD file: a string that will be compiled by DynamicFunc
>east
function { igor.moveIntoForTravel(igorsLair); }
>ask igor about himself

// OR steps explicitly typed: can use an actual function pointer

['east',
function { igor.moveIntoForTravel(igorsLair); },
'ask igor about himself' ]

The code will be posted below, but I’ll give an overview of what the code is made of in order to make it work.

We create a readthru object, to store various data pertaining to the mode. Most of the principal mechanics occur in a modification of readMainCommandTokens. Undo causes lots of weird situations so we need to modify UndoAction. libGlobal needs a couple of mods, and we need some verbs for starting the mode, and also for leaving it if the player chooses to continue autonomously from a given point. We also need to add one change to executeAction to have control over whether the library calls savepoint() or not.

// Your project needs to #include dynfunc.t

   //convenience functions
savepointOn() {	libGlobal.suspendUndoSave = nil; }
savepointOff() { libGlobal.suspendUndoSave = true; }

modify libGlobal
	readthruMode = nil
	suspendUndoSave = nil
;

readthru: PreinitObject
             // if this is nil, it is to be assumed that the author has 
             // manually typed the list of command strings in the [steps] property
	loadStepsFromFile = true
	stepFileName = 'rthSteps.txt'
	verticalSpaceAfterCmd = '<.p>'	//users may prefer '\n' or something custom
	lookAfterUndo = nil
		//* any initial tokens not found in this list will cause the mode to suspend
                // savepoint(), and will revert the game to the state at the last readthrough 
                //step the next time there is an empty command or an explicit 'undo'
	excludeTokens = ['save','restore','restart','hint','script']
		//* optional text to display describing the mode the player is entering
	infoBlurb = ""
            //* this property could potentially be filled out as a list of single-quote strings,
            // but most likely an author will just play the game in RECORD mode
            // and the game will load this property from the resulting file
    steps = static new Vector(1200)
            //* this method should be overridden to determine how the game starts. You might
            // have a sort of welcome screen, or a single interactive prompt upon game start that
            // asks which mode to play in... at any rate, this method will be called after the player
            // enters 'readthrough', so you need to make sure the playerChar ends up in the
            // starting loc and gets a lookAround, situated ready to execute the first command
            // in the readthrough steps
	transitionToGame { }
            // this should be defined as a condition, based on the way you present the opening
            // of your game. Perhaps (gPlayerChar.isIn(welcomeScreen)) or !gRevealed('tag').
            // Or you could explicitly set this to nil in transitionToGame
	gameNotBegun = true
	execute() {	
		if(!loadStepsFromFile) return; 
        local f = File.openTextFile(stepFileName,FileAccessReadWriteKeep); 
        local line;
        while((line = f.readFile()) != nil) {
            if(!line.startsWith('\>') && !line.startsWith('function')) continue;
            steps.append(line);
            }
		f.closeFile();
    }
    launch() {
	   if(gameNotBegun) {
            libGlobal.readthruMode = true;
			infoBlurb;
                        // you may wish to call a pause here
			transitionToGame;
		}
	    else "<<if libGlobal.readthruMode>>Readthrough mode is currently running... 
			<<else>>Readthrough mode can only be launched at game startup... ";
    }
    stepCt = 1 //* the next step waiting to be executed
    needsUndo = nil
    needsDecr = nil
	scriptedUndo = nil
    lastCmd = ''
	shownSaveNotice = nil

    // infoBlurb = "\ \b\b\b<.p>Welcome to the readthrough! We wish to brief you on the fundamentals of experiencing the game in this mode. <.p>To advance, all you have to do is leave the command line blank and hit [Enter]. You could proceed through the whole game in this manner. However, if at any time you want to take the liberty of entering your own commands, to see what would happen if such-and-such, you can do so. In fact you can do so for as long as you please, but be aware that the next time you hit [Enter] with a blank command line, the game will undo all of your autonomous decisions, and continue from where it left in the auto-play sequence. As a further \"however\", if you use auto-play to reach a certain point in the game and then you decide you want to continue autonomously, you may enter READTHROUGH OFF, after which point you will not be returned to the auto-play sequence. You cannot reenter readthrough mode from the middle of a game, but you may save readthrough games where they are in order to branch off in normal play mode. <<inputManager.pauseForMore(true)>><.p>One final note is that the readthrough is not meant to be the same as a blazing-fast ultra-minimal walkthrough. It plays more so like a real player, who would have to examine things to get clues, come back to certain locations later after they\'ve figured out what they need to do there, and maybe do a few silly things for fun. <.p>With that, we proceed! <<inputManager.pauseForMore(true)>>"
;
DefineIAction(Readthrough) 
	actionTime = 0
	execAction() 	{ readthru.launch; } ;
	VerbRule(Readthrough) ('start'|'begin'|) ('slideshow'|'slide''show'|'slides'|'auto''play'|'autoplay'|'auto-play'|'readthrough'|'readthru') : ReadthroughAction 
;
DefineIAction(ReadthroughOff)
    actionTime = 0
    execAction { libGlobal.readthruMode = nil;
        "We have exited auto-play mode. You can continue the game from here with complete autonomy. You will not be able to reenter auto-play mode without restarting it from the beginning. If you wish to remain in auto-play mode, or wish to save the auto-play game before entering autonomous mode, enter UNDO now. "; } ;
    VerbRule(ReadthroughOff) (('slideshow'|'slide''show'|'slides'|'auto''play'|'autoplay'|'auto-play'|'readthrough') ('mode'|) 'off') | ('leave'|'end'|'quit'|'stop')('slideshow'|'slide''show'|'slides') ('mode'|) : ReadthroughOffAction ;

replace readMainCommandTokens(which) { 
        local str; local toks;
        for (;;) {
            if(readthru.scriptedUndo) {
                readthru.scriptedUndo = nil;
                str = ''; //* we just came from performUndo and want to bypass getting input to execute the next readthrough step
            }
            else str = readMainCommand(which);
            str = StringPreParser.runAll(str, which);
            if (str == nil) return nil;
            try toks = cmdTokenizer.tokenize(str);
            catch (TokErrorNoMatch tokExc) {
                gLibMessages.invalidCommandToken(tokExc.curChar_.htmlify());
                continue;
            }
            if (toks.length() != 0) { 
                if(libGlobal.readthruMode && !readthru.excludeTokens.indexOf(toks[1][3].toLower()) ) {
                    if(toks[1][3].toLower()=='undo') { 
                        if(libGlobal.suspendUndoSave) ; //* just return the tokens: we'll jump back to the last game state in the auto-play sequence
                        else readthru.needsDecr = true; //* They want to back up the actual readthrough step: need extra measures so that the step counter doesn't go out of sync
                    }
                    else {  //* They entered a regular command during readthrough mode. Save the state now and then turn off savepoint so they can enter commands at will, and still be returned to the auto-play sequence
                        readthru.needsUndo = true;                 
                        if(!libGlobal.suspendUndoSave) savepoint();
                        savepointOff();                        
                    }
                }
                return [str, toks];
            }
				//* We have an empty command string. Special handling for readthrough mode; otherwise show the standard emptyCommandResponse 
            else if(libGlobal.readthruMode) { 
                if(readthru.needsUndo) undo();
                readthru.needsUndo = nil;
                savepointOn();
                local str = readthru.steps[readthru.stepCt];
                    //* process any functions in the step list, and then get the next command
                while(dataType(str)==TypeFuncPtr || dataType(str)==TypeSString && str.startsWith('function')) {
                    if(dataType(str)==TypeSString) str = Compiler.compile(str);
                    str();
                    str = readthru.steps[++readthru.stepCt];
                }
					//* if we used RECORD to make our step list, our string will be prefixed with '>' and suffixed with '\n'. Remove these characters before sending the command to the parser. 
                if(readthru.loadStepsFromFile) str = str.substr(2,-1);
					//* print comments without taking an action cycle
                if(str.startsWith('*')) { "<b><<str>></b>"; 
                    ++readthru.stepCt;
                    continue; }
                else {
                    str = StringPreParser.runAll(str, which);
                    if(str==nil) return nil; 
                    readthru.lastCmd = str;
                    "<b>&gt; <<str>></b><<readthru.verticalSpaceAfterCmd>>";
                    toks = cmdTokenizer.tokenize(str);
                    if(toks.length) {
                        ++readthru.stepCt;
                        return [str,toks];
                    }
                }
            }
            gLibMessages.emptyCommandResponse();
        }
}

modify UndoAction
	performUndo(asCommand) {
			//* we need to store some values before we perform undo()
		local s = libGlobal.suspendUndoSave;		
		local nd = readthru.needsDecr;
			//* Users: feel free to sophisticate this. I didn't want to go through all the rex parsing that would be necessary to separate individual commands out of something like '> north. east. south. enter cave' and keep the stepCt in sync
		if(readthru.lastCmd.find('.')) { "<.p>Apologies... we cannot perform an undo after a string of multiple commands. ";
			return nil; }
		if (undo())        {
            PostUndoObject.classExec();
            local oldActor = gActor; local oldIssuer = gIssuingActor; local oldAction = gAction;
            gActor = gPlayerChar; gIssuingActor = gPlayerChar; gAction = self;
            try        {
                if(!s && !libGlobal.readthruMode) gLibMessages.undoOkay(libGlobal.lastActorForUndo, libGlobal.lastCommandForUndo);
				else if(libGlobal.readthruMode) {
						//* A player entered 'undo' after some autonomous commands
					if(s) "<.p>We now return to the last auto-play step. <.p>";
						//* A player has been using the readthrough sequence but wants to back up one
					else "<.p>Undone. \b";
					if(nd) {
						--readthru.stepCt;
						readthru.needsDecr = nil;
					}
					if(!asCommand) readthru.scriptedUndo = true; 
				}
                if(!libGlobal.readthruMode || readthru.lookAfterUndo) libGlobal.playerChar.lookAround(true);
            }
            finally { gActor = oldActor; gIssuingActor = oldIssuer; gAction = oldAction; }
            if (asCommand)  AgainAction.saveForAgain(gPlayerChar, gPlayerChar, nil, self);
			savepointOn();
            return true;        }
        else        {
            gLibMessages.undoFailed();
            return nil;       }
		}
;

modify executeAction(targetActor, targetActorPhrase,issuingActor, countsAsIssuerTurn, action){
		local rm, results;
	startOver:
		rm = GlobalRemapping.findGlobalRemapping(issuingActor, targetActor, action);
		targetActor = rm[1];
		action = rm[2];
		results = new BasicResolveResults();
		results.setActors(targetActor, issuingActor);
		try    {
			action.resolveNouns(issuingActor, targetActor, results);}
		catch (RemapActionSignal sig){
			sig.action_.setRemapped(action);
			action = sig.action_;
			goto startOver;    }
		if (action.includeInUndo
				&& action.parentAction == nil
				&& (targetActor.isPlayerChar()
					|| (issuingActor.isPlayerChar() && countsAsIssuerTurn))
				&& !libGlobal.suspendUndoSave)	{  						// ADDED
			libGlobal.lastCommandForUndo = action.getOrigText();
			libGlobal.lastActorForUndo = (targetActorPhrase == nil ? nil : targetActorPhrase.getOrigText());
			savepoint(); }
		if (countsAsIssuerTurn && !action.isConversational(issuingActor)) {
			issuingActor.lastInterlocutor = targetActor;
			issuingActor.addBusyTime(nil,issuingActor.orderingTime(targetActor));
			targetActor.nonIdleTurn();}
		if (issuingActor != targetActor
				&& !action.isConversational(issuingActor)
				&& !targetActor.obeyCommand(issuingActor, action))    {
			if (issuingActor.orderingTime(targetActor) == 0)
				issuingActor.addBusyTime(nil, 1);
			action.saveActionForAgain(issuingActor, countsAsIssuerTurn,targetActor, targetActorPhrase);
			throw new TerminateCommandException();    }
		action.doAction(issuingActor, targetActor, targetActorPhrase,countsAsIssuerTurn); }

PreSaveObject 
	execute {
		if(libGlobal.suspendUndoSave) { 
			if(libGlobal.readthruMode && !readthru.shownSaveNotice) {
				say('<.p>You have entered autonomous commands since the last readthrough step. We regrettably cannot save autonomous changes if the game is to stay in readthrough mode. If you wish to save your autonomous changes, you can first enter READTHROUGH OFF, and then SAVE... just note that you will be unable to reenter readthrough mode from that point. If you wish to save the game in readthrough mode, enter SAVE again, but be aware that we will undo the game to the state it was in at the last readthrough step. <.p>');
				readthru.shownSaveNotice = true;
			}
			else if(libGlobal.readthruMode) "<.p> We return the game to the readthrough sequence before saving... <.p>";
			else "<.p>Undoing to the last savepoint before saving the game... <.p>"; 
			undo();  
			savepointOn();
		}
	}
;

P.S. If you use a RECORD file, manually remove any meta commands that appear while turning it on or off… and make sure the file ends with a final blank line, because the code as it is trims the last character off of every line, expecting a \n. And of course, put the file in the same folder as your t3 file, so it can find it at compile time/preinit. The file shouldn’t be needed once the game is built.

9 Likes

Good work. After you first mentioned this idea, I started adding it to my WIP in Inform. Though I haven’t got any rewind/resume yet.

I’m now curious how you handled the resume point. Does TADS have facility for filing away a snapshot of the game? Or does it have unlimited ‘internal’ undos? Or do you save a file of the game state every turn while someone’s in readthrough mode so you can jump back to it?

The only way I can envisage doing this in Inform is saving the game to a file every turn.

-Wade

2 Likes

Indeed it does. You have only to call savepoint() and the game’s state is saved to the VM. The next time that any code calls undo(), the state at the last savepoint will be restored. In my approach (not sure if you can read TADS code or not?), the game turns off the library’s normal routine of calling savepoint() after every action if the player starts entering their own commands. So the player can continue entering their own commands, and although the game state is changing as they do so, the last saved state is not changing. The next time the token reader finds an empty command, it calls undo(), voilá, we’re back where the readthrough left off, and it can immediately execute the next readthrough step.

We’ve recently had a discussion about undo capacity for TADS runners. No, it’s not unlimited… but it sounds like Gargoyle and Parchment have recently updated their capacity. Unfortunately I am not sure if this holds for QTads, and I am sorry to report that a game the size of mine (pretty ridiculously large) has only two undos available (in QTads) at the beginning of the game, and towards the end of the game, undo capacity is lost entirely.

3 Likes

Another note!
If you are including some PC deaths in your readthrough, do not add ‘undo’ to the steps list! The player input for finish options is not handled through readMainCommandTokens, but through the processOptions function.
So don’t do this:

>take rat poison
>drink it
>undo
>out

But rather modify processOptions something like this:

modify processOptions(lst) {
 promptLoop:
    for (;;) {
        local resp;
		local rthr = libGlobal.readthruMode;
        if(!rthr || readthru.needsUndo /* || READTHROUGH IS OVER COND*/) finishOptionsLister.showListAll(lst, 0, 0);	
        "<.commandbefore>";
        statusLine.showStatusLine();
        resp = inputManager.getInputLine(nil, nil);
                // don't force the player to type in 'undo'... fill it out as such 
                // if we get a blank input
		if(resp=='' && rthr /* && ///////////some condition that indicates that game end has not yet been reached*/ ) { 
              "<b>&gt; undo</b>\b"; resp = 'undo'; }	
        "<.commandafter>";
        foreach (local cur in lst)        {
            if (cur.responseMatches(resp)) {
                if (cur.doOption()) {
                    continue promptLoop;
                }
                else { return; }
            } }
        gLibMessages.invalidFinishOption(resp);
    }
}
1 Like

Right. Yes, I realise I can do the same thing in Inform, just stop the undo point from updating while the player is in ‘mucking around’ mode.

Yeah, I think everyone has this problem. Big games have few or no UNDOs, eventually. I feel like I/we need to kick and shout about this! I will start shouting again, soon.

-Wade

3 Likes

John, on undo, if you save your game, how big is the savefile ?

my WIP currently generates a 81,330 bytes savefile and I still can do tens of undos, under Qtads.

OTOH, if you remember my post in the iterative saves debate, you will understand my suggestion: instead of a fixed stepFileName, asking the filename (or taking it from the input, as in SAVE “savename.sav”), allowing multiple step files.

I’ll try this extension, for sure.

Thanks for your effort in this extension, whose usefulness to my WIP can be second on to an eventual porting of ProxyActor to adv3…
dott. Piergiorgio.

2 Likes

My save file is 3.6 MB, and that’s near the beginning of the game when there are two undos available… at the end it grows large enough that undo becomes available. I don’t know how large the save is then…

Sorry, P… I’m not sure I remember what you’re talking about. In this case—loading the readthrough steps—, I don’t think it’s very important to do a filename prompt, because the file is something that will only be needed at compile time, and only the author sees it.

If you copied/downloaded the extension code prior to this morning, I’ve already found one typo (this always happens to me when I extract code from my own mess and try to generalize it). There was a place where there is a call to savepointOff which lacked parentheses. That spot needs to be changed to savepointOff(), or else recopy the code from this post (which has been updated).

@Piergiorgio_d_errico Oh, I missed part of your post… you’re saying that the author might create several different playthroughs, and the player can select which one to use? Well certainly, go ahead and modify the module however you like! I can see where a filename prompt would be needful then. I didn’t even think about multiple different readthroughs, but that’s probably because my game can pretty much hit all of the content in one playthrough.

1 Like

Wade, I’d be interested to keep updated on your progress with the Inform version. Is it going to be baked into your game, or will it be a module that other authors can use? Maybe we can sort of collaborate to keep the functionalities of the two versions in sync, for instance if you think of other features to add that aren’t currently in mine, or think that certain behavior should be changed.

1 Like

An important omission in the original post… your game needs to #include dynfunc.t…

1 Like

Please don’t do that. Simply add it as a source in your project makefile (.t3m) :stuck_out_tongue:

-source dynfunc.t
2 Likes

Are TADS authors not supposed to use include statements…? I’m confused by what you mean here.

1 Like

*.t files don’t have multiple-inclusion guards (#pragma once). They are not supposed to be included. Only header files are (.h).

Also, header files never define anything that produces link-time symbols. They only declare things, making them safe to include in different translation units (each generated object file is the result of compiling one translation unit.)

In other words, TADS uses “seperate compilation”. See:

http://tads.org/t3doc/doc/techman/t3inc.htm

If you #include .t files, it will work, but if you end up including the same .t file somewhere else as well, linking will fail, and the error message is not exactly helpful:

error: symbol "CompilerException" (type: object) redefined in object file "obj/main.t3o"

If a user ever ends up doing this by mistake in a large project and is not explicitly aware of the issue, you can imagine the confusion.

If you ever programmed in a language like C or C++, the reason why you shouldn’t #include .t files is basically the same as the reason you shouldn’t #include source files (.c, .cpp) in those languages.

3 Likes

@johnnywz00 Right now it’s baked into the game. I’m not great at making things modular/portable. Also, my game is a bit atypical for Inform. I’m using the Unified Glulx Input extension (UGI) which includes some low level rewriting of the parser. And based on UGI, I have single-keypress choice interludes, so this readthrough mode caters for those as well. I don’t know if UGI even runs in the current version of Inform. I’ve had to stay with version 6M62 because this is year four on this game.

That said, there wasn’t much code to write overall. I just had to get the finicky timing of when it does various things right. I haven’t added the ability to muck around and rewind, yet, but I will.

The behaviour of it overall so far is:

  • If the mode’s on, the command it will enter next appears before the prompt. Press enter to accept it. Or you can type the same command yourself if you feel like it.
  • Entering out of world commands won’t break out of the mode. e.g. You can SAVE, or use an out of world command like GOALS.
  • Otherwise, if you type a different command, you just leave the mode.

-Wade

2 Likes

Thanks for pointing this out, Nikos. The makefile is actually how I included dynfunc in my game, and what I really meant was to include DynamicFunc in the project, not specifically to #include it, although I wasn’t thinking about the hazards of actually using #include. So thanks for the catch.

1 Like

Wow, so you’ve got a four-year game too! Interesting that you’ve got some of that low-level control… one thing that bugs me is that I don’t have a way to paste in the readthrough command to the input line without making a custom interpreter…

1 Like

While I’ve “got” you, is there any possibility of increasing undo capacity for QTads??

1 Like

Hopefully this week.

3 Likes

Wow, that’s fantastic!

1 Like