createObj stored the raw 0-f nibble as Object.Which with no bounds check, so wizard mode -> C -> / -> f made a wand numbered 15 against a 14-entry table and panicked in fixStick. Input outside 0-f overshoots much further rather than going negative: readchar returns a byte, so the int(ch-'a') + 10 branch is byte arithmetic and wraps, giving 234 for 'A' and 202 for '!', and panicked the same way. C's create_obj() was equally unchecked, but its consumers were either switches (defined for any value) or static-array reads past the end (undefined, and survivable in practice). Since one game is now one process, the Go panic kills the game outright and leaves the terminal in raw mode. Reject at the two boundaries a bad Which can enter through. createObj now refuses an out-of-range choice with a message drawn from C's own type_name() vocabulary and adds nothing to the pack, a deliberate divergence recorded in a comment because C had no defined behavior here to be faithful to. Restore refuses a snapshot describing such an object (ErrSaveCorrupt) rather than loading a game that would explode later. A decoded snapshot is also the only source of a genuinely negative Which, Which being a plain int off the wire, so it is what the Which >= 0 arm of hasValidWhich defends against. Behind those, whichLimit/hasValidWhich back defensive guards at every dispatch the issue names: the quaffHandler/readHandler/zapHandler accessors return no handler instead of indexing (for wands that is exactly what non-MASTER C did, matching no case and still running o_charges--), the callIt lore lookups, identifyType, armorClass for the a_class[] reads, initWeapon against the missing init_dam[] row for WeaponFlame, fixStick's ws_type[] read, and inventoryName and objectWorth, hoisted so one check each covers the whole family of per-kind name and appraisal tables. identifyType's bound is defensive rather than live: readHandlers registers readIdentify only for the identify scrolls, all of which sit inside the shorter idType table. No in-range input changes behavior and no guard consumes a random number: the rejection precedes every rnd() call. TestSeedCompatItemTables stays green untouched. New game/wizard_test.go covers the exact reproducer, a rejection sweep over every indexed kind including the wrapped values from input outside 0-f, an acceptance sweep proving valid choices still build the right item, one no-panic test per guarded family, the fixStick crash site, the corrupt-save rejection over both the wrapped values and a negative Which, and a check that whichLimit still agrees with the table sizes. Each guard was confirmed load-bearing by reverting it and watching the test fail. TODO.md records the step; Next Step is deliberately left alone, since this arrived out of band via an issue.
291 lines
6.8 KiB
Go
291 lines
6.8 KiB
Go
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
|
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// rip.c — the fun ends: death or a total win.
|
|
//
|
|
// The C functions here call exit(). One game run is one process, so the
|
|
// port does the same: myExit restores the terminal and exits directly.
|
|
|
|
// myExit leaves the process properly (main.c my_exit): it restores the
|
|
// terminal and ends the process. Every C caller exited with status 0.
|
|
func (g *RogueGame) myExit() {
|
|
g.scr.Fini()
|
|
os.Exit(0)
|
|
}
|
|
|
|
// death does something really fun when he dies (rip.c death).
|
|
func (g *RogueGame) death(monst byte) {
|
|
p := &g.Player
|
|
p.Purse -= p.Purse / 10
|
|
|
|
g.clear()
|
|
|
|
killer := g.killname(monst, false)
|
|
if !g.Options.Tombstone {
|
|
g.scr.Std.MvPrintwf(NumLines-2, 0, "Killed by ")
|
|
|
|
if monst != 's' && monst != 'h' {
|
|
g.printw("a%s ", vowelstr(killer))
|
|
}
|
|
|
|
g.printw("%s with %d gold", killer, p.Purse)
|
|
} else {
|
|
year := time.Now().Year()
|
|
|
|
for i, line := range g.data.ripArt {
|
|
g.scr.Std.MvAddStr(8+i, 0, line)
|
|
}
|
|
|
|
g.mvaddstr(17, center(killer), killer)
|
|
|
|
if monst == 's' || monst == 'h' {
|
|
g.mvaddch(16, 32, ' ')
|
|
} else {
|
|
g.mvaddstr(16, 33, vowelstr(killer))
|
|
}
|
|
|
|
g.mvaddstr(14, center(g.Whoami), g.Whoami)
|
|
|
|
au := fmt.Sprintf("%d Au", p.Purse)
|
|
g.mvaddstr(15, center(au), au)
|
|
g.mvaddstr(18, 26, fmt.Sprintf("%4d", year))
|
|
}
|
|
|
|
g.mvaddstr(NumLines-1, 0, "[Press return to continue]")
|
|
g.refresh()
|
|
|
|
flags := 0
|
|
if g.HasAmulet {
|
|
flags = 3
|
|
}
|
|
|
|
g.score(p.Purse, flags, monst)
|
|
g.waitFor('\n')
|
|
g.myExit()
|
|
}
|
|
|
|
// center returns the column to center the given string on the tombstone
|
|
// (rip.c center).
|
|
func center(str string) int {
|
|
return 28 - (len(str)+1)/2
|
|
}
|
|
|
|
// totalWinner is the code for a winner (rip.c total_winner).
|
|
func (g *RogueGame) totalWinner() {
|
|
p := &g.Player
|
|
g.clear()
|
|
g.standout()
|
|
|
|
banner := []string{
|
|
" ",
|
|
" @ @ @ @ @ @@@ @ @ ",
|
|
" @ @ @@ @@ @ @ @ @ ",
|
|
" @ @ @@@ @ @ @ @ @ @@@ @@@@ @@@ @ @@@ @ ",
|
|
" @@@@ @ @ @ @ @ @ @ @ @ @ @ @ @ @ ",
|
|
" @ @ @ @ @ @ @ @@@@ @ @ @@@@@ @ @ @ ",
|
|
" @ @ @ @ @ @@ @ @ @ @ @ @ @ @ @ @ ",
|
|
" @@@ @@@ @@ @ @ @ @@@@ @@@@ @@@ @@@ @@ @ ",
|
|
" ",
|
|
" Congratulations, you have made it to the light of day! ",
|
|
}
|
|
for i, line := range banner {
|
|
g.scr.Std.MvAddStr(i, 0, line)
|
|
}
|
|
|
|
g.standend()
|
|
g.scr.Std.MvAddStr(10, 0,
|
|
"You have joined the elite ranks of those who have escaped the")
|
|
g.scr.Std.MvAddStr(11, 0,
|
|
"Dungeons of Doom alive. You journey home and sell all your loot at")
|
|
g.scr.Std.MvAddStr(12, 0, "a great profit and are admitted to the Fighters' Guild.")
|
|
g.mvaddstr(NumLines-1, 0, "--Press space to continue--")
|
|
g.refresh()
|
|
g.waitFor(' ')
|
|
g.clear()
|
|
g.mvaddstr(0, 0, " Worth Item")
|
|
g.move(1, 0)
|
|
|
|
oldpurse := p.Purse
|
|
line := 1
|
|
|
|
for _, obj := range p.Pack {
|
|
worth := g.objectWorth(obj)
|
|
|
|
g.scr.Std.MvPrintwf(line, 0, "%c) %5d %s", obj.PackCh, worth,
|
|
g.inventoryName(obj, false))
|
|
line++
|
|
p.Purse += worth
|
|
}
|
|
|
|
g.scr.Std.MvPrintwf(line, 0, " %5d Gold Pieces ", oldpurse)
|
|
g.refresh()
|
|
g.score(p.Purse, 2, ' ')
|
|
g.myExit()
|
|
}
|
|
|
|
// objectWorth appraises one pack item on the way out, marking it known
|
|
// (the switch of rip.c total_winner).
|
|
func (g *RogueGame) objectWorth(obj *Object) int {
|
|
// Same defense as inventoryName: most arms below appraise from a
|
|
// per-kind table at obj.Which, so a malformed item is worth nothing
|
|
// rather than a panic on the way to the scoreboard.
|
|
if !obj.hasValidWhich() {
|
|
return 0
|
|
}
|
|
|
|
it := &g.Items
|
|
worth := 0
|
|
|
|
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
|
switch obj.Kind {
|
|
case KindFood:
|
|
worth = 2 * obj.Count
|
|
case KindWeapon:
|
|
worth = it.Weapons[obj.Which].Worth
|
|
worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count
|
|
obj.Flags.Set(Known)
|
|
case KindArmor:
|
|
worth = it.Armors[obj.Which].Worth
|
|
worth += (9 - obj.ArmorClass) * 100
|
|
worth += 10 * (g.data.armorClass(obj.Which) - obj.ArmorClass)
|
|
obj.Flags.Set(Known)
|
|
case KindScroll:
|
|
worth = loreWorth(&it.Scrolls[obj.Which], obj.Count)
|
|
case KindPotion:
|
|
worth = loreWorth(&it.Potions[obj.Which], obj.Count)
|
|
case KindRing:
|
|
worth = g.ringWorth(obj)
|
|
case KindWand:
|
|
worth = g.wandWorth(obj)
|
|
case KindAmulet:
|
|
worth = 1000
|
|
}
|
|
|
|
if worth < 0 {
|
|
worth = 0
|
|
}
|
|
|
|
return worth
|
|
}
|
|
|
|
// loreWorth appraises a scroll or potion, halved when unidentified, and
|
|
// identifies it (rip.c total_winner).
|
|
func loreWorth(op *ObjInfo, count int) int {
|
|
worth := op.Worth * count
|
|
if !op.Know {
|
|
worth /= 2
|
|
}
|
|
|
|
op.Know = true
|
|
|
|
return worth
|
|
}
|
|
|
|
// ringWorth appraises a ring: bonus rings gain by their bonus, cursed
|
|
// ones are junk (rip.c total_winner).
|
|
func (g *RogueGame) ringWorth(obj *Object) int {
|
|
op := &g.Items.Rings[obj.Which]
|
|
worth := op.Worth
|
|
|
|
if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage ||
|
|
obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity {
|
|
if obj.Bonus > 0 {
|
|
worth += obj.Bonus * 100
|
|
} else {
|
|
worth = 10
|
|
}
|
|
}
|
|
|
|
if !obj.Flags.Has(Known) {
|
|
worth /= 2
|
|
}
|
|
|
|
obj.Flags.Set(Known)
|
|
|
|
op.Know = true
|
|
|
|
return worth
|
|
}
|
|
|
|
// wandWorth appraises a wand or staff by its charges (rip.c
|
|
// total_winner).
|
|
func (g *RogueGame) wandWorth(obj *Object) int {
|
|
op := &g.Items.Sticks[obj.Which]
|
|
worth := op.Worth
|
|
|
|
worth += 20 * obj.Charges
|
|
if !obj.Flags.Has(Known) {
|
|
worth /= 2
|
|
}
|
|
|
|
obj.Flags.Set(Known)
|
|
|
|
op.Know = true
|
|
|
|
return worth
|
|
}
|
|
|
|
// killname converts a code to a monster name (rip.c killname).
|
|
func (g *RogueGame) killname(monst byte, doart bool) string {
|
|
var (
|
|
sp string
|
|
article bool
|
|
)
|
|
|
|
if isUpper(monst) {
|
|
sp = g.Monsters[monst-'A'].Name
|
|
article = true
|
|
} else {
|
|
sp = "Wally the Wonder Badger"
|
|
article = false
|
|
|
|
for _, hp := range g.data.killnameTable {
|
|
if hp.Ch == monst {
|
|
sp = hp.Desc
|
|
article = hp.Print
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if doart && article {
|
|
return "a" + vowelstr(sp) + " " + sp
|
|
}
|
|
|
|
return sp
|
|
}
|
|
|
|
// DeathDemo implements the -d command line option (main.c): burn some
|
|
// random numbers to break patterns, then die a random death. It does not
|
|
// return — death exits the process.
|
|
func (g *RogueGame) DeathDemo() {
|
|
dnum := g.rnd(100)
|
|
for dnum--; dnum > 0; dnum-- {
|
|
g.rnd(100)
|
|
}
|
|
|
|
g.Player.Purse = g.rnd(100) + 1
|
|
g.Depth = g.rnd(100) + 1
|
|
g.death(g.deathMonst())
|
|
}
|
|
|
|
// deathMonst returns a monster appropriate for a random death (rip.c
|
|
// death_monst).
|
|
func (g *RogueGame) deathMonst() byte {
|
|
poss := []byte{
|
|
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
|
|
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
|
'Y', 'Z', 'a', 'b', 'h', 'd', 's',
|
|
' ', // generates the "Wally the Wonder Badger" message
|
|
}
|
|
|
|
return poss[g.rnd(len(poss))]
|
|
}
|