diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a99fd6..a30a70d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1784,7 +1784,7 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7"). | `md_readchar` escape decoding | tcell decodes keys | key-event translation table | | XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) | | save-file symlink/hardlink checks | single-user era anti-cheat | none | -| DES crypt wizard password | ditto | `ROGUE_WIZARD` env var | +| DES crypt wizard password (`passwd()`, the `'+'` enter arm) | ditto | `ROGUE_WIZARD` env var | | load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none | | tty dsusp/ltc character juggling | tcell owns the tty | none | | shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell | @@ -1793,6 +1793,16 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7"). | SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly | | `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none | +Only half of C's `'+'` command (`command.c` 317-338) goes with the password row. +The leave arm is ported in full as `wizardToggleCommand`: it clears the wizard +flag, calls `turnSee(true)` — C's `turn_see(TRUE)`, without which there is no +way back out of wizard sight once it is on — and prints "not wizard any more". +What `passwd()` takes with it is the enter arm. A password check that no longer +exists is a password check that can never succeed, so `'+'` outside wizard mode +reduces to what C did when the answer was wrong: the message "sorry", with no +prompt, since nothing typed into one could change the outcome, and none of the +`noscore`/`turn_see(FALSE)` bookkeeping of C's success branch. + The three signal rows warrant more than a table cell. **SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game diff --git a/TODO.md b/TODO.md index c2568dc..39efd7d 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,44 @@ wizard commands). # Completed Steps +- 2026-08-09 The `'+'` wizard-mode toggle (`fix/wizard-toggle-off`, closes #11): + C's `command.c` 317-338 has a `when '+'` arm that leaves wizard mode — + `wizard = FALSE`, `turn_see(TRUE)`, `msg("not wizard any more")` — and the + port had no `'+'` anywhere, so the key fell through `dispatchKey`'s default to + `illcom` and answered "illegal command '+'". The password half of that arm was + dropped on purpose (wizard mode is `ROGUE_WIZARD` configuration) and is in + ARCHITECTURE.md §9; the leave half was lost silently and is not the same + decision — it does not touch the password machinery at all. **The substantive + part is `turn_see(TRUE)`**, not the flag: wizard sight draws every monster the + hero cannot see, so without the re-hide there is no way back to normal + visibility once wizard mode is on, and clearing the flag alone would have left + the screen lying. New `wizardToggleCommand` in `game/command.go`, registered + in `commandHandlers` between `'^'` and `Escape` — C's own switch order, and + note that C's arm sits in the **main** command switch under `#ifdef MASTER`, + not in the `if (wizard) switch (ch)` sub-switch that `wizardCommand` ports, so + it is reachable whether or not `wizard` is set. That makes the non-wizard case + a divergence too, and it resolves the way the dropped `passwd()` forces: a + password check that no longer exists can never succeed, so the else arm is + what C did on a wrong answer, the message "sorry" — no prompt, since nothing + typed into one could change the outcome, and none of the + `noscore`/`turn_see(FALSE)` bookkeeping of C's unreachable success branch. The + choice is stated in the function's doc comment and in §9, whose password row + now names the `'+'` enter arm and whose new paragraph records that the leave + arm is ported in full. Two tests in `game/wizard_test.go` drive `'+'` through + `g.dispatch`: the wizard one spawns a phantom (`ISINVIS` straight from the + monster table, so `seeMonst` is false and it is on screen only because wizard + sight put it there), asserts the precondition — monster glyph drawn in + standout at its cell, `SenseMonsters` set — and then asserts the flag cleared, + `SenseMonsters` cleared, the cell back to the map char under the monster with + standout off, the exact message, and `After` false; the non-wizard one pins + "sorry" and that `'+'` is no longer an illegal command. Mutation-proved: + deleting the `turnSee(true)` call fails the test on all three visibility + assertions, which is the half a flag-only test would have missed. No RNG call + is added — the `turn_off` arm of `turn_see` never reaches `rnd`, only the + turn-on arm does — and `TestSeedCompatItemTables` stays green against the + untouched golden. `Next Step` deliberately not rotated: out-of-band issue + work. + - 2026-08-09 Cleanups deferred from the PR #26 review (`cleanup/pr26-followups`, closes #27): four items, no behaviour change. (1) The `sig-leave` entry below still argued, in the present tense, that declining to save on SIGINT/SIGQUIT diff --git a/game/command.go b/game/command.go index dd1dcff..1c3bd32 100644 --- a/game/command.go +++ b/game/command.go @@ -398,6 +398,36 @@ func (g *RogueGame) identifyTrapCommand() { } } +// wizardToggleCommand handles '+': leave wizard mode (command.c +// command). C's arm lives in the main command switch under +// #ifdef MASTER, not in the wizard sub-switch, so it is reachable +// whether or not wizard is set. +// +// The entry half is deliberately not ported. C ran wizard = passwd(), +// which compared a DES-crypted answer against a compiled-in password; +// this port drops that machinery and makes wizard mode configuration +// instead (ROGUE_WIZARD, ARCHITECTURE.md §9). A password check that is +// gone is a password check that can never succeed, so the else arm +// reduces to exactly what C did when the answer was wrong: wizard stays +// off and the game says "sorry". No prompt is shown, since nothing typed +// into it could change the outcome, and the noscore/turn_see(FALSE) +// bookkeeping of C's success branch is unreachable and so is absent. +func (g *RogueGame) wizardToggleCommand() { + g.After = false + + if !g.Wizard { + g.msg("sorry") + + return + } + + g.Wizard = false + // Re-hide the monsters wizard sight was showing: without this there + // is no way back to normal visibility. + g.turnSee(true) + g.msg("not wizard any more") +} + // wizardCommand handles the MASTER debug commands (command.c). func (g *RogueGame) wizardCommand(ch byte) { p := &g.Player diff --git a/game/tables.go b/game/tables.go index c914f43..31201a9 100644 --- a/game/tables.go +++ b/game/tables.go @@ -802,6 +802,7 @@ func newGameData() *gameData { g.After = false // "legal" illegal command }, '^': (*RogueGame).identifyTrapCommand, + '+': (*RogueGame).wizardToggleCommand, Escape: func(g *RogueGame) { g.DoorStop = false g.Count = 0 diff --git a/game/wizard_test.go b/game/wizard_test.go index f6973b0..e65ef9d 100644 --- a/game/wizard_test.go +++ b/game/wizard_test.go @@ -424,3 +424,108 @@ func TestWhichLimitCoversEveryIndexedTable(t *testing.T) { } } } + +// TestWizardToggleOffRehidesSensedMonsters drives '+' through command +// dispatch in wizard mode (command.c 317-338). Clearing the flag is the +// cheap half; the substantive half is turn_see(TRUE) — leaving wizard +// mode has to put the screen back, or there is no way out of wizard +// sight once it is on. +func TestWizardToggleOffRehidesSensedMonsters(t *testing.T) { + t.Parallel() + + g := mkGame(t, 11) + g.Wizard = true + + // A phantom carries ISINVIS straight from the monster table, so + // seeMonst is false for it and it is on screen only because wizard + // sight put it there. + tp := spawnAdjacent(g, 'P') + if !tp.On(Invisible) { + t.Fatal("phantom is not invisible; this test needs an unseeable monster") + } + + if g.seeMonst(tp) { + t.Fatal("monster is ordinarily visible; wizard sight would reveal nothing") + } + + if tp.OldCh == tp.Type { + t.Fatalf("map char under the monster is also %q; the redraw "+ + "assertion would prove nothing", tp.Type) + } + + g.turnSee(false) + + if !g.Player.On(SenseMonsters) { + t.Fatal("turnSee(false) did not set SenseMonsters") + } + + if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.Type { + t.Fatalf("wizard sight did not draw the monster: cell is %q, want %q", + ch, tp.Type) + } + + if !g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout { + t.Fatal("wizard-sighted monster was not drawn in standout") + } + + g.Msgs.Mpos = 0 + g.After = true + + g.dispatch('+') + + if g.Wizard { + t.Error("'+' did not clear the wizard flag") + } + + if g.Player.On(SenseMonsters) { + t.Error("'+' left SenseMonsters set: turn_see(TRUE) was not performed") + } + + if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.OldCh { + t.Errorf("monster still on screen after leaving wizard mode: cell is "+ + "%q, want the map char under it, %q", ch, tp.OldCh) + } + + if g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout { + t.Error("cell left in standout after leaving wizard mode") + } + + if g.Msgs.Huh != "not wizard any more" { + t.Errorf("message = %q, want %q", g.Msgs.Huh, "not wizard any more") + } + + if g.After { + t.Error("'+' consumed a turn; C sets after = FALSE") + } +} + +// TestWizardToggleWithoutWizardSaysSorry pins the other arm. C ran +// wizard = passwd() and said "sorry" when the answer was wrong; the +// password machinery is dropped, so that is the only outcome left. What +// it must not be any more is "illegal command '+'". +func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) { + t.Parallel() + + g := mkGame(t, 12) + g.Wizard = false + g.Msgs.Mpos = 0 + g.After = true + + g.dispatch('+') + + if g.Wizard { + t.Error("'+' entered wizard mode with no password check to pass") + } + + if g.Player.On(SenseMonsters) { + t.Error("'+' turned on monster sense outside wizard mode") + } + + if g.Msgs.Huh != "sorry" { + t.Errorf("message = %q, want %q", g.Msgs.Huh, "sorry") + } + + if g.After { + t.Error("'+' consumed a turn; C sets after = FALSE") + } +}