diff --git a/src/game.lua b/src/game.lua index 7c60943..cc66fa3 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2424,7 +2424,201 @@ function Game:manualStep(text) self:endPlayerTurn() return true, nil end - self:endPlayerTurn() + local consumesTurn, message = Commands.apply(self, text) + -- "explore" (Game:autoExplore, wired up in commands.lua) can advance and + -- close several turns entirely on its own inside this one call, unlike + -- every other handler here which only ever acts within the single turn + -- already opened above - self.turnState is already nil by the time it + -- returns, so this must not end an already-ended turn a second time - and + -- must never end one at all if the very last thing that happened was a + -- halt (self.turnState == "halt"), exactly like Game:autoStep and this + -- same function's own "halt" branch above never do either. Every other + -- command leaves turnState exactly as beginTurn set it above ("ready"), + -- so the extra check below is a no-op for them. + if consumesTurn and self.turnState == "ready" then self:endPlayerTurn() end + return consumesTurn, message +end + +local EXPLORE_STEP_CAP = 2000 + +-- Repeats Game:exploreStep/Game:movePlayer turn after turn inside a single +-- call, the way autoexplore works in most other roguelikes, instead of +-- making the player retype "explore" once per step. Built on the exact same +-- beginTurn/endPlayerTurn pair Game:autoStep already drives, so monster AI, +-- status ticks and hazards all run normally on every turn it takes - the +-- only difference from a hand-typed run of single moves is that this stops +-- itself the instant something worth the player's attention shows up, +-- rather than walking on regardless. Returns (consumesTurn, message) in the +-- exact same shape every Commands.apply handler does: consumesTurn is true +-- iff at least one turn was actually spent, so "already interrupted before +-- moving at all" and "an ordinary rejected command" both read the same way +-- to a caller that only checks that first value. +function Game:autoExplore() + local steps = 0 + while steps < EXPLORE_STEP_CAP do + if self:anyHostileVisible() then + if steps == 0 then return false, "a monster is already in sight" end + return true, "you stop exploring - a monster is in sight" + end + local hpBefore = self.player.hp + local state = self:beginTurn() + if state == "halt" then + if steps == 0 then return false, "there is nothing left to do" end + return true, "you stop exploring" + end + if state == "ready" then + local ex, ey = self:exploreStep() + if not ex then + if steps == 0 then return false, "there is nothing left to explore" end + -- beginTurn above already ran this turn's monster moves and status + -- ticks for real, even though there turns out to be nothing left to + -- walk toward - so, unlike the steps == 0 case above (nothing at + -- all has happened yet, left dangling for a retry within the same + -- still-open turn, exactly like a rejected command), this turn must + -- still be closed out properly rather than abandoned half-finished. + self:endPlayerTurn() + return true, "you have finished exploring" + end + self:movePlayer(ex, ey) + end + self:endPlayerTurn() + steps = steps + 1 + if self.player.hp < hpBefore then + return true, "you stop exploring - you have taken damage" + end + end + return true, "you stop exploring - that is far enough for now" +end + +local REST_STEP_CAP = 500 + +-- Repeats a do-nothing turn (Handlers.wait's own move) inside a single call +-- until the player's hp is back to full, the way "rest" works in most other +-- roguelikes, instead of making the player retype "wait" once per turn. +-- Built on the same beginTurn/endPlayerTurn pair Game:autoExplore already +-- drives, so status ticks, hazards and the passive hp regen sources that +-- actually restore hp (a regen ring or artifact, Status.REGEN, favorable +-- weather - see Game:hungerTorchTick and Status.tick) all run exactly as +-- they would turn by turn. Stops itself the instant a hostile comes into +-- sight, the player's hp actually drops, or two full turns in a row pass +-- with the player's hp completely unchanged - the last case catches the +-- common situation where none of those regen sources are active at all, so +-- waiting here would otherwise never do anything but burn turns up to the +-- safety cap below. Returns (consumesTurn, message) in the exact same shape +-- every other handler in commands.lua does, exactly like Game:autoExplore. +-- +-- The "no progress" check deliberately waits for a *second* completed turn +-- (steps > 1) rather than judging the very first one: Game:manualStep (the +-- only caller a real player's typed "rest" ever goes through) already calls +-- Game:beginTurn once of its own, before Commands.apply even runs, to check +-- whether the turn is allowed to start at all - so by the time this loop's +-- own first Game:beginTurn call runs, self.turnState may already be primed +-- from that earlier call, and it is a no-op cache hit rather than a fresh +-- tick (see Game:beginTurn's own comment). Any regen this call's Status.tick +-- and Game:hungerTorchTick would otherwise have produced already landed on +-- self.player.hp *before* this function's very first hpBefore snapshot was +-- even taken, so a first-iteration hp==hpBefore reading is inconclusive, not +-- proof that nothing is healing the player - judging it anyway would have +-- reported a perfectly working regen ring as "nothing here is healing you" +-- after a single real turn every time rest is typed in the actual game. +-- Every iteration from the second one on always starts with self.turnState +-- already nil (this loop's own prior Game:endPlayerTurn call clears it), so +-- its own Game:beginTurn call is always a fresh tick there - reliable either +-- way this function is entered, direct or through Game:manualStep. +function Game:restUntilHealed() + if self.player.hp >= self.player.maxHp then + return false, "you are already at full health" + end + local steps = 0 + while steps < REST_STEP_CAP do + if self:anyHostileVisible() then + if steps == 0 then return false, "a monster is already in sight" end + return true, "you stop resting - a monster is in sight" + end + local hpBefore = self.player.hp + local state = self:beginTurn() + if state == "halt" then + if steps == 0 then return false, "there is nothing left to do" end + return true, "you stop resting" + end + self:endPlayerTurn() + steps = steps + 1 + if self.player.hp >= self.player.maxHp then + return true, "you rest until your wounds close" + end + if self.player.hp < hpBefore then + return true, "you stop resting - you have taken damage" + end + if steps > 1 and self.player.hp == hpBefore then + return true, "you stop resting - nothing here is healing you" + end + end + return true, "you stop resting - that is far enough for now" +end + +local TRAVEL_STEP_CAP = 500 + +-- Repeats Game:playerPathTo/Game:movePlayer turn after turn toward a fixed +-- destination inside a single call, the way "travel"/autotravel works in +-- most other roguelikes (DCSS's `>`, Brogue's travel cursor) - instead of +-- making the player retype a direction once per step to walk back to a spot +-- they already know is safe (the stairs they just left behind). Built on the +-- exact same beginTurn/endPlayerTurn pair Game:autoExplore/Game:restUntilHealed +-- already drive, so monster AI, status ticks and hazards all run normally on +-- every turn it takes. Reuses Game:playerPathTo, the same pathfinder +-- choosePlayerAction already relies on to walk the autopilot toward the +-- stairs turn by turn - this only exposes that existing primitive to a typed +-- command instead of teaching a second way to path anywhere. Stops itself +-- the instant a hostile comes into sight, the player's hp actually drops, the +-- destination is reached, or the route becomes blocked (a monster or a +-- spawned block stepped onto the only way through). Returns (consumesTurn, +-- message) in the exact same shape every other Commands.apply handler does. +function Game:travelTo(tx, ty) + if self.player.x == tx and self.player.y == ty then + return false, "you are already there" + end + if not self:playerPathTo(tx, ty) then + return false, "you cannot find a path there" + end + local steps = 0 + while steps < TRAVEL_STEP_CAP do + if self:anyHostileVisible() then + if steps == 0 then return false, "a monster is already in sight" end + return true, "you stop traveling - a monster is in sight" + end + local hpBefore = self.player.hp + local state = self:beginTurn() + if state == "halt" then + if steps == 0 then return false, "there is nothing left to do" end + return true, "you stop traveling" + end + if state == "ready" then + local path = self:playerPathTo(tx, ty) + if not path or #path < 2 then + -- beginTurn above already ran this turn's monster moves and status + -- ticks for real (see Game:autoExplore's own "nothing left to + -- explore" branch for the identical reasoning), so unlike the + -- steps == 0 case below - where nothing has happened yet and the + -- turn is left open for a retry, exactly like a rejected command - + -- a route that only went dead after steps have already been taken + -- must close out the turn that already ran rather than abandon it + -- half-finished. + if steps == 0 then return false, "you cannot find a path there" end + self:endPlayerTurn() + return true, "you stop traveling - the way is blocked" + end + self:movePlayer(path[2].x - self.player.x, path[2].y - self.player.y) + end + self:endPlayerTurn() + steps = steps + 1 + if self.player.x == tx and self.player.y == ty then + return true, "you arrive" + end + if self.player.hp < hpBefore then + return true, "you stop traveling - you have taken damage" + end + end + return true, "you stop traveling - that is far enough for now" end function Game:render() @@ -2436,6 +2630,10 @@ function Game:toData() seed = self.seed, depth = self.depth, turn = self.turn, + won = self.won, + monsterSeq = self.monsterSeq, + deepestDepth = self.deepestDepth, + stats = self.stats, rng = self.rng:serialize(), identify = self.identify, dungeon = self.dungeon:toData(),