Merge test/wizard-coverage (wizard commands verified against C)
This commit was merged in pull request #38.
This commit is contained in:
67
TODO.md
67
TODO.md
@@ -29,12 +29,67 @@ Refactor ground rules:
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Broaden unit test coverage where playtesting finds thin spots — wizard commands
|
Tag a release once a full game (Amulet retrieval and score entry) completes
|
||||||
(#7). Rings and sticks, the first two thirds of this step, are done; see the top
|
without defects. Promoted from Future Steps now that the coverage step above it
|
||||||
of Completed Steps.
|
is finished.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-09 Wizard commands under test (`test/wizard-coverage`, closes #7): the
|
||||||
|
last of the three thin spots, so the coverage step is now closed rather than
|
||||||
|
narrowed. `game/wizard.go`'s eight functions had no tests of their own, and
|
||||||
|
the file is not purely a debug surface — `set_know` writes the per-game
|
||||||
|
discovered tables that name items in ordinary play, and `teleport` is what the
|
||||||
|
teleport ring calls every fiftieth turn. Package coverage 60.6% -> 62.4%.
|
||||||
|
Everything expected was transcribed from `wizard.c`, `command.c` (the
|
||||||
|
`CTRL('I')` kit), `extern.c` (`a_class[]`), `weapons.c` (`init_dam[]`) and
|
||||||
|
`rogue.h`; 30 mutations were tried and all 30 were caught.
|
||||||
|
|
||||||
|
Two findings came out of the reading. (1) **A wizard-created cursed weapon
|
||||||
|
is not cursed, in C or here.** `create_obj` sets `ISCURSED` and then calls
|
||||||
|
`init_weapon`, which _assigns_ `weap->o_flags = iwp->iw_flags` and so
|
||||||
|
overwrites the bit it just set; only the `o_hplus` penalty survives, and the
|
||||||
|
"cursed" weapon can still be dropped and unwielded. The port reproduces this
|
||||||
|
exactly. The test asserts the whole flag word comes back as the `init_dam[]`
|
||||||
|
row's value whatever blessing was answered, and deleting the `ISCURSED` line
|
||||||
|
from the port leaves every weapon test green — which is the evidence that
|
||||||
|
the line is dead for weapons. The armor arm has no such clobber and does
|
||||||
|
keep the curse. (2) **`show_map`'s standout is asymmetric in C and symmetric
|
||||||
|
here.** C tests `!(real & F_REAL)` before drawing and `!real` — the whole
|
||||||
|
flag word — after. `new_level` seeds every square with `p_flags = F_REAL`,
|
||||||
|
and exactly three sites clear that bit. `passages.c putpass` sets `F_PASS`
|
||||||
|
first, so its secret passage is left at `0x80`. `passages.c door`'s
|
||||||
|
secret-door arm clears it on a room-wall exit whose flags are still exactly
|
||||||
|
`F_REAL` (`rooms.c` writes no `p_flags` at all), leaving `p_flags == 0`; its
|
||||||
|
per-square gate is `rnd(5) == 0` against `putpass`'s `rnd(40) == 0`, and
|
||||||
|
`game/passages.go`'s `door` reproduces it. `new_level`'s trap loop then ORs
|
||||||
|
in `rnd(NTRAPS)`, which is `abs((int) RN) % 8` and so yields `0..7`, and
|
||||||
|
`T_DOOR` is `00` — an unsprung trapdoor square is also exactly zero
|
||||||
|
(`be_trapped` is what later ORs `F_SEEN` into it). So C _does_ turn standout
|
||||||
|
off again, at secret doors and unsprung trapdoors; what it gets wrong is
|
||||||
|
leaking the attribute forward from a secret passage or a non-trapdoor trap
|
||||||
|
until it reaches one of those. Intermittent bands of reverse video, not a
|
||||||
|
permanently reversed map. `game/wizard.go` tests `isReal` both times and
|
||||||
|
highlights the one square. That is a display-only difference in a
|
||||||
|
wizard-only command and was reported on the issue rather than changed here;
|
||||||
|
the test asserts the map characters unconditionally but the standout
|
||||||
|
attribute only up to the first secret square, so it pins nothing that C
|
||||||
|
contradicts.
|
||||||
|
|
||||||
|
Two things the tests had to be built around. The `insist` arm of `whatis` is
|
||||||
|
a loop whose only exits are picking a matching item and `n_objs == 0`, so a
|
||||||
|
script that runs dry hangs instead of failing — every sequence that can
|
||||||
|
re-prompt ends in an abort tail, the `n_objs == 0` exit is reached the way a
|
||||||
|
player reaches it (`*` for a list with nothing appropriate in the pack)
|
||||||
|
rather than by poking the counter, and the one mutation that deletes that
|
||||||
|
exit is the only one of the 30 that fails by timeout instead of fast,
|
||||||
|
necessarily so. And `show_map` does **not** mark squares seen — it writes
|
||||||
|
into `hw` and touches no `PLACE` at all — so the issue's wording for it
|
||||||
|
could not be tested as written; the loop bounds are asserted instead by
|
||||||
|
planting a marker in the rows C's loop excludes, since those rows are blank
|
||||||
|
on a real level and copying blanks over blanks would have made the bound
|
||||||
|
unfalsifiable.
|
||||||
|
|
||||||
- 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6):
|
- 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6):
|
||||||
the second of the three thin spots the Next Step names. `game/sticks.go` was
|
the second of the three thin spots the Next Step names. `game/sticks.go` was
|
||||||
the largest under-tested file in the repo — 534 lines, 23 functions, one test
|
the largest under-tested file in the repo — 534 lines, 23 functions, one test
|
||||||
@@ -724,13 +779,11 @@ of Completed Steps.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
1. Tag a release once a full game (Amulet retrieval and score entry) completes
|
1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||||
without defects.
|
|
||||||
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
|
||||||
per-game dungeon dimensions instead of the 80x24 constants; open design
|
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||||
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||||
80x24 mode.
|
80x24 mode.
|
||||||
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
2. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||||
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
||||||
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
||||||
REPO_POLICIES.md.
|
REPO_POLICIES.md.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -530,3 +532,962 @@ func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) {
|
|||||||
t.Error("'+' consumed a turn; C sets after = FALSE")
|
t.Error("'+' consumed a turn; C sets after = FALSE")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rest of this file covers game/wizard.go proper (issue #7). Every
|
||||||
|
// expected value below is transcribed from origin/c-master — wizard.c for
|
||||||
|
// create_obj/whatis/set_know/teleport/show_map, command.c for the CTRL('I')
|
||||||
|
// kit, extern.c for a_class[], weapons.c for init_dam[], and rogue.h for
|
||||||
|
// the R_* numbering and the F_* place flags — never from what the port
|
||||||
|
// happens to return.
|
||||||
|
//
|
||||||
|
// Two shapes recur. Scripted input always ends with an abort tail (a space
|
||||||
|
// for a --More--, then ESCAPE), because testTerm.ReadChar hands out filler
|
||||||
|
// forever once the script runs dry and a re-prompting loop would spin to
|
||||||
|
// the suite timeout instead of failing. And where C issues no prompt at
|
||||||
|
// all, the test asserts on the scripted input cursor rather than on state:
|
||||||
|
// a stray readchar would eat the next answer and desynchronise everything
|
||||||
|
// after it, which no state assertion would notice.
|
||||||
|
|
||||||
|
// mkWizard builds a headless game in wizard mode the way the program does.
|
||||||
|
// cmd/rogue/main.go turns ROGUE_WIZARD into Params.Wizard and New consumes
|
||||||
|
// that field, so no test here pokes g.Wizard. depth is what decides
|
||||||
|
// whether the generator produces secret (non-F_REAL) squares at all.
|
||||||
|
func mkWizard(t *testing.T, seed int32, depth int) *RogueGame {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
g := New(Params{Seed: seed, Wizard: true, Term: &testTerm{}})
|
||||||
|
if !g.Wizard {
|
||||||
|
t.Fatal("Params.Wizard did not turn on wizard mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Depth = depth
|
||||||
|
g.NewLevel()
|
||||||
|
g.Oldpos = g.Player.Pos
|
||||||
|
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// packSet snapshots pack membership by identity. add_pack files a new item
|
||||||
|
// in kind order, so its position is no guide to which one it is.
|
||||||
|
func packSet(g *RogueGame) map[*Object]bool {
|
||||||
|
seen := make(map[*Object]bool, len(g.Player.Pack))
|
||||||
|
for _, o := range g.Player.Pack {
|
||||||
|
seen[o] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyNewItem returns the single object added to the pack since before.
|
||||||
|
func onlyNewItem(t *testing.T, g *RogueGame, before map[*Object]bool) *Object {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var made []*Object
|
||||||
|
|
||||||
|
for _, o := range g.Player.Pack {
|
||||||
|
if !before[o] {
|
||||||
|
made = append(made, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(made) != 1 {
|
||||||
|
t.Fatalf("pack gained %d objects, want exactly 1", len(made))
|
||||||
|
}
|
||||||
|
|
||||||
|
return made[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputUsed reports how many scripted keys have been consumed so far.
|
||||||
|
func inputUsed(t *testing.T, g *RogueGame) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tt, ok := g.scr.term.(*testTerm)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("game terminal is not a testTerm")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tt.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateObjFilesTheItemInThePack covers the tail every arm of
|
||||||
|
// wizard.c create_obj shares: o_group = 0, o_count = 1, then
|
||||||
|
// add_pack(obj, FALSE). A potion is the kind C's switch does nothing for,
|
||||||
|
// so nothing else is in the way.
|
||||||
|
func TestCreateObjFilesTheItemInThePack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 21, 1)
|
||||||
|
before := packSet(g)
|
||||||
|
|
||||||
|
setInput(t, g, Potion, '0', ' ', Escape)
|
||||||
|
g.createObj()
|
||||||
|
|
||||||
|
made := onlyNewItem(t, g, before)
|
||||||
|
|
||||||
|
if made.Kind != KindPotion || made.Which != int(PotionConfusion) {
|
||||||
|
t.Fatalf("created %v which %d, want %v which %d",
|
||||||
|
made.Kind, made.Which, KindPotion, int(PotionConfusion))
|
||||||
|
}
|
||||||
|
|
||||||
|
if made.Count != 1 {
|
||||||
|
t.Errorf("count = %d, want the 1 C sets", made.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if made.Group != 0 {
|
||||||
|
t.Errorf("group = %d, want the 0 C sets", made.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
if made.PackCh == 0 {
|
||||||
|
t.Error("created object has no pack letter: add_pack never filed it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateObjGoldAsksHowMuch covers the GOLD arm, C's
|
||||||
|
// msg("how much?") followed by get_num(&obj->o_goldval, stdscr).
|
||||||
|
func TestCreateObjGoldAsksHowMuch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 22, 1)
|
||||||
|
before := packSet(g)
|
||||||
|
|
||||||
|
setInput(t, g, Gold, '0', '2', '5', '0', '\n', ' ', Escape)
|
||||||
|
g.createObj()
|
||||||
|
|
||||||
|
made := onlyNewItem(t, g, before)
|
||||||
|
|
||||||
|
if made.Kind != KindGold {
|
||||||
|
t.Fatalf("created %v, want %v", made.Kind, KindGold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if made.GoldValue != 250 {
|
||||||
|
t.Errorf("gold value = %d, want the typed 250", made.GoldValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateWeaponBlessing pins the weapon arm of create_obj to C:
|
||||||
|
//
|
||||||
|
// if (bless == '-') obj->o_flags |= ISCURSED;
|
||||||
|
// if (obj->o_type == WEAPON) {
|
||||||
|
// init_weapon(obj, obj->o_which);
|
||||||
|
// if (bless == '-') obj->o_hplus -= rnd(3)+1;
|
||||||
|
// if (bless == '+') obj->o_hplus += rnd(3)+1;
|
||||||
|
//
|
||||||
|
// A curse subtracts and a blessing adds — the opposite of the armor arm
|
||||||
|
// below, and rnd(3)+1 is 1..3 either way.
|
||||||
|
//
|
||||||
|
// The curse itself does not survive on a weapon, and that is C's own
|
||||||
|
// behavior, not a port bug: weapons.c init_weapon *assigns*
|
||||||
|
// weap->o_flags = iwp->iw_flags, so it overwrites the ISCURSED bit set
|
||||||
|
// three lines earlier with the init_dam[] row's flags. A wizard-created
|
||||||
|
// "cursed" weapon therefore carries only the hit penalty and can still be
|
||||||
|
// dropped and unwielded. The mace row's flags are 0, so the whole word
|
||||||
|
// must come back 0 here whatever was answered. The armor arm has no such
|
||||||
|
// clobber, which is why TestCreateArmorBlessing does expect ISCURSED.
|
||||||
|
func TestCreateWeaponBlessing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
bless byte
|
||||||
|
low, hi int
|
||||||
|
}{
|
||||||
|
{"no blessing", 'n', 0, 0},
|
||||||
|
{"blessed adds to the hit bonus", '+', 1, 3},
|
||||||
|
{"cursed subtracts from it", '-', -3, -1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 23, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = KindWeapon
|
||||||
|
obj.Which = int(WeaponMace)
|
||||||
|
|
||||||
|
setInput(t, g, tc.bless, ' ', Escape)
|
||||||
|
g.createWeaponArmor(obj)
|
||||||
|
|
||||||
|
if obj.Flags != 0 {
|
||||||
|
t.Errorf("flags = %d, want the init_dam mace row's 0: "+
|
||||||
|
"init_weapon assigns o_flags over any curse",
|
||||||
|
obj.Flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.HPlus < tc.low || obj.HPlus > tc.hi {
|
||||||
|
t.Errorf("hit bonus = %d, want %d..%d",
|
||||||
|
obj.HPlus, tc.low, tc.hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// init_weapon ran: the mace row of C's init_dam[].
|
||||||
|
if got := obj.Damage.String(); got != "2x4" {
|
||||||
|
t.Errorf("damage = %q, want the init_dam mace row 2x4", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := obj.HurlDmg.String(); got != "1x3" {
|
||||||
|
t.Errorf("hurl damage = %q, want 1x3", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateArmorBlessing pins the armor arm, where C moves o_arm the
|
||||||
|
// other way because a lower armor class is better:
|
||||||
|
//
|
||||||
|
// obj->o_arm = a_class[obj->o_which];
|
||||||
|
// if (bless == '-') obj->o_arm += rnd(3)+1;
|
||||||
|
// if (bless == '+') obj->o_arm -= rnd(3)+1;
|
||||||
|
//
|
||||||
|
// extern.c's a_class[] has PLATE_MAIL at 3, so the three answers land at
|
||||||
|
// 3, 0..2 and 4..6.
|
||||||
|
func TestCreateArmorBlessing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
bless byte
|
||||||
|
cursed bool
|
||||||
|
low, hi int
|
||||||
|
}{
|
||||||
|
{"no blessing leaves the table value", 'n', false, 3, 3},
|
||||||
|
{"blessed lowers the armor class", '+', false, 0, 2},
|
||||||
|
{"cursed raises it", '-', true, 4, 6},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 24, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = KindArmor
|
||||||
|
obj.Which = int(ArmorPlateMail)
|
||||||
|
|
||||||
|
setInput(t, g, tc.bless, ' ', Escape)
|
||||||
|
g.createWeaponArmor(obj)
|
||||||
|
|
||||||
|
if got := obj.Flags.Has(Cursed); got != tc.cursed {
|
||||||
|
t.Errorf("cursed = %v, want %v", got, tc.cursed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.ArmorClass < tc.low || obj.ArmorClass > tc.hi {
|
||||||
|
t.Errorf("armor class = %d, want %d..%d",
|
||||||
|
obj.ArmorClass, tc.low, tc.hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The armor arm must not fall into init_weapon.
|
||||||
|
if obj.Kind != KindArmor || obj.Which != int(ArmorPlateMail) {
|
||||||
|
t.Errorf("armor became %v which %d", obj.Kind, obj.Which)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateRingBonus covers the four bonus rings, C's
|
||||||
|
// obj->o_arm = (bless == '-' ? -1 : rnd(2) + 1), where rnd(2)+1 is 1..2.
|
||||||
|
// R_ADDHIT is RingDexterity and R_ADDDAM is RingIncreaseDamage; the
|
||||||
|
// RingKind iota matches C's R_ numbering index for index.
|
||||||
|
func TestCreateRingBonus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rings := []RingKind{
|
||||||
|
RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage,
|
||||||
|
}
|
||||||
|
|
||||||
|
blessings := []struct {
|
||||||
|
name string
|
||||||
|
bless byte
|
||||||
|
cursed bool
|
||||||
|
low, hi int
|
||||||
|
}{
|
||||||
|
{"blessed", '+', false, 1, 2},
|
||||||
|
{"unblessed", 'n', false, 1, 2},
|
||||||
|
{"cursed", '-', true, -1, -1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ring := range rings {
|
||||||
|
for _, tc := range blessings {
|
||||||
|
t.Run(ringTestName(ring, tc.name), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 25, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = KindRing
|
||||||
|
obj.Which = int(ring)
|
||||||
|
|
||||||
|
setInput(t, g, tc.bless, ' ', Escape)
|
||||||
|
g.createRing(obj)
|
||||||
|
|
||||||
|
if got := obj.Flags.Has(Cursed); got != tc.cursed {
|
||||||
|
t.Errorf("cursed = %v, want %v", got, tc.cursed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.Bonus < tc.low || obj.Bonus > tc.hi {
|
||||||
|
t.Errorf("bonus = %d, want %d..%d",
|
||||||
|
obj.Bonus, tc.low, tc.hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
if used := inputUsed(t, g); used != 1 {
|
||||||
|
t.Errorf("read %d keys, want the 1 blessing answer",
|
||||||
|
used)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ringTestName labels a subtest by ring index, the R_ number from rogue.h.
|
||||||
|
func ringTestName(ring RingKind, what string) string {
|
||||||
|
return "R_" + strconv.Itoa(int(ring)) + " " + what
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateRingCursedKindsSkipThePrompt covers C's second case group,
|
||||||
|
// "when R_AGGR: case R_TELEPORT: obj->o_flags |= ISCURSED": cursed with
|
||||||
|
// no blessing question and no bonus at all.
|
||||||
|
func TestCreateRingCursedKindsSkipThePrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, ring := range []RingKind{RingAggravateMonsters, RingTeleportation} {
|
||||||
|
t.Run(ringTestName(ring, "is cursed silently"), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 26, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = KindRing
|
||||||
|
obj.Which = int(ring)
|
||||||
|
|
||||||
|
setInput(t, g, ' ', Escape)
|
||||||
|
g.createRing(obj)
|
||||||
|
|
||||||
|
if !obj.Flags.Has(Cursed) {
|
||||||
|
t.Error("ring is not cursed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.Bonus != 0 {
|
||||||
|
t.Errorf("bonus = %d, want 0: C sets none here", obj.Bonus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if used := inputUsed(t, g); used != 0 {
|
||||||
|
t.Errorf("read %d keys; C asks nothing for this kind", used)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateRingOtherKindsAreLeftAlone is the default arm: every ring
|
||||||
|
// outside C's two case groups gets no prompt, no curse and no bonus.
|
||||||
|
func TestCreateRingOtherKindsAreLeftAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
others := []RingKind{
|
||||||
|
RingSustainStrength, RingSearching, RingSeeInvisible, RingAdornment,
|
||||||
|
RingRegeneration, RingSlowDigestion, RingStealth, RingMaintainArmor,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ring := range others {
|
||||||
|
t.Run(ringTestName(ring, "is untouched"), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 27, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = KindRing
|
||||||
|
obj.Which = int(ring)
|
||||||
|
|
||||||
|
setInput(t, g, ' ', Escape)
|
||||||
|
g.createRing(obj)
|
||||||
|
|
||||||
|
if obj.Flags.Has(Cursed) {
|
||||||
|
t.Error("ring was cursed; C curses only R_AGGR and R_TELEPORT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.Bonus != 0 {
|
||||||
|
t.Errorf("bonus = %d, want 0", obj.Bonus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if used := inputUsed(t, g); used != 0 {
|
||||||
|
t.Errorf("read %d keys; C asks nothing for this kind", used)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShowMapRendersTheWholeLevel covers wizard.c show_map against a
|
||||||
|
// generated level. C clears hw, walks y from 1 to NUMLINES-2 and x across
|
||||||
|
// every column writing chat(y,x), then show_win()s it, so the whole map
|
||||||
|
// including squares the hero has never seen has to land in the hw window.
|
||||||
|
//
|
||||||
|
// What show_map does *not* do is mark anything seen: it touches no PLACE
|
||||||
|
// at all, in C or here, so there is no F_SEEN assertion to make.
|
||||||
|
//
|
||||||
|
// The standout attribute is only asserted up to the first non-real
|
||||||
|
// square, deliberately. C's two tests are not the same test:
|
||||||
|
//
|
||||||
|
// real = flat(y, x);
|
||||||
|
// if (!(real & F_REAL)) wstandout(hw);
|
||||||
|
// ...
|
||||||
|
// if (!real) wstandend(hw); /* whole word, not the bit */
|
||||||
|
//
|
||||||
|
// new_level.c seeds every square with p_flags = F_REAL, and exactly three
|
||||||
|
// sites clear that bit. putpass sets F_PASS first, so a secret passage is
|
||||||
|
// left at 0x80. door's secret-door arm clears it on a room-wall exit whose
|
||||||
|
// flags are still exactly F_REAL, leaving p_flags == 0. And the trap loop
|
||||||
|
// ORs in rnd(NTRAPS), which is 0..7, so the T_DOOR (00) case is zero too
|
||||||
|
// until be_trapped ORs F_SEEN in. So C's wstandend does fire, at secret
|
||||||
|
// doors and unsprung trapdoors; what it gets wrong is leaking standout
|
||||||
|
// forward from a secret passage or a non-trapdoor trap until it reaches
|
||||||
|
// one of those — intermittent bands, not a permanently reversed map.
|
||||||
|
// game/wizard.go tests isReal both times and highlights the single square.
|
||||||
|
// That divergence is reported on issue #7 rather than settled here, so
|
||||||
|
// this test asserts only
|
||||||
|
// what both agree on: the characters everywhere, standout on every
|
||||||
|
// non-real square, and no standout on real squares before the first
|
||||||
|
// non-real one.
|
||||||
|
func TestShowMapRendersTheWholeLevel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Deep enough that putpass and the trap loop actually fire; both are
|
||||||
|
// gated on the depth, so a level-1 map would have nothing secret.
|
||||||
|
g := mkWizard(t, 31, 20)
|
||||||
|
|
||||||
|
setInput(t, g, ' ')
|
||||||
|
g.showMap()
|
||||||
|
|
||||||
|
hw := g.scr.Hw
|
||||||
|
seenSecret := false
|
||||||
|
|
||||||
|
for y := 1; y < NumLines-1; y++ {
|
||||||
|
for x := range NumCols {
|
||||||
|
c := hw.at(y, x)
|
||||||
|
if c.ch != g.Level.Char(y, x) {
|
||||||
|
t.Fatalf("hw(%d,%d) = %q, want the map char %q",
|
||||||
|
y, x, c.ch, g.Level.Char(y, x))
|
||||||
|
}
|
||||||
|
|
||||||
|
isReal := g.Level.FlagsAt(y, x).Has(FReal)
|
||||||
|
if !isReal && !c.standout {
|
||||||
|
t.Errorf("secret square (%d,%d) was not drawn in standout",
|
||||||
|
y, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seenSecret && isReal && c.standout {
|
||||||
|
t.Errorf("ordinary square (%d,%d) was drawn in standout",
|
||||||
|
y, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenSecret = seenSecret || !isReal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seenSecret {
|
||||||
|
t.Fatal("generated level has no non-F_REAL squares: the standout " +
|
||||||
|
"half of show_map went untested, pick a deeper level or seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShowMapLoopBoundsMatchC pins the loop bounds. C starts at y = 1
|
||||||
|
// and stops before NUMLINES-1, so the top line stays free for show_win's
|
||||||
|
// prompt and the status line is never overwritten.
|
||||||
|
func TestShowMapLoopBoundsMatchC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 32, 10)
|
||||||
|
|
||||||
|
// Rows 0 and NUMLINES-1 are blank on a generated level, so a bound
|
||||||
|
// that ran off either end would copy blanks onto blanks and look
|
||||||
|
// identical. Planting a marker in places[] there is what makes the
|
||||||
|
// bound observable at all.
|
||||||
|
const marker = 'Z'
|
||||||
|
|
||||||
|
for x := range NumCols {
|
||||||
|
g.Level.SetChar(0, x, marker)
|
||||||
|
g.Level.SetChar(NumLines-1, x, marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
setInput(t, g, ' ')
|
||||||
|
g.showMap()
|
||||||
|
|
||||||
|
hw := g.scr.Hw
|
||||||
|
|
||||||
|
for x := range NumCols {
|
||||||
|
if got := hw.at(NumLines-1, x).ch; got == marker {
|
||||||
|
t.Fatalf("hw(%d,%d) = %q: the loop ran onto the status line",
|
||||||
|
NumLines-1, x, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const want = "---More (level map)---"
|
||||||
|
|
||||||
|
// show_win's prompt covers the start of row 0; past it the row must
|
||||||
|
// still be untouched by the map loop.
|
||||||
|
for x := len(want); x < NumCols; x++ {
|
||||||
|
if got := hw.at(0, x).ch; got == marker {
|
||||||
|
t.Fatalf("hw(0,%d) = %q: the loop ran onto the message line",
|
||||||
|
x, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
top := make([]byte, 0, len(want))
|
||||||
|
|
||||||
|
for x := range len(want) {
|
||||||
|
top = append(top, hw.at(0, x).ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(top) != want {
|
||||||
|
t.Errorf("top line = %q, want show_win's %q", string(top), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhatisMarksTheRightTable covers wizard.c whatis's switch: scrolls,
|
||||||
|
// potions, sticks and rings each go through set_know on their own
|
||||||
|
// per-game table, and the function ends with msg(inv_name(obj, FALSE)),
|
||||||
|
// so the reported name is the newly identified one.
|
||||||
|
func TestWhatisMarksTheRightTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
kind ObjectKind
|
||||||
|
which int
|
||||||
|
table func(g *RogueGame) []ObjInfo
|
||||||
|
}{
|
||||||
|
{"scroll", KindScroll, int(ScrollEnchantArmor),
|
||||||
|
func(g *RogueGame) []ObjInfo { return g.Items.Scrolls[:] }},
|
||||||
|
{"potion", KindPotion, int(PotionHealing),
|
||||||
|
func(g *RogueGame) []ObjInfo { return g.Items.Potions[:] }},
|
||||||
|
{"wand", KindWand, int(WandLight),
|
||||||
|
func(g *RogueGame) []ObjInfo { return g.Items.Sticks[:] }},
|
||||||
|
{"ring", KindRing, int(RingSearching),
|
||||||
|
func(g *RogueGame) []ObjInfo { return g.Items.Rings[:] }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 33, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = tc.kind
|
||||||
|
obj.Which = tc.which
|
||||||
|
ch := give(g, obj)
|
||||||
|
|
||||||
|
tbl := tc.table(g)
|
||||||
|
tbl[tc.which].Guess = "a wild guess"
|
||||||
|
before := g.inventoryName(obj, false)
|
||||||
|
|
||||||
|
setInput(t, g, ch, ' ', Escape)
|
||||||
|
g.whatis(false, KindNone)
|
||||||
|
|
||||||
|
if !tbl[tc.which].Know {
|
||||||
|
t.Error("set_know did not mark the table entry known")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tbl[tc.which].Guess != "" {
|
||||||
|
t.Errorf("guess = %q, want it freed", tbl[tc.which].Guess)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !obj.Flags.Has(Known) {
|
||||||
|
t.Error("the object did not get ISKNOW")
|
||||||
|
}
|
||||||
|
|
||||||
|
after := g.inventoryName(obj, false)
|
||||||
|
if after == before {
|
||||||
|
t.Errorf("name is still %q; identifying changed nothing",
|
||||||
|
after)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Msgs.Huh != after {
|
||||||
|
t.Errorf("reported %q, want inv_name's %q", g.Msgs.Huh, after)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhatisIdentifiesOnlyTheChosenEntry is the other half of set_know's
|
||||||
|
// contract: one table entry, not a whole table and not its neighbours.
|
||||||
|
func TestWhatisIdentifiesOnlyTheChosenEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 34, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = KindScroll
|
||||||
|
obj.Which = int(ScrollEnchantArmor)
|
||||||
|
ch := give(g, obj)
|
||||||
|
|
||||||
|
setInput(t, g, ch, ' ', Escape)
|
||||||
|
g.whatis(false, KindNone)
|
||||||
|
|
||||||
|
for i := range g.Items.Scrolls {
|
||||||
|
if i == obj.Which {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items.Scrolls[i].Know {
|
||||||
|
t.Errorf("scroll %d was marked known too", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items.Potions[obj.Which].Know {
|
||||||
|
t.Error("identifying a scroll marked the potion at the same index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhatisWeaponAndArmorOnlySetTheFlag pins C's WEAPON/ARMOR arm, which
|
||||||
|
// is "obj->o_flags |= ISKNOW" and no set_know call: knowing this sword is
|
||||||
|
// a sword says nothing about the kind, so the per-kind table entry must
|
||||||
|
// stay untouched.
|
||||||
|
func TestWhatisWeaponAndArmorOnlySetTheFlag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
kind ObjectKind
|
||||||
|
which int
|
||||||
|
table func(g *RogueGame) []ObjInfo
|
||||||
|
}{
|
||||||
|
{"a mace", KindWeapon, int(WeaponMace),
|
||||||
|
func(g *RogueGame) []ObjInfo { return g.Items.Weapons[:] }},
|
||||||
|
{"plate mail", KindArmor, int(ArmorPlateMail),
|
||||||
|
func(g *RogueGame) []ObjInfo { return g.Items.Armors[:] }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 35, 1)
|
||||||
|
obj := newObject()
|
||||||
|
obj.Kind = tc.kind
|
||||||
|
obj.Which = tc.which
|
||||||
|
ch := give(g, obj)
|
||||||
|
|
||||||
|
setInput(t, g, ch, ' ', Escape)
|
||||||
|
g.whatis(false, KindNone)
|
||||||
|
|
||||||
|
if !obj.Flags.Has(Known) {
|
||||||
|
t.Error("the object did not get ISKNOW")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.table(g)[tc.which].Know {
|
||||||
|
t.Error("the kind table was marked known; C calls no " +
|
||||||
|
"set_know for weapons or armor")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhatisEmptyPackSaysSo covers the early return C takes when
|
||||||
|
// pack == NULL, before any prompt happens.
|
||||||
|
func TestWhatisEmptyPackSaysSo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 36, 1)
|
||||||
|
g.Player.Pack = nil
|
||||||
|
|
||||||
|
g.whatis(false, KindNone)
|
||||||
|
|
||||||
|
const want = "you don't have anything in your pack to identify"
|
||||||
|
|
||||||
|
if g.Msgs.Huh != want {
|
||||||
|
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if used := inputUsed(t, g); used != 0 {
|
||||||
|
t.Errorf("read %d keys; C returns before get_item", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhatisInsistRepromptsUntilAMatch drives both re-prompting arms of
|
||||||
|
// C's insist loop in one pass: a wrong-kind pick ("you must identify a
|
||||||
|
// %s") and then a bare escape with n_objs non-zero ("you must identify
|
||||||
|
// something"), before the scroll finally satisfies it. The spaces in the
|
||||||
|
// script are the --More-- acknowledgements those two messages force, and
|
||||||
|
// without insist neither arm exists — the loop would have returned the
|
||||||
|
// potion on the first answer.
|
||||||
|
func TestWhatisInsistRepromptsUntilAMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 37, 1)
|
||||||
|
|
||||||
|
pot := newObject()
|
||||||
|
pot.Kind = KindPotion
|
||||||
|
pot.Which = int(PotionHealing)
|
||||||
|
potCh := give(g, pot)
|
||||||
|
|
||||||
|
scr := newObject()
|
||||||
|
scr.Kind = KindScroll
|
||||||
|
scr.Which = int(ScrollEnchantArmor)
|
||||||
|
scrCh := give(g, scr)
|
||||||
|
|
||||||
|
setInput(t, g, potCh, ' ', Escape, ' ', scrCh, ' ', Escape)
|
||||||
|
g.whatis(true, KindScroll)
|
||||||
|
|
||||||
|
if !g.Items.Scrolls[scr.Which].Know {
|
||||||
|
t.Error("the scroll was never identified: the loop gave up early")
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items.Potions[pot.Which].Know {
|
||||||
|
t.Error("the wrong-kind potion was identified anyway")
|
||||||
|
}
|
||||||
|
|
||||||
|
if used := inputUsed(t, g); used < 5 {
|
||||||
|
t.Errorf("consumed %d keys, want at least the 5 the two "+
|
||||||
|
"re-prompts need", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhatisInsistGivesUpWhenNothingMatches covers "if (n_objs == 0)
|
||||||
|
// return": asking for the list with nothing appropriate in the pack sets
|
||||||
|
// n_objs to 0, and that is the one way out of the insist loop short of
|
||||||
|
// picking something. Getting it wrong is not a wrong answer but a hang.
|
||||||
|
func TestWhatisInsistGivesUpWhenNothingMatches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 38, 1)
|
||||||
|
pot := newObject()
|
||||||
|
pot.Kind = KindPotion
|
||||||
|
pot.Which = int(PotionHealing)
|
||||||
|
give(g, pot)
|
||||||
|
|
||||||
|
setInput(t, g, '*', ' ', Escape)
|
||||||
|
g.whatis(true, KindScroll)
|
||||||
|
|
||||||
|
if g.NObjs != 0 {
|
||||||
|
t.Fatalf("n_objs = %d; this test needs the empty-list path", g.NObjs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items.Potions[pot.Which].Know {
|
||||||
|
t.Error("giving up identified something anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetKnowDoesNotLeakAcrossGames is the reason set_know is not just a
|
||||||
|
// debug helper: the tables it writes are the per-game discovered lists
|
||||||
|
// that drive item naming in ordinary play. They live on RogueGame, and a
|
||||||
|
// second game must start ignorant.
|
||||||
|
func TestSetKnowDoesNotLeakAcrossGames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g1 := mkWizard(t, 39, 1)
|
||||||
|
g2 := mkWizard(t, 40, 1)
|
||||||
|
|
||||||
|
ring := newObject()
|
||||||
|
ring.Kind = KindRing
|
||||||
|
ring.Which = int(RingSearching)
|
||||||
|
g1.Items.Rings[ring.Which].Guess = "a hunch"
|
||||||
|
|
||||||
|
setKnow(ring, g1.Items.Rings[:])
|
||||||
|
|
||||||
|
if !g1.Items.Rings[ring.Which].Know {
|
||||||
|
t.Error("the entry was not marked known")
|
||||||
|
}
|
||||||
|
|
||||||
|
if g1.Items.Rings[ring.Which].Guess != "" {
|
||||||
|
t.Error("the old guess was not freed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ring.Flags.Has(Known) {
|
||||||
|
t.Error("the object did not get ISKNOW")
|
||||||
|
}
|
||||||
|
|
||||||
|
if g2.Items.Rings[ring.Which].Know {
|
||||||
|
t.Error("the second game already knows the ring: the discovered " +
|
||||||
|
"tables are shared between games")
|
||||||
|
}
|
||||||
|
|
||||||
|
if g2.Items.Rings[ring.Which].Guess != "" {
|
||||||
|
t.Error("the second game inherited the first game's guess")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTeleportLandsTheHeroSomewhereLegal covers wizard.c teleport. C
|
||||||
|
// picks the spot with find_floor(NULL, &c, FALSE, TRUE) — any room, and
|
||||||
|
// monst TRUE, so the square must be steppable and unoccupied — then keeps
|
||||||
|
// the room bookkeeping straight (leave_room/enter_room when the room
|
||||||
|
// changed, look(TRUE) when it did not) and clears the run state.
|
||||||
|
func TestTeleportLandsTheHeroSomewhereLegal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 41, 3)
|
||||||
|
p := &g.Player
|
||||||
|
from := p.Pos
|
||||||
|
vacated := g.floorAt()
|
||||||
|
|
||||||
|
g.NoMove = 3
|
||||||
|
g.Count = 5
|
||||||
|
g.Running = true
|
||||||
|
|
||||||
|
g.teleport()
|
||||||
|
|
||||||
|
if p.Pos == from {
|
||||||
|
t.Fatal("hero did not move; this seed teleported him onto himself")
|
||||||
|
}
|
||||||
|
|
||||||
|
pp := g.Level.At(p.Pos.Y, p.Pos.X)
|
||||||
|
if !stepOk(pp.Ch) || pp.Monst != nil {
|
||||||
|
t.Errorf("landed on %q with monster %v: find_floor's contract is "+
|
||||||
|
"a steppable, unoccupied square", pp.Ch, pp.Monst != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Room != g.roomIn(p.Pos) {
|
||||||
|
t.Error("player room does not match the square he is standing on")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := g.mvinch(p.Pos.Y, p.Pos.X); got != PlayerCh {
|
||||||
|
t.Errorf("new square shows %q, want the hero %q", got, PlayerCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := g.mvinch(from.Y, from.X); got != vacated {
|
||||||
|
t.Errorf("vacated square shows %q, want floor_at()'s %q",
|
||||||
|
got, vacated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.NoMove != 0 || g.Count != 0 || g.Running {
|
||||||
|
t.Errorf("run state left at no_move=%d count=%d running=%v",
|
||||||
|
g.NoMove, g.Count, g.Running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTeleportReleasesTheFlytrap covers the tail C spells out: bamfing
|
||||||
|
// away while a Flytrap has hold of you clears ISHELD, resets vf_hit and
|
||||||
|
// puts the 'F' bestiary entry's damage back to "000x0" — the Flytrap
|
||||||
|
// grows its own damage string as it holds on, so leaving it grown would
|
||||||
|
// make the next Flytrap of the game start off mid-fight.
|
||||||
|
func TestTeleportReleasesTheFlytrap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 42, 3)
|
||||||
|
p := &g.Player
|
||||||
|
p.Flags.Set(Held)
|
||||||
|
p.VfHit = 4
|
||||||
|
g.Monsters['F'-'A'].Stats.Dmg = dice("3x4")
|
||||||
|
|
||||||
|
g.teleport()
|
||||||
|
|
||||||
|
if p.On(Held) {
|
||||||
|
t.Error("hero is still held after teleporting away")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.VfHit != 0 {
|
||||||
|
t.Errorf("vf_hit = %d, want 0", p.VfHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// C strcpy's the literal "000x0"; the port keeps damage parsed, so
|
||||||
|
// the same thing reads back as the single 0x0 attack that string
|
||||||
|
// means rather than as those five characters.
|
||||||
|
dmg := g.Monsters['F'-'A'].Stats.Dmg
|
||||||
|
if len(dmg) != 1 || dmg[0].Count != 0 || dmg[0].Sides != 0 {
|
||||||
|
t.Errorf("flytrap damage = %q, want C's 000x0, one 0x0 attack", dmg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTeleportLeavesTheFlytrapAloneWhenFree pins the other side of C's
|
||||||
|
// "if (on(player, ISHELD))" guard: an ordinary wizard teleport must not
|
||||||
|
// reach into the bestiary and reset a Flytrap that is busy elsewhere.
|
||||||
|
func TestTeleportLeavesTheFlytrapAloneWhenFree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 43, 3)
|
||||||
|
g.Player.VfHit = 2
|
||||||
|
g.Monsters['F'-'A'].Stats.Dmg = dice("3x4")
|
||||||
|
|
||||||
|
g.teleport()
|
||||||
|
|
||||||
|
if g.Player.VfHit != 2 {
|
||||||
|
t.Errorf("vf_hit = %d, want the untouched 2", g.Player.VfHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := g.Monsters['F'-'A'].Stats.Dmg.String(); got != "3x4" {
|
||||||
|
t.Errorf("flytrap damage = %q, want the untouched %q", got, "3x4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardKitEquipsTheHero covers the CTRL('I') arm of command.c's
|
||||||
|
// wizard switch: nine raise_level() calls, a (+1,+1) two-handed sword
|
||||||
|
// wielded, and plate mail at o_arm -5 already known and worn.
|
||||||
|
func TestWizardKitEquipsTheHero(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
g := mkWizard(t, 44, 1)
|
||||||
|
p := &g.Player
|
||||||
|
|
||||||
|
if p.Stats.Lvl != 1 {
|
||||||
|
t.Fatalf("hero starts at level %d, not 1", p.Stats.Lvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// raise_level messages queue up --More-- prompts; spaces clear them.
|
||||||
|
setInput(t, g, ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ')
|
||||||
|
g.wizardKit()
|
||||||
|
|
||||||
|
if p.Stats.Lvl != 10 {
|
||||||
|
t.Errorf("level = %d, want 10 after nine raise_level calls",
|
||||||
|
p.Stats.Lvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkKitWeapon(t, g)
|
||||||
|
checkKitArmor(t, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkKitWeapon asserts the sword half of the wizard kit.
|
||||||
|
func checkKitWeapon(t *testing.T, g *RogueGame) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
weap := g.Player.CurWeapon
|
||||||
|
if weap == nil {
|
||||||
|
t.Fatal("no weapon wielded")
|
||||||
|
}
|
||||||
|
|
||||||
|
if weap.Kind != KindWeapon || weap.Which != int(WeaponTwoHandedSword) {
|
||||||
|
t.Errorf("wielding %v which %d, want the two-handed sword",
|
||||||
|
weap.Kind, weap.Which)
|
||||||
|
}
|
||||||
|
|
||||||
|
if weap.HPlus != 1 || weap.DPlus != 1 {
|
||||||
|
t.Errorf("sword is (%+d,%+d), want (+1,+1)", weap.HPlus, weap.DPlus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// init_dam[]'s 2h sword row.
|
||||||
|
if got := weap.Damage.String(); got != "4x4" {
|
||||||
|
t.Errorf("damage = %q, want 4x4", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inPack(g, weap) {
|
||||||
|
t.Error("the sword was never added to the pack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkKitArmor asserts the plate mail half of the wizard kit.
|
||||||
|
func checkKitArmor(t *testing.T, g *RogueGame) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
armor := g.Player.CurArmor
|
||||||
|
if armor == nil {
|
||||||
|
t.Fatal("no armor worn")
|
||||||
|
}
|
||||||
|
|
||||||
|
if armor.Kind != KindArmor || armor.Which != int(ArmorPlateMail) {
|
||||||
|
t.Errorf("wearing %v which %d, want plate mail",
|
||||||
|
armor.Kind, armor.Which)
|
||||||
|
}
|
||||||
|
|
||||||
|
if armor.ArmorClass != -5 {
|
||||||
|
t.Errorf("armor class = %d, want -5", armor.ArmorClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !armor.Flags.Has(Known) {
|
||||||
|
t.Error("the armor is not known")
|
||||||
|
}
|
||||||
|
|
||||||
|
if armor.Count != 1 {
|
||||||
|
t.Errorf("count = %d, want 1", armor.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inPack(g, armor) {
|
||||||
|
t.Error("the armor was never added to the pack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inPack reports whether obj is filed in the hero's pack.
|
||||||
|
func inPack(g *RogueGame, obj *Object) bool {
|
||||||
|
return slices.Contains(g.Player.Pack, obj)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user