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:
@@ -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
37
game/command_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
39
game/game.go
39
game/game.go
@@ -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
64
game/greeting_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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--
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user