← All work · 2024 – now
ZorkAI
Zork I, rebuilt with an AI narrator
The game that started Infocom, reimplemented from scratch with every room, object and puzzle intact, and a parser that finally understands what you meant. When you try something the 1980 game never anticipated, an AI narrator answers in character.

- 3,400+
- tests pinning down original behavior
- 700+
- commits since March 2024
- 300+
- merged pull requests
- 2
- games live on one engine
- What I did
- The architecture, the game engine, the decision about when the model is and isn’t allowed to speak, and, in 2024, every line of code.
- What AI did
- The narrator, in production. Since 2025, AI agents write much of the new code against the test suite, and I review it.
Why I built it
I LOVE Infocom games. In my youth, they gave me hours of entertainment, and began my love of personal computing.
I still collect and play them. But for anyone who didn’t grow up with text adventures, they can feel like relics: you type something perfectly reasonable and the game answers That sentence isn’t one I recognize. I wanted to make them feel richer, deeper and more alive for a new generation, without changing what made them great.
There are plenty of fully AI-generated dungeons out there. That wasn’t what I wanted. I didn’t want new games. I wanted every setting, location, story beat, joke and puzzle of the originals preserved, and an AI that made the interactivity deeper: a parser that understands what you type, and a narrator who answers anything, in character, without breaking the spell.
I started with Zork because it’s the best known and one of the simplest. What I really wanted was Planetfall.
The rule: the model speaks last
The obvious design is to put an LLM in front of everything. That would be slow, expensive and, worst of all, unfaithful: a model improvising Zork is not Zork. So every command goes through four layers, and the model is the last resort:
- System commands like save, restore and inventory are handled immediately.
- The game’s own parser handles everything it can, deterministically, with the original text.
- An AI parser maps natural language the game can’t handle onto real game actions.
- An AI narrator answers in character only when no action applies.
When a command advances the story or changes the game, you get the original response, exactly as written. The AI only steps in when it can add something without breaking the game. AI enhancement, not AI replacement.
One of my earliest notes on the project put the whole philosophy in a sentence: the kitchen description mentions a table, so you should be able to interact with it, but it must produce no state change.
What that feels like
Try to pull the mailbox out of the ground.
- Zork, 1980: That sentence isn’t one I recognize.
- ZorkAI: It seems the postal service invested in anti-theft roots.
Kick the mailbox. Tell the game you’re bored. Try to fly away to Canada. The narrator is an exasperated, sarcastic guide who knows exactly where you are and what you’re carrying, and it won’t let you take the house.
Getting the voice right
The code to call a model is simple. The hard part, and the reason it works, is the prompts. In the first months I spent hours tuning them, and the parser went through generations: AWS Lex, which was barely better than Infocom’s original, then Claude for understanding input, with a separate model writing the narration.
The hardest problem turned out not to be wit. It’s restraint. A narrator that doesn’t know an object hasn’t been built yet doesn’t go quiet; it invents something confident and plausible, and an unfinished room starts to feel like a different game. So the engine now counts every time a command falls through to the narrator, and tests drive that number to zero.
Engineering it like a product
Each game is its own project on a shared C# engine. Thousands of deterministic tests pin down how the originals behave, room by room and puzzle by puzzle, including full walkthroughs that double as proof the game can still be won. That’s what makes it safe to change anything at all. None of Infocom’s original code is used; everything is rebuilt from the games’ observable behavior.
In production, each game runs as its own AWS Lambda behind a React client, with the entire game state carried in the session, so every turn is stateless. When a player asked to play offline, the whole stack learned to run locally in Docker against any OpenAI-compatible model server, including Ollama. No cloud account required.
How it’s built now
In 2024 I wrote every line. Today most new code comes from AI agents working against that test suite. I write the issue, sometimes a single line (“implement this, follow all existing patterns, add tests”), and review what comes back. There’s even an AI playtester, AdventureBreaker, whose only job is to break the narrator in production.
One engine, more worlds
Zork I is complete. Planetfall runs on the same engine. A Zork II stub exists mostly to prove how quickly a new game can be added, and a Stationfall port is underway, which I’m building partly so I can finally play it fresh. The agents working on it follow one rule I’m especially fond of: no spoilers for the owner.
Someday, A Mind Forever Voyaging.
426 private async Task<string?> ProcessSingleSentence(string? playerInput) 427 { 428 _currentInput = playerInput; 429 430 // 1. ------- Processor in Progress - 431 // See if we have something already running like a save, quit, etc. 432 // and see if it has any output. Does not count as a turn. No actor or turn processing. 433 var (returnProcessorInProgressOutput, processorInProgressOutput) = 434 await RunProcessorInProgress(playerInput); 435 436 if (returnProcessorInProgressOutput) 437 return PostProcessing(processorInProgressOutput!); 438 439 // After disambiguation resolution, _currentInput may have been updated to the clarified command 440 // Use it for all subsequent processing 441 playerInput = _currentInput; 442 443 // 2. ------- Empty command. Does not count as a turn. No actor or turn processing. 444 if (string.IsNullOrEmpty(playerInput)) 445 return PostProcessing(await GetGeneratedNoCommandResponse()); 446 447 // 2b. ------- "look at <noun>" — and the "look in/inside <noun>" container-inspection phrasings 448 // (issue #396) — are examine synonyms, not the bare-room LOOK command. The AI parser collapses 449 // "look at X" to a noun-less look intent (issues #312 / #283) and mis-tags "look in X" as an "in" 450 // movement ("You cannot go that way."). Rewrite them to the canonical "examine <noun>" here so 451 // they route through the examine-with-noun path (which lists an open container's contents), while 452 // leaving the bare forms ("look", "look around") and genuinely different prepositions ("look 453 // under the rug", "look behind the painting") untouched. Note: because this runs before pronoun 454 // resolution and the LastInput capture below, a subsequent "again"/"g" replays "look at X" as 455 // "examine X" — harmless, since both produce the same examination output. 456 playerInput = NormalizeLookAt(playerInput); 457 _currentInput = playerInput; 458 459 Context.PreviousLocationName = LocationName; 460 461 // 3. ------- System, or "meta" commands - like save, restore, quit, verbose etc. Does not count as a turn. No actor or turn processing. 462 var systemCommand = _parser.DetermineSystemIntentType(playerInput); 463 if (systemCommand is GlobalCommandIntent global) 464 { 465 var globalResult = await ProcessGlobalCommandIntent(global); 466 return PostProcessing(globalResult); 467 } 468 469 // 3b. ------- Context-level command override. Lets a game-specific context fully handle the 470 // raw command before normal parsing — used by Zork's spirit/DEAD state, which overrides most 471 // verbs with canned ghost responses while letting movement and resurrection fall through by 472 // returning null (issue #17). Does not count as a turn; no actor or end-of-turn processing. 473 var contextOverride = Context.InterceptPlayerCommand(playerInput); 474 if (contextOverride is not null) 475 return PostProcessing(contextOverride); 476 477 // Determine up front whether this input resolves to a "free" global command - a 478 // meta/informational verb (look, inventory, score, current time) that must never advance 479 // Context.Moves or a time-based game's survival clock (issue #354). This has to be known 480 // BEFORE Context.ProcessBeginningOfTurn() runs (which does exactly that), so it's computed 481 // here rather than at its usual step-5 spot below - the result is reused there instead of 482 // being recomputed. Actor processing (chase scenes, countdown timers, Floyd, ...) still runs 483 // for free commands further down - the world keeps moving even while the player just glances 484 // at their status; only the player's own turn/survival-clock bookkeeping is skipped. 485 // 486 // "again"/"g" replays the previous command - AgainProcessor.Process (below, at its usual 487 // spot) resolves that later, but we need to know what it WILL resolve to now, so a replayed 488 // free command (e.g. "look" then "g") classifies correctly too. Peeking is side-effect-free; 489 // Process() still runs normally afterward for its real side effects. 490 var replayTarget = _againProcessor.PeekReplayTarget(playerInput!, Context); 491 var earlyGlobalIntent = _parser.DetermineGlobalIntentType(replayTarget ?? playerInput); 492 var isFreeCommand = earlyGlobalIntent is GlobalCommandIntent { Command: IFreeGlobalCommand }; 493 494 // One-shot actor-suppression flags (e.g. Planetfall's FloydShouldNotActThisTurn) must reset 495 // every turn regardless of isFreeCommand: actor processing below always runs, even for free 496 // commands, so a flag it consumes must always get its one-shot reset too - otherwise it leaks 497 // across consecutive free commands and suppresses an actor for longer than intended. 498 Context.ResetPerTurnActorFlags(); 499 500 // Everything below here counts as a turn, unless it's a free command. Pre-process the turn. 501 // See if the context needs to notify us of anything. Are we sleepy? Hungry? 502 var contextPrepend = isFreeCommand ? null : Context.ProcessBeginningOfTurn(); 503 504 // Check if player died during beginning-of-turn processing (e.g., hunger death) 505 if (Context.PendingDeath is not null) 506 { 507 var deathResult = Context.PendingDeath; 508 var deathMessage = deathResult.InteractionMessage; 509 RestartAfterDeath(deathResult.DeathCount); 510 return PostProcessing(deathMessage + Context.CurrentLocation.GetDescription(Context)); 511 } 512 513 // Issue #355: a scheduled event (e.g. Planetfall's forced sleep) consumed this turn during 514 // ProcessBeginningOfTurn, mutating state (dropping carried items, changing location) against 515 // wherever the player was BEFORE their own command ran. Executing that command now - most 516 // dangerously a movement command - would change CurrentLocation again, leaving the narration 517 // and any side effects of the event stranded against a location the response never mentions 518 // again. Short-circuit here, mirroring the PendingDeath check above: report wherever the 519 // player actually is instead of running their command, which is deferred to next turn. Still 520 // routed through ProcessActorsAndContextEndOfTurn so the clock ticks and actors act, exactly 521 // as they would for any other turn. 522 if (Context.TurnConsumedByForcedEvent) 523 { 524 Context.TurnConsumedByForcedEvent = false; 525 return await ProcessActorsAndContextEndOfTurn( 526 contextPrepend, Context.CurrentLocation.GetDescription(Context)); 527 } 528 529 // See if the user typed "again" or some variation. 530 // if so, we'll replace the input with their previous input. 531 (_currentInput, var returnResponseFromAgainProcessor) = _againProcessor.Process( 532 _currentInput!, 533 Context 534 ); 535 if (returnResponseFromAgainProcessor) 536 return PostProcessing(_currentInput); 537 538 // Resolve pronouns from recent player input and game response (BEFORE ItProcessor), UNLESS a 539 // just-completed move left the deterministic engine holding a still-carried antecedent for 540 // this pronoun. After a move, LastInput is the movement command ("north") and LastResponse is 541 // the destination room's description, so the AI resolver would re-bind "it"/"them" to a noun 542 // in the NEW room and lose the carried-item antecedent that MoveEngine deliberately preserved 543 // across the move (issues #248 / #275). In that case we defer to the deterministic ItProcessor 544 // below, which resolves the pronoun from the preserved LastNoun/LastNouns. 545 if (!MoveJustClobberedPronounContext(_currentInput!, Context) 546 && (!string.IsNullOrEmpty(Context.LastInput) || !string.IsNullOrEmpty(Context.LastResponse))) 547 { 548 var resolved = await _parser.ResolvePronounsAsync(_currentInput!, Context.LastInput, Context.LastResponse); 549 if (resolved != null && !resolved.Equals(_currentInput, StringComparison.OrdinalIgnoreCase) 550 && !ResolverConflatedSingularItWithASet(_currentInput!, resolved)) 551 { 552 _currentInput = resolved; 553 } 554 } 555 556 // Track player input for pronoun resolution (AFTER resolution, so next command can use this as context) 557 // Store the RESOLVED input so subsequent pronoun resolution has the actual noun, not the pronoun 558 if (!string.IsNullOrWhiteSpace(_currentInput)) 559 Context.LastInput = _currentInput; 560 561 // 4. ------- Location specific raw commands 562 // Check if the location has an interaction with the raw, unparsed input. 563 // Some locations have a special interaction to raw input that does not fit 564 // the traditional sentence parsing. Sometimes that is a single verb with no 565 // noun like "jump" or "pray" or "echo", or some other specific phrase 566 // that does not lend itself well to parsing. 567 var singleVerbResult = await Context.CurrentLocation.RespondToSpecificLocationInteraction( 568 playerInput, 569 Context, 570 GenerationClient 571 ); 572 // isFreeCommand was classified from raw input TEXT before we knew whether a location would 573 // intercept it here - a location's raw interaction is never actually the free processor, so 574 // an interaction that fires here is always a real turn, regardless of what the input text 575 // happened to look like (e.g. LoudRoom's echo catch-all firing for the literal word "score"). 576 // If we skipped Context.ProcessBeginningOfTurn() based on that wrong early guess, run it now 577 // - late beats never - before treating this as the real turn it actually is. 578 // 579 // Known ordering trade-off: RespondToSpecificLocationInteraction above has ALREADY run (and 580 // any state it mutates has already happened) by the time this catches up, whereas on every 581 // other path in this method ProcessBeginningOfTurn() runs first. In practice this is inert - 582 // the only unconditional-catch-all locations today (LoudRoom, InsideTheBarrow, 583 // CryoAnteroomLocation) have no stateful side effects beyond their message - but a future 584 // catch-all location with one would apply it before this turn's hazard check instead of 585 // after. The visible outcome, a death here, is still handled correctly either way (see 586 // FreeMetaCommandTests.PendingDeath_FromLateBeginningOfTurn_StillOverridesACatchAllLocationInteraction) - 587 // the interaction's TEXT is discarded in favor of the death message - only a stateful side 588 // effect could land "early". 589 if (singleVerbResult.InteractionHappened) 590 { 591 if (isFreeCommand) 592 { 593 contextPrepend = Context.ProcessBeginningOfTurn(); 594 595 if (Context.PendingDeath is not null) 596 { 597 var deathResult = Context.PendingDeath; 598 var deathMessage = deathResult.InteractionMessage; 599 RestartAfterDeath(deathResult.DeathCount); 600 return PostProcessing(deathMessage + Context.CurrentLocation.GetDescription(Context)); 601 } 602 } 603 604 return await ProcessActorsAndContextEndOfTurn(contextPrepend, singleVerbResult.InteractionMessage); 605 } 606 607 // 5. ------- Global commands - these work always, everywhere: like look, inventory, wait and cardinal directions. These DO count as a turn 608 // (except free commands - see isFreeCommand above). We must process actors afterwards regardless. 609 var simpleIntent = earlyGlobalIntent; 610 if (simpleIntent is not null) 611 { 612 var resultMessage = simpleIntent switch 613 { 614 GlobalCommandIntent intent => await ProcessGlobalCommandIntent(intent), 615 MoveIntent moveInteraction => (await new MoveEngine().Process(moveInteraction, Context, 616 GenerationClient)).ResultMessage, 617 _ => null 618 }; 619 620 return await ProcessActorsAndContextEndOfTurn(contextPrepend, resultMessage, isFreeCommand); 621 } 622 623 // Is the player talking to someone? 624 _logger?.LogDebug($"[GAME ENGINE DEBUG] About to check for conversation with input: '{_currentInput}'"); 625 var conversation = await _conversationHandler.CheckForConversation(_currentInput, Context); 626 if (conversation is not null) 627 { 628 _logger?.LogDebug($"[GAME ENGINE DEBUG] Conversation detected, returning response: '{conversation}'"); 629 return await ProcessActorsAndContextEndOfTurn(contextPrepend, conversation); 630 } 631 _logger?.LogDebug("[GAME ENGINE DEBUG] No conversation detected, continuing with normal processing"); 632 633 // 6. ------- Complex parsed commands. These require a parser to break them down into their noun(s) and verb. 634 635 // if the user referenced an object using "it", let's see if we can handle that. 636 var (requiresClarification, replacedInput) = _itProcessor.Check(_currentInput, Context); 637 if (requiresClarification) 638 { 639 _processorInProgress = _itProcessor; 640 // Persist the command awaiting a noun so the "What item are you referring to?" clarification 641 // survives the stateless save/restore boundary (issue #472). _currentInput is exactly what 642 // ItProcessor.Check just stashed as the command to complete once the noun arrives. 643 Context.PendingClarificationCommand = _currentInput; 644 return PostProcessing(replacedInput); 645 } 646 647 // Replace the "it" with the correct noun, if applicable. 648 _currentInput = replacedInput; 649 650 var parsedResult = await _parser.DetermineComplexIntentType( 651 _currentInput, 652 Context.CurrentLocation.GetDescription(Context), 653 _sessionId 654 ); 655 656 var complexIntentResult = await ProcessComplexIntent(parsedResult); 657 658 // A look/inventory phrasing the AI parser recognizes but GlobalCommandFactory's static list 659 // doesn't (e.g. "what is this place?") reaches LookProcessor/InventoryProcessor here instead 660 // of the fast static path above, so isFreeCommand was (necessarily) false when contextPrepend 661 // was computed - Context.ProcessBeginningOfTurn() already ran as a real turn, and that can't 662 // be undone without calling the AI parser before every turn, defeating the "cheap first, AI 663 // fallback second" design this engine otherwise follows. What we CAN still do is skip the 664 // end-of-turn survival-clock tick, which is what actually risks a hunger/sleep death from 665 // checking your status - issue #354's core complaint - even though Context.Moves still 666 // advances for this narrow AI-only-recognized case. 667 var aiRecognizedFreeIntent = parsedResult is LookIntent or InventoryIntent; 668 669 // Put it all together for return. 670 return await ProcessActorsAndContextEndOfTurn(contextPrepend, complexIntentResult.ResultMessage, 671 aiRecognizedFreeIntent); 672 } 673 674 /// <summary> 675 /// True when the immediately preceding command was a movement and the player's current command 676 /// uses "it"/"them" with a still-carried antecedent that <see cref="MoveEngine" /> preserved 677 /// across that move (issue #248). When this holds we must NOT run the AI pronoun resolver: right 678 /// after a move its only context is the movement command (LastInput) and the destination room's 679 /// description (LastResponse), so it re-binds the pronoun to a noun in the NEW room and the 680 /// carried-item antecedent is lost (issue #275 — "the invisible gangway"). The deterministic 681 /// <see cref="ItProcessor" /> downstream resolves the pronoun from LastNoun/LastNouns instead. 682 /// 683 /// The check is deliberately scoped to the post-move case so the AI resolver keeps its value for 684 /// every other turn — semantic rewrites like "put it on" -> "wear X", resolving from response 685 /// narration, the other pronouns (him/her/that/...), and so on. 686 /// </summary> 687 private static bool MoveJustClobberedPronounContext(string input, IContext context) 688 { 689 // "the move is the trigger": only defer to the preserved antecedent when the previous command 690 // (now sitting in LastInput) was itself a movement. Any non-move command refreshes LastInput 691 // with a real noun phrase, which is exactly what the AI resolver needs to work correctly. 692 if (!DirectionParser.IsDirection(context.LastInput, out _)) 693 return false; 694 695 // Only "it"/"them" are resolved deterministically from LastNoun/LastNouns; for any other 696 // pronoun (him, her, that, ...) the AI resolver is the only thing that can help, so let it run. 697 if (Regex.IsMatch(input, @"\bit\b", RegexOptions.IgnoreCase)) 698 return !string.IsNullOrEmpty(context.LastNoun) && 699 context.HasMatchingNoun(context.LastNoun).HasItem; 700 701 if (Regex.IsMatch(input, @"\bthem\b", RegexOptions.IgnoreCase)) 702 return context.LastNouns.Any(noun => context.HasMatchingNoun(noun).HasItem); 703 704 return false; 705 }
426 private async Task<string?> ProcessSingleSentence(string? playerInput) 427 { 428 _currentInput = playerInput; 429 430 // 1. ------- Processor in Progress - 431 // See if we have something already running like a save, quit, etc. 432 // and see if it has any output. Does not count as a turn. No actor or turn processing. 433 var (returnProcessorInProgressOutput, processorInProgressOutput) = 434 await RunProcessorInProgress(playerInput); 435 436 if (returnProcessorInProgressOutput) 437 return PostProcessing(processorInProgressOutput!); 438 439 // After disambiguation resolution, _currentInput may have been updated to the clarified command 440 // Use it for all subsequent processing 441 playerInput = _currentInput; 442 443 // 2. ------- Empty command. Does not count as a turn. No actor or turn processing. 444 if (string.IsNullOrEmpty(playerInput)) 445 return PostProcessing(await GetGeneratedNoCommandResponse()); 446 447 // 2b. ------- "look at <noun>" — and the "look in/inside <noun>" container-inspection phrasings 448 // (issue #396) — are examine synonyms, not the bare-room LOOK command. The AI parser collapses 449 // "look at X" to a noun-less look intent (issues #312 / #283) and mis-tags "look in X" as an "in" 450 // movement ("You cannot go that way."). Rewrite them to the canonical "examine <noun>" here so 451 // they route through the examine-with-noun path (which lists an open container's contents), while 452 // leaving the bare forms ("look", "look around") and genuinely different prepositions ("look 453 // under the rug", "look behind the painting") untouched. Note: because this runs before pronoun 454 // resolution and the LastInput capture below, a subsequent "again"/"g" replays "look at X" as 455 // "examine X" — harmless, since both produce the same examination output. 456 playerInput = NormalizeLookAt(playerInput); 457 _currentInput = playerInput; 458 459 Context.PreviousLocationName = LocationName; 460 461 // 3. ------- System, or "meta" commands - like save, restore, quit, verbose etc. Does not count as a turn. No actor or turn processing. 462 var systemCommand = _parser.DetermineSystemIntentType(playerInput); 463 if (systemCommand is GlobalCommandIntent global) 464 { 465 var globalResult = await ProcessGlobalCommandIntent(global); 466 return PostProcessing(globalResult); 467 } 468 469 // 3b. ------- Context-level command override. Lets a game-specific context fully handle the 470 // raw command before normal parsing — used by Zork's spirit/DEAD state, which overrides most 471 // verbs with canned ghost responses while letting movement and resurrection fall through by 472 // returning null (issue #17). Does not count as a turn; no actor or end-of-turn processing. 473 var contextOverride = Context.InterceptPlayerCommand(playerInput); 474 if (contextOverride is not null) 475 return PostProcessing(contextOverride); 476 477 // Determine up front whether this input resolves to a "free" global command - a 478 // meta/informational verb (look, inventory, score, current time) that must never advance 479 // Context.Moves or a time-based game's survival clock (issue #354). This has to be known 480 // BEFORE Context.ProcessBeginningOfTurn() runs (which does exactly that), so it's computed 481 // here rather than at its usual step-5 spot below - the result is reused there instead of 482 // being recomputed. Actor processing (chase scenes, countdown timers, Floyd, ...) still runs 483 // for free commands further down - the world keeps moving even while the player just glances 484 // at their status; only the player's own turn/survival-clock bookkeeping is skipped. 485 // 486 // "again"/"g" replays the previous command - AgainProcessor.Process (below, at its usual 487 // spot) resolves that later, but we need to know what it WILL resolve to now, so a replayed 488 // free command (e.g. "look" then "g") classifies correctly too. Peeking is side-effect-free; 489 // Process() still runs normally afterward for its real side effects. 490 var replayTarget = _againProcessor.PeekReplayTarget(playerInput!, Context); 491 var earlyGlobalIntent = _parser.DetermineGlobalIntentType(replayTarget ?? playerInput); 492 var isFreeCommand = earlyGlobalIntent is GlobalCommandIntent { Command: IFreeGlobalCommand }; 493 494 // One-shot actor-suppression flags (e.g. Planetfall's FloydShouldNotActThisTurn) must reset 495 // every turn regardless of isFreeCommand: actor processing below always runs, even for free 496 // commands, so a flag it consumes must always get its one-shot reset too - otherwise it leaks 497 // across consecutive free commands and suppresses an actor for longer than intended. 498 Context.ResetPerTurnActorFlags(); 499 500 // Everything below here counts as a turn, unless it's a free command. Pre-process the turn. 501 // See if the context needs to notify us of anything. Are we sleepy? Hungry? 502 var contextPrepend = isFreeCommand ? null : Context.ProcessBeginningOfTurn(); 503 504 // Check if player died during beginning-of-turn processing (e.g., hunger death) 505 if (Context.PendingDeath is not null) 506 { 507 var deathResult = Context.PendingDeath; 508 var deathMessage = deathResult.InteractionMessage; 509 RestartAfterDeath(deathResult.DeathCount); 510 return PostProcessing(deathMessage + Context.CurrentLocation.GetDescription(Context)); 511 } 512 513 // Issue #355: a scheduled event (e.g. Planetfall's forced sleep) consumed this turn during 514 // ProcessBeginningOfTurn, mutating state (dropping carried items, changing location) against 515 // wherever the player was BEFORE their own command ran. Executing that command now - most 516 // dangerously a movement command - would change CurrentLocation again, leaving the narration 517 // and any side effects of the event stranded against a location the response never mentions 518 // again. Short-circuit here, mirroring the PendingDeath check above: report wherever the 519 // player actually is instead of running their command, which is deferred to next turn. Still 520 // routed through ProcessActorsAndContextEndOfTurn so the clock ticks and actors act, exactly 521 // as they would for any other turn. 522 if (Context.TurnConsumedByForcedEvent) 523 { 524 Context.TurnConsumedByForcedEvent = false; 525 return await ProcessActorsAndContextEndOfTurn( 526 contextPrepend, Context.CurrentLocation.GetDescription(Context)); 527 } 528 529 // See if the user typed "again" or some variation. 530 // if so, we'll replace the input with their previous input. 531 (_currentInput, var returnResponseFromAgainProcessor) = _againProcessor.Process( 532 _currentInput!, 533 Context 534 ); 535 if (returnResponseFromAgainProcessor) 536 return PostProcessing(_currentInput); 537 538 // Resolve pronouns from recent player input and game response (BEFORE ItProcessor), UNLESS a 539 // just-completed move left the deterministic engine holding a still-carried antecedent for 540 // this pronoun. After a move, LastInput is the movement command ("north") and LastResponse is 541 // the destination room's description, so the AI resolver would re-bind "it"/"them" to a noun 542 // in the NEW room and lose the carried-item antecedent that MoveEngine deliberately preserved 543 // across the move (issues #248 / #275). In that case we defer to the deterministic ItProcessor 544 // below, which resolves the pronoun from the preserved LastNoun/LastNouns. 545 if (!MoveJustClobberedPronounContext(_currentInput!, Context) 546 && (!string.IsNullOrEmpty(Context.LastInput) || !string.IsNullOrEmpty(Context.LastResponse))) 547 { 548 var resolved = await _parser.ResolvePronounsAsync(_currentInput!, Context.LastInput, Context.LastResponse); 549 if (resolved != null && !resolved.Equals(_currentInput, StringComparison.OrdinalIgnoreCase) 550 && !ResolverConflatedSingularItWithASet(_currentInput!, resolved)) 551 { 552 _currentInput = resolved; 553 } 554 } 555 556 // Track player input for pronoun resolution (AFTER resolution, so next command can use this as context) 557 // Store the RESOLVED input so subsequent pronoun resolution has the actual noun, not the pronoun 558 if (!string.IsNullOrWhiteSpace(_currentInput)) 559 Context.LastInput = _currentInput; 560 561 // 4. ------- Location specific raw commands 562 // Check if the location has an interaction with the raw, unparsed input. 563 // Some locations have a special interaction to raw input that does not fit 564 // the traditional sentence parsing. Sometimes that is a single verb with no 565 // noun like "jump" or "pray" or "echo", or some other specific phrase 566 // that does not lend itself well to parsing. 567 var singleVerbResult = await Context.CurrentLocation.RespondToSpecificLocationInteraction( 568 playerInput, 569 Context, 570 GenerationClient 571 ); 572 // isFreeCommand was classified from raw input TEXT before we knew whether a location would 573 // intercept it here - a location's raw interaction is never actually the free processor, so 574 // an interaction that fires here is always a real turn, regardless of what the input text 575 // happened to look like (e.g. LoudRoom's echo catch-all firing for the literal word "score"). 576 // If we skipped Context.ProcessBeginningOfTurn() based on that wrong early guess, run it now 577 // - late beats never - before treating this as the real turn it actually is. 578 // 579 // Known ordering trade-off: RespondToSpecificLocationInteraction above has ALREADY run (and 580 // any state it mutates has already happened) by the time this catches up, whereas on every 581 // other path in this method ProcessBeginningOfTurn() runs first. In practice this is inert - 582 // the only unconditional-catch-all locations today (LoudRoom, InsideTheBarrow, 583 // CryoAnteroomLocation) have no stateful side effects beyond their message - but a future 584 // catch-all location with one would apply it before this turn's hazard check instead of 585 // after. The visible outcome, a death here, is still handled correctly either way (see 586 // FreeMetaCommandTests.PendingDeath_FromLateBeginningOfTurn_StillOverridesACatchAllLocationInteraction) - 587 // the interaction's TEXT is discarded in favor of the death message - only a stateful side 588 // effect could land "early". 589 if (singleVerbResult.InteractionHappened) 590 { 591 if (isFreeCommand) 592 { 593 contextPrepend = Context.ProcessBeginningOfTurn(); 594 595 if (Context.PendingDeath is not null) 596 { 597 var deathResult = Context.PendingDeath; 598 var deathMessage = deathResult.InteractionMessage; 599 RestartAfterDeath(deathResult.DeathCount); 600 return PostProcessing(deathMessage + Context.CurrentLocation.GetDescription(Context)); 601 } 602 } 603 604 return await ProcessActorsAndContextEndOfTurn(contextPrepend, singleVerbResult.InteractionMessage); 605 } 606 607 // 5. ------- Global commands - these work always, everywhere: like look, inventory, wait and cardinal directions. These DO count as a turn 608 // (except free commands - see isFreeCommand above). We must process actors afterwards regardless. 609 var simpleIntent = earlyGlobalIntent; 610 if (simpleIntent is not null) 611 { 612 var resultMessage = simpleIntent switch 613 { 614 GlobalCommandIntent intent => await ProcessGlobalCommandIntent(intent), 615 MoveIntent moveInteraction => (await new MoveEngine().Process(moveInteraction, Context, 616 GenerationClient)).ResultMessage, 617 _ => null 618 }; 619 620 return await ProcessActorsAndContextEndOfTurn(contextPrepend, resultMessage, isFreeCommand); 621 } 622 623 // Is the player talking to someone? 624 _logger?.LogDebug($"[GAME ENGINE DEBUG] About to check for conversation with input: '{_currentInput}'"); 625 var conversation = await _conversationHandler.CheckForConversation(_currentInput, Context); 626 if (conversation is not null) 627 { 628 _logger?.LogDebug($"[GAME ENGINE DEBUG] Conversation detected, returning response: '{conversation}'"); 629 return await ProcessActorsAndContextEndOfTurn(contextPrepend, conversation); 630 } 631 _logger?.LogDebug("[GAME ENGINE DEBUG] No conversation detected, continuing with normal processing"); 632 633 // 6. ------- Complex parsed commands. These require a parser to break them down into their noun(s) and verb. 634 635 // if the user referenced an object using "it", let's see if we can handle that. 636 var (requiresClarification, replacedInput) = _itProcessor.Check(_currentInput, Context); 637 if (requiresClarification) 638 { 639 _processorInProgress = _itProcessor; 640 // Persist the command awaiting a noun so the "What item are you referring to?" clarification 641 // survives the stateless save/restore boundary (issue #472). _currentInput is exactly what 642 // ItProcessor.Check just stashed as the command to complete once the noun arrives. 643 Context.PendingClarificationCommand = _currentInput; 644 return PostProcessing(replacedInput); 645 } 646 647 // Replace the "it" with the correct noun, if applicable. 648 _currentInput = replacedInput; 649 650 var parsedResult = await _parser.DetermineComplexIntentType( 651 _currentInput, 652 Context.CurrentLocation.GetDescription(Context), 653 _sessionId 654 ); 655 656 var complexIntentResult = await ProcessComplexIntent(parsedResult); 657 658 // A look/inventory phrasing the AI parser recognizes but GlobalCommandFactory's static list 659 // doesn't (e.g. "what is this place?") reaches LookProcessor/InventoryProcessor here instead 660 // of the fast static path above, so isFreeCommand was (necessarily) false when contextPrepend 661 // was computed - Context.ProcessBeginningOfTurn() already ran as a real turn, and that can't 662 // be undone without calling the AI parser before every turn, defeating the "cheap first, AI 663 // fallback second" design this engine otherwise follows. What we CAN still do is skip the 664 // end-of-turn survival-clock tick, which is what actually risks a hunger/sleep death from 665 // checking your status - issue #354's core complaint - even though Context.Moves still 666 // advances for this narrow AI-only-recognized case. 667 var aiRecognizedFreeIntent = parsedResult is LookIntent or InventoryIntent; 668 669 // Put it all together for return. 670 return await ProcessActorsAndContextEndOfTurn(contextPrepend, complexIntentResult.ResultMessage, 671 aiRecognizedFreeIntent); 672 } 673 674 /// <summary> 675 /// True when the immediately preceding command was a movement and the player's current command 676 /// uses "it"/"them" with a still-carried antecedent that <see cref="MoveEngine" /> preserved 677 /// across that move (issue #248). When this holds we must NOT run the AI pronoun resolver: right 678 /// after a move its only context is the movement command (LastInput) and the destination room's 679 /// description (LastResponse), so it re-binds the pronoun to a noun in the NEW room and the 680 /// carried-item antecedent is lost (issue #275 — "the invisible gangway"). The deterministic 681 /// <see cref="ItProcessor" /> downstream resolves the pronoun from LastNoun/LastNouns instead. 682 /// 683 /// The check is deliberately scoped to the post-move case so the AI resolver keeps its value for 684 /// every other turn — semantic rewrites like "put it on" -> "wear X", resolving from response 685 /// narration, the other pronouns (him/her/that/...), and so on. 686 /// </summary> 687 private static bool MoveJustClobberedPronounContext(string input, IContext context) 688 { 689 // "the move is the trigger": only defer to the preserved antecedent when the previous command 690 // (now sitting in LastInput) was itself a movement. Any non-move command refreshes LastInput 691 // with a real noun phrase, which is exactly what the AI resolver needs to work correctly. 692 if (!DirectionParser.IsDirection(context.LastInput, out _)) 693 return false; 694 695 // Only "it"/"them" are resolved deterministically from LastNoun/LastNouns; for any other 696 // pronoun (him, her, that, ...) the AI resolver is the only thing that can help, so let it run. 697 if (Regex.IsMatch(input, @"\bit\b", RegexOptions.IgnoreCase)) 698 return !string.IsNullOrEmpty(context.LastNoun) && 699 context.HasMatchingNoun(context.LastNoun).HasItem; 700 701 if (Regex.IsMatch(input, @"\bthem\b", RegexOptions.IgnoreCase)) 702 return context.LastNouns.Any(noun => context.HasMatchingNoun(noun).HasItem); 703 704 return false; 705 }