Port the '+' wizard-mode toggle-off (closes #11) #30

Merged
clawbot merged 1 commits from fix/wizard-toggle-off into main 2026-08-09 10:30:22 +02:00
5 changed files with 185 additions and 1 deletions

View File

@@ -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 | | `md_readchar` escape decoding | tcell decodes keys | key-event translation table |
| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) | | XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) |
| save-file symlink/hardlink checks | single-user era anti-cheat | none | | 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 | | load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none |
| tty dsusp/ltc character juggling | tcell owns the tty | none | | tty dsusp/ltc character juggling | tcell owns the tty | none |
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell | | 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 | | SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly |
| `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none | | `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. The three signal rows warrant more than a table cell.
**SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game **SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game

38
TODO.md
View File

@@ -34,6 +34,44 @@ wizard commands).
# Completed Steps # 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`, - 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 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 still argued, in the present tense, that declining to save on SIGINT/SIGQUIT

View File

@@ -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). // wizardCommand handles the MASTER debug commands (command.c).
func (g *RogueGame) wizardCommand(ch byte) { func (g *RogueGame) wizardCommand(ch byte) {
p := &g.Player p := &g.Player

View File

@@ -802,6 +802,7 @@ func newGameData() *gameData {
g.After = false // "legal" illegal command g.After = false // "legal" illegal command
}, },
'^': (*RogueGame).identifyTrapCommand, '^': (*RogueGame).identifyTrapCommand,
'+': (*RogueGame).wizardToggleCommand,
Escape: func(g *RogueGame) { Escape: func(g *RogueGame) {
g.DoorStop = false g.DoorStop = false
g.Count = 0 g.Count = 0

View File

@@ -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")
}
}