WIP: restore lost C behaviors (schtick message, forced redraw, greeting)

Incomplete checkpoint, preserved after the implementing session hit a
capacity limit partway through mutation verification. NOT reviewed and NOT
gate-clean: make fmt has not been run, so fmt-check currently fails on
ARCHITECTURE.md and TODO.md.

Work still outstanding before this is fit for review:
  - run make fmt and fold the result in
  - finish mutation-proving each of the three behaviors
  - verify every message string byte-for-byte against the C sources
  - confirm TestSeedCompatItemTables passes with its golden untouched

Refs #13.
This commit is contained in:
2026-08-09 09:55:38 +00:00
parent 727dfb2642
commit 3f0a14c5e6
15 changed files with 401 additions and 9 deletions

View File

@@ -457,6 +457,10 @@ func newBlockingTerm() *blockingTerm {
func (t *blockingTerm) Render(*Window) {}
// Repaint has nothing to redraw: this terminal exists for its input
// behaviour, and no autosave test types CTRL-R.
func (t *blockingTerm) Repaint() {}
func (t *blockingTerm) Fini() {}
// Interrupt wakes a blocked ReadChar; called from the saving goroutine.

37
game/command_test.go Normal file
View File

@@ -0,0 +1,37 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// TestRedrawCommandForcesFullRepaint pins CTRL-R to a forced repaint
// rather than an ordinary refresh. C's arm is "after = FALSE;
// clearok(curscr, TRUE); wrefresh(curscr);" (command.c), and the
// clearok is the command: a diffing refresh compares the new frame
// against the device's record of the old one and sends nothing when they
// agree, which is exactly the situation after some other program has
// scribbled on the terminal. Only the terminal can tell the difference,
// so the test watches the terminal rather than the window contents.
func TestRedrawCommandForcesFullRepaint(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
term, ok := g.scr.term.(*testTerm)
if !ok {
t.Fatal("game terminal is not a testTerm")
}
before := term.repaints
g.After = true
g.dispatch(CTRL('R'))
if term.repaints != before+1 {
t.Errorf("terminal repainted %d times, want %d: CTRL-R did not force "+
"a full redraw", term.repaints-before, 1)
}
if g.After {
t.Error("CTRL-R consumed a turn; C sets after = FALSE")
}
}

View File

@@ -194,6 +194,74 @@ func TestZapSlowMonster(t *testing.T) {
}
}
// bizarreSchtick is C's message for a zap that matched no case at all
// (sticks.c do_zap, the "otherwise" arm). Shared by the pair of tests
// below so that the one asserting it appears and the one asserting it
// does not can never drift apart.
const bizarreSchtick = "what a bizarre schtick!"
// TestZapUnhandledWandSaysBizarreSchtick pins the closing arm of C's zap
// switch. Every WS_ kind has a case, so the arm is reachable only for an
// o_which outside the table — here a wand one past the end, the state a
// corrupt save file can still describe. C's message is not gated on the
// wizard flag, only on the MASTER build this port is, so no test setup
// turns it on.
func TestZapUnhandledWandSaysBizarreSchtick(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
wand := malformed(KindWand)
wand.Charges = 3
ch := give(g, wand)
setInput(t, g, ch)
g.Msgs.Huh = ""
g.doZap()
if g.Msgs.Huh != bizarreSchtick {
t.Errorf("message = %q, want %q", g.Msgs.Huh, bizarreSchtick)
}
// C falls out of the switch into o_charges-- from the otherwise arm
// as much as from any other.
if wand.Charges != 2 {
t.Errorf("charges = %d after zapping, want 2", wand.Charges)
}
}
// TestZapWandOfNothingIsSilent is the other half, and the reason the
// message cannot simply be attached to "no handler ran". WS_NOP is a case
// of C's switch in its own right — "when WS_NOP: break;" — so the wand
// that does nothing does it quietly, and only a kind C had no case for
// is bizarre.
func TestZapWandOfNothingIsSilent(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
stick := newObject()
stick.Kind = KindWand
stick.Which = int(WandNothing)
g.fixStick(stick)
ch := give(g, stick)
setInput(t, g, ch)
charges := stick.Charges
g.Msgs.Huh = ""
g.doZap()
if g.Msgs.Huh == bizarreSchtick {
t.Errorf("the wand of nothing said %q; WS_NOP is a case of C's "+
"switch, not an unhandled kind", bizarreSchtick)
}
if stick.Charges != charges-1 {
t.Error("zap did not use a charge")
}
}
func TestParseOpts(t *testing.T) {
t.Parallel()

View File

@@ -1,6 +1,8 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// ItemLore is the per-game item identity state: the randomized appearance
// names and the seven mutable ObjInfo tables (extern.c/init.c).
type ItemLore struct {
@@ -151,6 +153,43 @@ type RogueGame struct {
data *gameData
}
// Greeting is the line C printed on stdout while the player waited for
// the dungeon to be dug, immediately before initscr() (main.c main). The
// caller prints it before the terminal package takes the screen, which is
// where initscr() sat; there is no trailing newline in either wording,
// because C followed the printf with fflush and let curses have the
// display.
//
// Only the wizard wording is #ifdef MASTER in C, and it carries the
// dungeon number, which is the seed (main.c assigns seed = dnum right
// after choosing dnum). The other wording is unconditional.
//
// The name is C's whoami, resolved the way main.c resolves it: parse_opts
// runs before the printf, so a ROGUEOPTS "name=" setting is what the
// player is greeted by, and the account name is only the fallback. New
// does the same parse a moment later; doing it here too is safe because
// ParseOpts does nothing but assign into the fields it is handed — no
// RNG, no screen — so it cannot disturb the item tables the seed-compat
// golden pins.
func Greeting(params Params) string {
whoami := params.Name
if params.RogueOpts != "" {
opts := &RogueGame{Whoami: params.Name}
opts.ParseOpts(params.RogueOpts)
whoami = opts.Whoami
}
if params.Wizard {
return fmt.Sprintf("Hello %s, welcome to dungeon #%d",
whoami, params.Seed)
}
return fmt.Sprintf(
"Hello %s, just a moment while I dig the dungeon...", whoami)
}
// New builds a game from params, seeds the RNG, and randomizes the item
// appearance tables (the front half of main.c main(); the player roll-up
// and first level arrive with later porting phases).

64
game/greeting_test.go Normal file
View File

@@ -0,0 +1,64 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"strings"
"testing"
)
// TestGreeting pins both wordings of main.c's pre-initscr printf byte for
// byte. The wizard one carries dnum, which main.c has just assigned to
// seed, so it is the seed the player sees. Neither ends in a newline: C
// printed, flushed, and handed the display to curses.
func TestGreeting(t *testing.T) {
t.Parallel()
cases := []struct {
name string
params Params
want string
}{
{
name: "normal",
params: Params{Name: "conan", Seed: 4242},
want: "Hello conan, just a moment while I dig the dungeon...",
},
{
name: "wizard names the dungeon",
params: Params{Name: "conan", Seed: 4242, Wizard: true},
want: "Hello conan, welcome to dungeon #4242",
},
{
// parse_opts runs before the printf in main.c, and whoami
// falls back to the account name only when ROGUEOPTS left it
// empty, so the option is what the player is greeted by.
name: "ROGUEOPTS name wins over the account name",
params: Params{
Name: "conan", Seed: 7, RogueOpts: "name=Rodney",
},
want: "Hello Rodney, just a moment while I dig the dungeon...",
},
{
name: "ROGUEOPTS without a name keeps the account name",
params: Params{
Name: "conan", Seed: 7, RogueOpts: "terse,fruit=mango",
},
want: "Hello conan, just a moment while I dig the dungeon...",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := Greeting(tc.params)
if got != tc.want {
t.Errorf("Greeting() = %q, want %q", got, tc.want)
}
if strings.HasSuffix(got, "\n") {
t.Error("greeting ends in a newline; C's printf did not")
}
})
}
}

