Restore three lost C behaviors: schtick message, forced redraw, greeting (closes #13)
Three behaviors from 5.4.4 that the port dropped silently. Each is a few
lines; grouped because they are all "restore something C did".
1. sticks.c 237: the "otherwise" arm closing do_zap's switch printed
"what a bizarre schtick!", and doZap had turned it into doing nothing.
The arm is under #ifdef MASTER, not under a runtime wizard test, so in
the MASTER build this port is it printed for every player and must not
be gated on g.Wizard. WS_NOP is a case of that switch in its own right
("when WS_NOP: break;"), so "no handler ran" cannot be the trigger:
the wand of nothing does nothing quietly. C's switch covers all 14 WS_
values, so its otherwise is reachable only for an o_which outside the
table, which is what Object.hasValidWhich already screens for. All
three arms fall through to obj.Charges--, as C's do.
2. command.c 288-291: CTRL('R') is "after = FALSE; clearok(curscr, TRUE);
wrefresh(curscr);" — a forced full repaint. The port called
g.refresh(), the ordinary diffing blit, which cannot fix the only
situation the command exists for: a screen corrupted by another
program's output leaves the game's record of it still correct, so the
diff sends nothing. New Terminal.Repaint (tcell Screen.Sync, which
discards tcell's record of the terminal rather than diffing against
it), Screen.Repaint and g.repaint(), implemented in term.Tcell and in
both headless test terminals. Named for the curses operation: the
interface is the game's abstraction, not tcell's. It repaints what was
last rendered — C repainted curscr, not stdscr — so it takes no
window.
3. main.c 107-113: the startup greeting existed nowhere in the tree. New
game.Greeting, printed on stdout by cmd/rogue/main.go before
term.New(), the port's initscr(). Only the wizard wording is #ifdef
MASTER; the other is unconditional. The %d is dnum, which main.c has
just assigned to seed, so it is Params.Seed. Neither wording ends in a
newline. Two placement details the tests pin: the printf sits after
parse_opts, so a ROGUEOPTS name= is what the player is greeted by; and
it sits after the -s/-d handling and after restore(), which never
returns, so a resumed game does not announce that a dungeon is being
dug (digsNewDungeon).
Greeting parses ROGUEOPTS into a throwaway game built the way New builds
the real one, tables and home directory included: ParseOpts handles every
option, not just the one the greeting reads, and inven= is matched
against inv_t_name[], which lives on the game.
All three message strings verified byte-for-byte against origin/c-master
sticks.c and main.c. No RNG call is added on any path and nothing under
game/testdata/ changed; TestSeedCompatItemTables is green against the
untouched golden.
Mutation-proved, each behavior removed in turn with only its own test
failing: dropping the message arm fails
TestZapUnhandledWandSaysBizarreSchtick; extending the message to WS_NOP
fails TestZapWandOfNothingIsSilent; putting g.refresh() back fails
TestRedrawCommandForcesFullRepaint; swapping the two wordings, and
ignoring the ROGUEOPTS name, both fail TestGreeting; greeting on the
restore path fails TestDigsNewDungeon.
ARCHITECTURE.md 5.3 gains Repaint and why a blit cannot substitute for
it; nothing here is deliberately dropped, so section 9 is unchanged.
TODO.md gets a Completed Steps entry; Next Step deliberately not rotated,
this being out-of-band issue work.
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()
|
||||
|
||||
|
||||
50
game/game.go
50
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,54 @@ 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.
|
||||
//
|
||||
// The game it parses into is a throwaway, but it is built the way New
|
||||
// builds the real one, because ParseOpts handles every option and not
|
||||
// just the one this function reads: "inven=" is matched against the
|
||||
// inv_t_name[] table and "file=~/..." against the home directory, both
|
||||
// of which live on the game. A greeting that skimped on them faulted on
|
||||
// a perfectly legal ROGUEOPTS before the player saw a single character.
|
||||
func Greeting(params Params) string {
|
||||
whoami := params.Name
|
||||
|
||||
if params.RogueOpts != "" {
|
||||
opts := &RogueGame{
|
||||
data: newGameData(),
|
||||
Whoami: params.Name,
|
||||
Home: params.Home,
|
||||
}
|
||||
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).
|
||||
|
||||
80
game/greeting_test.go
Normal file
80
game/greeting_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
//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()
|
||||
|
||||
// The account name main.c copies into whoami when ROGUEOPTS does not
|
||||
// name the player itself.
|
||||
const account = "conan"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
params Params
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
params: Params{Name: account, Seed: 4242},
|
||||
want: "Hello conan, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
name: "wizard names the dungeon",
|
||||
params: Params{Name: account, 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: account, 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: account, Seed: 7, RogueOpts: "terse,fruit=mango",
|
||||
},
|
||||
want: "Hello conan, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
// ParseOpts reaches every option, not just name=, and the
|
||||
// inventory style is matched against a table (options.c
|
||||
// parse_opts, inv_t_name[]) that lives in the game data. A
|
||||
// greeting parsed on a game without those tables faulted on
|
||||
// this ROGUEOPTS before it could print anything at all.
|
||||
name: "ROGUEOPTS inventory style parses without a fault",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "inven=slow,name=Rodney",
|
||||
},
|
||||
want: "Hello Rodney, 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