View File

@@ -23,6 +23,15 @@ type Terminal interface {
// the one Terminal method called from another goroutine, so an
// implementation must be safe to call concurrently with ReadChar.
Interrupt()
// Repaint forces the device to redraw every cell it is showing, the
// redraw command's whole point (curses clearok(curscr, TRUE) followed
// by wrefresh(curscr)). Render cannot stand in for it: a device that
// diffs against its own idea of what is on screen will do nothing at
// all when the screen has been corrupted by something else's output,
// which is the case the player types CTRL-R for. It repaints what was
// last rendered — C repainted curscr, not stdscr — so it neither
// needs nor takes a window.
Repaint()
// Fini restores the device to its pre-game state (curses endwin). The
// game calls it on its way out, since one game run is one process.
Fini()
@@ -223,6 +232,14 @@ func (s *Screen) Refresh() {
}
}
// Repaint forces the device to redraw everything it is showing, if there
// is a device (curses clearok(curscr, TRUE) + wrefresh(curscr)).
func (s *Screen) Repaint() {
if s.term != nil {
s.term.Repaint()
}
}
// Fini restores the terminal device, if there is one (curses endwin).
func (s *Screen) Fini() {
if s.term != nil {
@@ -263,3 +280,4 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
func (g *RogueGame) clear() { g.scr.Std.Clear() }
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
func (g *RogueGame) refresh() { g.scr.Refresh() }
func (g *RogueGame) repaint() { g.scr.Repaint() }

View File

@@ -31,14 +31,30 @@ func (g *RogueGame) doZap() {
return
}
// A malformed Which yields no handler, which is exactly what C did in a
// non-MASTER build: its do_zap switch matched no case, fell out, and
// still ran o_charges--. (The MASTER-only "what a bizarre schtick!"
// message for that arm is issue #13, not this bounds fix.)
if h := g.data.zapHandler(obj); h != nil {
// C's switch has a case for every one of the 14 WS_ kinds, so its
// closing "otherwise: msg(...)" arm is reachable only for an o_which
// outside the table — the malformed objects hasValidWhich screens
// for, which is why no handler and a legal Which can only mean WS_NOP.
//
// The message is under #ifdef MASTER, not under a wizard test: C
// printed it for every player of a MASTER build, which is the build
// this port is (see the '+' command, issue #11). Do not gate it on
// g.Wizard.
//
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
// deliberately does nothing says nothing either. All three arms fall
// out of the switch into o_charges--, so even the bizarre schtick
// costs a charge.
h := g.data.zapHandler(obj)
switch {
case h != nil:
if !h(g, obj) {
return // the zap aborted; no charge is used
}
case obj.hasValidWhich(): // WS_NOP
default:
g.msg("what a bizarre schtick!")
}
obj.Charges--

View File

@@ -785,7 +785,10 @@ func newGameData() *gameData {
},
CTRL('R'): func(g *RogueGame) {
g.After = false
g.refresh()
// C forces a full repaint here (clearok + wrefresh on
// curscr), not the diffing refresh: the command exists
// for screens the game's own record no longer matches.
g.repaint()
},
'v': func(g *RogueGame) {
g.After = false

View File

@@ -8,10 +8,18 @@ type testTerm struct {
input []byte
pos int
tick int
// repaints counts forced full redraws. Rendering is a no-op here, so
// counting is the only way a headless test can tell that CTRL-R asked
// for a repaint rather than an ordinary refresh — the two are
// indistinguishable in the window contents, which is the whole reason
// the bug this replaces went unnoticed.
repaints int
}
func (t *testTerm) Render(*Window) {}
func (t *testTerm) Repaint() { t.repaints++ }
func (t *testTerm) Fini() {}
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.

View File

@@ -187,8 +187,9 @@ func malformed(kind ObjectKind) *Object {
}
// TestZapMalformedWandDoesNotPanic covers the sticks.go dispatch. C's
// non-MASTER do_zap matched no case and still ran o_charges--, so the
// charge must be spent even though nothing happens.
// do_zap matched no case and still ran o_charges--, so the charge must be
// spent even though the zap did nothing. What it says while doing nothing
// belongs to TestZapUnhandledWandSaysBizarreSchtick.
func TestZapMalformedWandDoesNotPanic(t *testing.T) {
t.Parallel()